Custom Modules
Guide to building custom Nimbus controller modules with commands, API routes, database access, and event handling.
Nimbus supports a dynamic module system for extending the controller with
custom functionality. Modules are standalone JAR files loaded at startup from
the modules/ directory. They can register console commands, REST API routes,
plugin deployments, doctor checks, event formatters, and versioned database
migrations — and interact with core services.
The module API lives in nimbus-module-api and has no dependency on
nimbus-core, keeping your module lightweight and decoupled. For typed
access to core internals (e.g. EventBus, ServiceManager), add a
compileOnly dependency on nimbus-core and use context.service<T>().
First- and third-party modules use exactly the same API. Every shipped module
(nimbus-module-perms, nimbus-module-scaling, nimbus-module-players,
nimbus-module-punishments, nimbus-module-resourcepacks,
nimbus-module-backup, nimbus-module-display) implements NimbusModule
against the public ModuleContext — read their source under modules/* as
reference.
Quick Start
Project Setup
Create a new Gradle project with the following build.gradle.kts:
plugins {
kotlin("jvm") version "2.1.10"
}
repositories {
mavenCentral()
}
dependencies {
// Module API — the only required dependency
compileOnly(files("libs/nimbus-module-api.jar"))
// Needed for Ktor Route type and Exposed Database type
compileOnly("io.ktor:ktor-server-core:3.1.1")
compileOnly("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.1")
compileOnly("org.jetbrains.exposed:exposed-core:0.57.0")
compileOnly("org.jetbrains.exposed:exposed-jdbc:0.57.0")
compileOnly("org.slf4j:slf4j-api:2.0.16")
}
kotlin {
jvmToolchain(21)
}
tasks.jar {
// Include resources (ServiceLoader config, module.properties)
from(sourceSets.main.get().output)
}If you are developing inside the Nimbus monorepo, replace the file dependency with a project reference:
compileOnly(project(":nimbus-module-api"))To access core types directly (e.g. EventBus, ServiceRegistry), add a compile-only dependency on nimbus-core:
compileOnly(project(":nimbus-core"))Minimal Module
package dev.example.mymodule
import dev.nimbuspowered.nimbus.module.ModuleContext
import dev.nimbuspowered.nimbus.module.NimbusModule
class MyModule : NimbusModule {
override val id = "my-module"
override val name = "My Module"
override val version = "1.0.0"
override val description = "A minimal example module"
override suspend fun init(context: ModuleContext) {
// Register commands, routes, initialize state
}
override suspend fun enable() {
// Called after all modules have been initialized
}
override fun disable() {
// Clean up resources on shutdown
}
}Required Resources
Your JAR must include a module.properties resource file:
module.properties — metadata and entry point used by the module loader:
id=my-module
name=My Module
description=A minimal example module
default=false
main_class=dev.example.mymodule.MyModule| Property | Required | Description |
|---|---|---|
id | Yes | Unique module identifier |
name | Yes | Human-readable name |
description | No | Short description for setup wizard |
default | No | Whether enabled by default in setup wizard |
main_class | Recommended | Fully qualified class name of the NimbusModule implementation |
min_nimbus_version | No | Minimum compatible Nimbus version (e.g. 0.4.0) |
dependencies | No | Comma-separated list of required module IDs |
plugins | No | Plugin deployments (DisplayName:fileName:resourcePath) |
If main_class is not set, Nimbus falls back to the legacy META-INF/services/dev.nimbuspowered.nimbus.module.NimbusModule ServiceLoader descriptor.
Lifecycle
Modules follow a three-phase lifecycle:
loadAll() → init(context) → enable() → disable()
↑ ↑ ↑
Per module, After ALL Reverse order
in load order modules init on shutdown-
Load —
ModuleManager.loadAll()scansmodules/for JAR files, creates aURLClassLoaderper JAR, and discoversNimbusModuleimplementations viaServiceLoader. Duplicate module IDs are rejected. -
Init —
init(context)is called on each module in load order. This is where you register commands, routes, create database tables, and set up managers. TheModuleContextprovides access to core services. -
Enable —
enable()is called after all modules have been initialized. Use this for cross-module interactions or deferred setup that depends on other modules being ready. -
Disable —
disable()is called during Nimbus shutdown, in reverse load order. Cancel coroutines, close connections, and release resources here.
ModuleContext API
The ModuleContext is passed to init() and provides access to the Nimbus runtime:
| Member | Type | Description |
|---|---|---|
scope | CoroutineScope | Coroutine scope tied to the Nimbus lifecycle. Launch jobs here. |
baseDir | Path | Root directory of the Nimbus installation. |
templatesDir | Path | Templates directory (templates/). |
database | Database | Exposed database instance for direct SQL access. |
moduleConfigDir(id) | Path | Returns config/modules/<id>/, created if missing. |
registerCommand(cmd) | — | Register a console command. |
unregisterCommand(name) | — | Unregister a command by name. |
registerRoutes(block, auth) | — | Register API routes with the given auth level. |
registerPluginDeployment(d) | — | Register a plugin JAR to deploy to services (backend or Velocity — see below). |
registerEventFormatter(type, fn) | — | Register a console formatter for module events. |
registerCompleter(cmd, fn) | — | Register tab completion for a command. |
getService(type) | T? | Retrieve a core service by class. Returns null if unavailable. |
registerService(type, instance) | — | Register a late-initialized service (used by core for ServiceManager). |
registerMigrations(list) | — | Register versioned database migrations (see below). |
registerDoctorCheck(check) | — | Register a diagnostic check for doctor / GET /api/doctor. |
Convenience Extension
The service<T>() reified extension avoids passing Class objects:
import dev.nimbuspowered.nimbus.module.service
val eventBus = context.service<EventBus>()!!
val registry = context.service<ServiceRegistry>()!!Console Commands
Implement ModuleCommand to add commands to the Nimbus console:
import dev.nimbuspowered.nimbus.module.ModuleCommand
class HelloCommand : ModuleCommand {
override val name = "hello"
override val description = "Say hello"
override val usage = "hello [name]"
override suspend fun execute(args: List<String>) {
val target = args.firstOrNull() ?: "World"
println("Hello, $target!")
}
}Register it during init():
override suspend fun init(context: ModuleContext) {
context.registerCommand(HelloCommand())
}The command is immediately available in the Nimbus console. To remove it later:
context.unregisterCommand("hello")If your module has a compileOnly dependency on nimbus-core, you can implement the core Command interface instead of ModuleCommand. Both work with registerCommand().
API Routes
Register HTTP endpoints using the Ktor routing DSL. Routes are mounted on the Nimbus API server.
Route Registration
import dev.nimbuspowered.nimbus.module.AuthLevel
override suspend fun init(context: ModuleContext) {
context.registerRoutes({
myModuleRoutes()
}, AuthLevel.SERVICE)
}Route Definition
Define routes as an extension function on Route:
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.server.routing.*
fun Route.myModuleRoutes() {
route("/api/my-module") {
get {
call.respond(mapOf("status" to "ok"))
}
post("/action") {
val body = call.receive<ActionRequest>()
// handle request
call.respond(mapOf("result" to "done"))
}
}
}Auth Levels
The AuthLevel enum controls authentication for your routes:
| Level | Description |
|---|---|
NONE | Public, no authentication required. |
SERVICE | Requires a service-level or admin API token. Default. |
ADMIN | Requires the master admin API token. |
Database Access
Modules can use the Exposed ORM to create tables and run queries. The Database instance is shared with Nimbus core.
Define Tables
import org.jetbrains.exposed.sql.Table
object MyRecords : Table("my_module_records") {
val id = integer("id").autoIncrement()
val name = varchar("name", 128)
val value = integer("value")
val createdAt = long("created_at")
override val primaryKey = PrimaryKey(id)
}Create Tables
Use DatabaseManager.createTables() during init:
override suspend fun init(context: ModuleContext) {
val db = context.service<DatabaseManager>()!!
db.createTables(MyRecords)
}Run Queries
Use transaction with the database instance:
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
fun insertRecord(name: String, value: Int) {
transaction(context.database) {
MyRecords.insert {
it[MyRecords.name] = name
it[MyRecords.value] = value
it[MyRecords.createdAt] = System.currentTimeMillis()
}
}
}Core Services
Use getService() or service<T>() to access core Nimbus components. Available services:
| Class | Description |
|---|---|
dev.nimbuspowered.nimbus.event.EventBus | Publish and subscribe to events. |
dev.nimbuspowered.nimbus.database.DatabaseManager | Database operations, table creation. |
dev.nimbuspowered.nimbus.service.ServiceRegistry | Query running services, get by name or group. |
dev.nimbuspowered.nimbus.service.ServiceManager | Start/stop services programmatically. Available after initial boot (lazy access in background loops). |
dev.nimbuspowered.nimbus.group.GroupManager | Access server group configurations. |
dev.nimbuspowered.nimbus.config.NimbusConfig | Read the main Nimbus configuration. |
Accessing core types requires compileOnly(project(":nimbus-core")) in your build. Without it, use getService() with the fully qualified class name via reflection.
Example using EventBus:
import dev.nimbuspowered.nimbus.event.EventBus
import dev.nimbuspowered.nimbus.event.NimbusEvent
import dev.nimbuspowered.nimbus.module.service
override suspend fun init(context: ModuleContext) {
val eventBus = context.service<EventBus>()!!
eventBus.on<NimbusEvent.ServiceReady> { event ->
println("Service ready: ${event.serviceName} (group ${event.groupName})")
}
}Events are nested inside the sealed NimbusEvent class (not a flat Events object). Lifecycle classes include ServiceStarting, ServiceReady, ServiceDraining, ServiceStopping, ServiceStopped, ServiceCrashed, ServiceRecovered. Use event.serviceName — there is no event.service object. See the full list in the Events reference.
Plugin Deployments
Modules commonly ship a companion server-side plugin — a permissions plugin, a punishments enforcer, a resource-pack applier, etc. Instead of asking operators to drop JARs into their templates by hand, register a PluginDeployment: Nimbus bundles the JAR as a resource inside your module, and ServiceFactory.resolveModulePlugins deploys it to the service's plugin directory on every prepare (with REPLACE_EXISTING, so deleted or modified copies self-heal on next start).
import dev.nimbuspowered.nimbus.module.PluginDeployment
import dev.nimbuspowered.nimbus.module.PluginTarget
override suspend fun init(context: ModuleContext) {
// Paper / Purpur / Pufferfish / Leaf / Folia — the usual backends.
context.registerPluginDeployment(PluginDeployment(
resourcePath = "plugins/my-plugin.jar",
fileName = "my-plugin.jar",
displayName = "MyPlugin",
minMinecraftVersion = 17, // Optional: deploy only on 1.17+
foliaRequiresPacketEvents = false,
target = PluginTarget.BACKEND // Default.
))
// Velocity proxies — different JAR format (uses @Plugin, not plugin.yml).
context.registerPluginDeployment(PluginDeployment(
resourcePath = "plugins/my-proxy-plugin.jar",
fileName = "my-proxy-plugin.jar",
displayName = "MyProxyPlugin",
target = PluginTarget.VELOCITY
))
}| Field | Purpose |
|---|---|
resourcePath | Path inside your module JAR (e.g. plugins/my-plugin.jar). Use Gradle resource copying to embed the JAR |
fileName | Target filename in the service's plugins/ directory |
displayName | Shown in console logs when the plugin is deployed |
minMinecraftVersion | Minor version gate (e.g. 20 for 1.20+); null means no restriction |
foliaRequiresPacketEvents | If true, PacketEvents is auto-deployed alongside this plugin on Folia |
target | PluginTarget.BACKEND (default) for Paper-family, PluginTarget.VELOCITY for proxies |
From v0.9.0 on, Nimbus never writes module-registered plugins into templates/global/plugins/ or templates/global_proxy/plugins/. Deployment is runtime-only, per service prepare — templates stay fully user-owned. If your module used to depend on the old template-install flow, remove it; the runtime path does the same job with better hygiene (no stale copies left behind when a module is uninstalled).
Database Migrations
Register versioned schema changes via ModuleContext.registerMigrations(list)
during init(). Core's MigrationManager applies every registered migration
in ascending version order after all modules finish init().
import dev.nimbuspowered.nimbus.module.Migration
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.Transaction
object MyV1_Baseline : Migration {
override val version = 9000
override val description = "Create my_module_records baseline table"
override val baseline = false // true = mark as already applied on existing DBs
override fun Transaction.migrate() {
SchemaUtils.create(MyRecords)
}
}Then register:
override suspend fun init(context: ModuleContext) {
context.registerMigrations(listOf(MyV1_Baseline, MyV2_AddIndex))
}Migration number ranges
Migrations are global — pick a range that doesn't collide with anyone else:
| Range | Owner |
|---|---|
1–999 | nimbus-core |
1000–1999 | nimbus-module-perms |
2000–2999 | nimbus-module-scaling |
3000–3999 | nimbus-module-players |
4000–4999 | nimbus-module-display |
5000–5999 | nimbus-module-punishments |
6000–6999 | nimbus-module-resourcepacks |
7000–7999 | nimbus-module-backup |
8000–8999 | reserved |
9000+ | Third-party modules (pick a band unlikely to collide) |
Migrations are one-way: Nimbus doesn't roll back. Always ship additive
migrations (new tables, new nullable columns, new indexes). Destructive
operations require a follow-up migration; there's no down().
baseline flag
Set baseline = true when you're retrofitting an existing production
schema — the migration is marked as applied on existing databases without
running. Fresh installs still execute it. Used by core and every shipped
module's V1.
Doctor Checks
Register a DoctorCheck to have your module contribute to doctor /
GET /api/doctor. Checks must be fast (<1s) and read-only.
import dev.nimbuspowered.nimbus.module.DoctorCheck
import dev.nimbuspowered.nimbus.module.DoctorFinding
import dev.nimbuspowered.nimbus.module.DoctorLevel
class MyDoctorCheck(private val storageDir: Path) : DoctorCheck {
override val section = "MyModule"
override suspend fun run(): List<DoctorFinding> = buildList {
if (!Files.exists(storageDir)) {
add(DoctorFinding(
level = DoctorLevel.FAIL,
message = "Storage dir missing: $storageDir",
hint = "Check permissions on the Nimbus data directory."
))
}
}
}Register:
context.registerDoctorCheck(MyDoctorCheck(storageDir))Findings are grouped by section and rendered in the console/API report.
Event Formatters
For module-fired events (NimbusEvent.ModuleEvent("my-module", "SOMETHING", data)), register a formatter so console output looks native:
context.registerEventFormatter("SOMETHING_HAPPENED") { data ->
"${success("✓")} thing happened to ${BOLD}${data["target"]}${RESET}"
}The formatter receives the event's data: Map<String, String> and returns
an ANSI-formatted string. See dev.nimbuspowered.nimbus.console.ConsoleFormatter
for the standard color helpers (if you depend on nimbus-core).
Late-Initialized Services
Some core services (notably ServiceManager) are created after modules
init(). Access them lazily:
override suspend fun init(context: ModuleContext) {
// Schedule a background loop that uses ServiceManager once available
context.scope.launch {
delay(10_000)
val sm = context.service<ServiceManager>() ?: return@launch
// … use sm …
}
}Core calls ModuleContext.registerService(ServiceManager::class.java, ...)
after service-layer wiring so that subsequent service<ServiceManager>()
calls resolve. Modules can use the same mechanism to expose their own
manager classes (e.g. PunishmentManager, BackupManager, ResourcePackManager
all call registerService() so the dashboard-side code can query cached state).
Building and Deploying
-
Build your module JAR:
Terminal ./gradlew jarIf you use
com.github.johnrengelman.shadow, exclude core runtime dependencies so your fat JAR doesn't bundle Ktor, Exposed, kotlinx, or SLF4J — Nimbus core already provides them. Bundling duplicates causesLinkageErrorat module load.build.gradle.kts (shadowJar) tasks.shadowJar { dependencies { exclude(dependency("io.ktor:.*")) exclude(dependency("org.jetbrains.kotlinx:.*")) exclude(dependency("org.jetbrains.exposed:.*")) exclude(dependency("org.slf4j:.*")) exclude(dependency("ch.qos.logback:.*")) } }Only shadow your own third-party transitive dependencies (domain-specific libraries Nimbus doesn't ship).
-
Copy the JAR to the Nimbus
modules/directory:Terminal cp build/libs/my-module.jar /path/to/nimbus/modules/ -
Restart Nimbus. The module is loaded automatically at startup.
Module load/enable status is logged to the console:
[INFO] Found 1 module JAR(s) in modules/
[INFO] Loaded module: My Module v1.0.0 (my-module)
[INFO] Initialized module: My ModuleComplete Example
A module that tracks service uptime with a console command, REST endpoint, and database table.
UptimeModule.kt
package dev.example.uptime
import dev.nimbuspowered.nimbus.database.DatabaseManager
import dev.nimbuspowered.nimbus.event.EventBus
import dev.nimbuspowered.nimbus.event.NimbusEvent
import dev.nimbuspowered.nimbus.module.*
class UptimeModule : NimbusModule {
override val id = "uptime"
override val name = "Uptime Tracker"
override val version = "1.0.0"
override val description = "Tracks service start/stop times"
private lateinit var context: ModuleContext
override suspend fun init(ctx: ModuleContext) {
context = ctx
// Create database table
val db = ctx.service<DatabaseManager>()!!
db.createTables(UptimeRecords)
// Listen for service events
val eventBus = ctx.service<EventBus>()!!
eventBus.on<NimbusEvent.ServiceReady> { event ->
UptimeStore.recordStart(ctx.database, event.serviceName)
}
eventBus.on<NimbusEvent.ServiceStopped> { event ->
UptimeStore.recordStop(ctx.database, event.serviceName)
}
// Register console command
ctx.registerCommand(UptimeCommand(ctx.database))
// Register API route
ctx.registerRoutes({
uptimeRoutes(ctx.database)
}, AuthLevel.SERVICE)
}
override suspend fun enable() {}
override fun disable() {}
}UptimeRecords.kt
package dev.example.uptime
import org.jetbrains.exposed.sql.Table
object UptimeRecords : Table("uptime_records") {
val id = integer("id").autoIncrement()
val serviceName = varchar("service_name", 64)
val startedAt = long("started_at")
val stoppedAt = long("stopped_at").nullable()
override val primaryKey = PrimaryKey(id)
}UptimeStore.kt
package dev.example.uptime
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
object UptimeStore {
fun recordStart(db: Database, name: String) {
transaction(db) {
UptimeRecords.insert {
it[serviceName] = name
it[startedAt] = System.currentTimeMillis()
}
}
}
fun recordStop(db: Database, name: String) {
transaction(db) {
UptimeRecords.update({
(UptimeRecords.serviceName eq name) and UptimeRecords.stoppedAt.isNull()
}) {
it[stoppedAt] = System.currentTimeMillis()
}
}
}
fun getAll(db: Database): List<UptimeEntry> = transaction(db) {
UptimeRecords.selectAll().map {
UptimeEntry(
it[UptimeRecords.serviceName],
it[UptimeRecords.startedAt],
it[UptimeRecords.stoppedAt]
)
}
}
}
data class UptimeEntry(val serviceName: String, val startedAt: Long, val stoppedAt: Long?)UptimeCommand.kt
package dev.example.uptime
import dev.nimbuspowered.nimbus.module.ModuleCommand
import org.jetbrains.exposed.sql.Database
class UptimeCommand(private val db: Database) : ModuleCommand {
override val name = "uptime"
override val description = "Show service uptime records"
override val usage = "uptime [service]"
override suspend fun execute(args: List<String>) {
val records = UptimeStore.getAll(db)
val filtered = if (args.isNotEmpty()) {
records.filter { it.serviceName == args[0] }
} else {
records
}
if (filtered.isEmpty()) {
println("No uptime records found.")
return
}
for (record in filtered) {
val duration = (record.stoppedAt ?: System.currentTimeMillis()) - record.startedAt
val minutes = duration / 60_000
val status = if (record.stoppedAt == null) "running" else "stopped"
println("${record.serviceName}: ${minutes}m ($status)")
}
}
}UptimeRoutes.kt
package dev.example.uptime
import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.jetbrains.exposed.sql.Database
fun Route.uptimeRoutes(db: Database) {
route("/api/uptime") {
get {
val records = UptimeStore.getAll(db)
call.respond(records.map { mapOf(
"service" to it.serviceName,
"startedAt" to it.startedAt,
"stoppedAt" to it.stoppedAt
)})
}
}
}Resource Files
src/main/resources/module.properties
id=uptime
name=Uptime Tracker
description=Tracks service start/stop times
default=false
main_class=dev.example.uptime.UptimeModuleProject Structure
my-uptime-module/
├── build.gradle.kts
└── src/main/
├── kotlin/dev/example/uptime/
│ ├── UptimeModule.kt
│ ├── UptimeRecords.kt
│ ├── UptimeStore.kt
│ ├── UptimeCommand.kt
│ └── UptimeRoutes.kt
└── resources/
└── module.propertiesAdvanced Extension Points
Beyond commands, routes, migrations, and doctor checks, modules can plug into a couple of service-level hooks. These are opt-in — the default behaviour doesn't change when nothing's registered.
LocalServiceHandleFactory
Swap out the default ProcessHandle with something else — a container, a VM, a remote sandbox. The Docker module is the reference implementation.
interface LocalServiceHandleFactory {
fun isAvailable(): Boolean
suspend fun create(
service: Service,
workDir: Path,
command: List<String>,
env: Map<String, String>,
dockerConfig: DockerServiceConfig,
readyPattern: Regex?
): ServiceHandle
/** Reattach to surviving instances on controller restart. */
fun recover(): Map<String, ServiceHandle> = emptyMap()
}Register via context.registerService(LocalServiceHandleFactory::class.java, impl). ServiceManager.startLocalService calls isAvailable() on every start and falls back to a bare process if the factory is down — so a temporarily offline backend doesn't wedge the controller.
ServiceMemorySource
Add a smarter memory reader alongside the default /proc/<pid>/status reader. Useful when you know something the default reader doesn't — e.g. the Docker module returns cgroup memory (the full container RSS) via docker stats, which catches sidecar processes the plain JVM PID would miss.
interface ServiceMemorySource {
fun readRssMb(service: Service): Long?
}Register via ServiceMemoryResolver.registerSource(source). The resolver queries every registered source in order and takes the first non-null result before falling back to /proc. Return null when the source doesn't apply to a given service — do not return 0L just because you don't know.
ServiceHandle.kind
Every ServiceHandle exposes a kind: String property (default "process"). Override it to tag your handle — the dashboard reads backedBy on /api/services to render badges, and core uses it for nothing else. Keep the value short and lowercase (docker, podman, firecracker).