This Rust project runs a fleet of Minecraft servers: it downloads the plugins, builds the server templates, applies them and keeps the servers alive, with a REST API, a web interface and an MCP endpoint on top.
It replaces three tools that used to talk to each other through cron timings and a fire and forget Redis channel:
redcraft_plugins_updaterdownloaded the plugin jars into a bucketredcraft_server_templatesbuilt the server tars out of layered configsredcraft_server_managementran the servers and applied those tars
Doing it in one process means the ordering is no longer a matter of hoping the 3am job finished before the 4am one.
tmux, because every server runs in a detached sessiongit, if you want to keep your configuration in a repository (you do)- Java, or whatever your servers actually need to start
- A database, but only if you want one. By default rcmd uses a SQLite file it creates itself
rcmd is meant to be easy on a single server and configurable on a big one. Every external dependency is optional and has a local default, so you can start with nothing and turn things on later.
- Download the latest release from the GitHub releases as
rcmd - Set it as runnable with
chmod +x rcmd - Run
./rcmd initto write aconfig.toml - Run
sudo ./rcmd install --start
That's it. The binary is its own installer: it copies itself to /usr/local/bin/rcmd, writes the systemd unit, reloads systemd and starts the service. There's no package to build and no unit file to keep in sync, because the unit is generated by the same binary it starts.
By default the service runs as whoever owns your servers directory, since that's the user the tmux sessions belong to. Running as anybody else means rcmd can't see them, decides they've all crashed, and starts applying templates over live servers, so it works this out rather than guessing. Use --user if you need something else.
Leave off --start and it writes everything without starting, for when you want to look at the unit first.
Secrets go in /etc/rcmd/env as NAME=value lines. Systemd reads that as root before dropping privileges, so it can stay chmod 600 and owned by root while the config file stays readable by the service user.
To take it away again, sudo rcmd uninstall. That stops the service and removes the unit, and keeps your config, database and audit log. Add --purge if you really want those gone too.
Please notice that neither stopping nor uninstalling stops a single server. They're in their own tmux sessions, and rcmd adopts them again when it comes back, so you can upgrade it in the middle of the afternoon.
On the first run rcmd finds no tokens and mints an admin one, printing it to the log exactly once. Copy it, that's your login. You can make more later with ./rcmd token <name> --role operator.
The web interface is on http://127.0.0.1:8484 by default. Paste the token and you're in.
127.0.0.1 and put something like nginx or Caddy in front of it, because a token going over plain HTTP is a token somebody else has.
Every key in config.toml is optional, these are the defaults:
servers_directoryis where your servers live, by default/mnt/minecraft/servers. Every directory in there is a servertmux_session_prefixis what rcmd names its tmux sessions, by defaultrcsm_so existing sessions keep workingstop_timeout_secsis how long to wait for a clean stop before killing the session, by default 120crash_max_triesandcrash_window_secscontrol the bootloop detection, by default 3 crashes in 120 secondslistenis the HTTP bind, by default127.0.0.1:8484database_urlis by defaultsqlite:///var/lib/rcmd/rcmd.db. Amysql://URL works too, if you already run oneconfig_directoryis where the catalog and the templates are read from, by default/var/lib/rcmd/config. Point it at a git checkout and rcmd follows the branchconfig_branchis the branch it follows, by defaultmainartifact_directoryis where downloaded jars are keptbuild_directoryis where built template tars are written. Pointtemplate_sourceat the same place to have your servers pick them uppipeline_scheduleis when to fetch and rebuild, by default0 3 * * *. Empty it if a CI job drives the pipeline insteadtemplate_sourceis where template tars come from, by defaultnonewhich means rcmd only supervises what is already on diskbackup_destinationis where backups go, by default a local directoryfrozen_serversis a list of servers nothing may restart, whoever asks. Put the one somebody spent a year setting up by hand in herenon_minecraft_serversis a list of processes that aren't Minecraft. They still start and stop, they just have no templatelog_filesis where to look for a server's log, relative to its directory, by default["logs/latest.log", "logs/*.log"]. Tried in order, first match wins,*is the only wildcard. Nothing matching is fine, rcmd falls back to the consolelog_files_by_serveroverrides that for one server, for the process that keeps its log somewhere nobody would guessauto_start_on_bootstarts every stopped server when rcmd starts, off by defaultwarn_commandis what to send before a restart, by defaultsay Restarting in {minutes} minute(s): {reason}.{minutes},{seconds}and{reason}are filled inwarn_secondsis how long to wait after warning, by default 0 which means don't warn at alldiscord_webhook_urlgets a summary after every pipeline run and a shout when a server crashes. Unset means no notifications. It's a credential, so you can put it in the environment asRCMD_DISCORD_WEBHOOK_URLinsteadinstance_nameis what rcmd calls itself in a notification, so two of them posting into one channel can be told apartbackup_scheduleandrestart_scheduleare when to back the fleet up and when to warn everybody and restart it. Both empty by default, because a tool that restarts your servers on a schedule you didn't ask for isn't a good toolpush_windowlimits when rcmd may push to a public repository, see below
Each server also needs a rcsm_config.json in its own directory. If it's missing, rcmd writes one with Aikar's flags so at least the server can start:
start_commandis the command that starts the server, and it's split on spaces, so don't expect shell quoting to workstop_commandis the command to gracefully stop the server, by default it'sstopbut for BungeeCord or Velocity you'll have to set it toendorshutdowndirectories_to_backupis the list of directories to include in a backup, relative to the server directory
Every server gets a detached tmux session named after it. That means rcmd can be restarted, upgraded or crash without touching a single running server, and it will adopt whatever sessions it finds when it comes back. You can still tmux attach -t rcsm_hub and type in the console yourself.
KillMode before you stop it. systemd's default is control-group, which signals every process in the unit's cgroup, and the tmux server usually ends up in there. Stopping the old daemon then kills every server it was supervising, whatever the daemon itself intended. systemctl show <unit> -p KillMode tells you, and a drop-in with KillMode=process plus a daemon-reload fixes it without signalling anything.
Please notice that rcmd targets sessions exactly. tmux normally matches on prefixes, so a command aimed at rcsm_crea would land in rcsm_crea_build_plot if that's the only session starting with that name. That's a fun way to stop the wrong server, so rcmd doesn't do it.
The catalog of plugins and the server templates are YAML in a repository, not rows in a database. rcmd reads that repository and never writes to the branch it follows: it fetches and hard resets, so a change made anywhere else can't quietly become the truth.
Ours is at rcmd_config if you want to see what a real one looks like.
The database holds what happened, not what should happen: which versions were fetched, what a build resolved them to, who did what, and which tokens exist.
Reading needs nothing. The checkout is fetched and hard reset over https, and for a public repository that works with no credential at all.
Writing needs a token, and it's only used for two things: pushing an update/... branch and opening the pull request for it. Without one, rcmd still fetches, builds and deploys, it just can't propose. You'll see could not propose <artifact> <version> in the log and no pull request.
Make a fine-grained token on GitHub, under Settings, Developer settings, Personal access tokens, Fine-grained tokens:
- Repository access: only select repositories, and pick your config repository. Nothing else
- Contents: read and write. This is what pushes the branch
- Pull requests: read and write. This is what opens the pull request
- Metadata: read-only. GitHub adds this on its own and won't let you remove it
- Set an expiry you'll actually remember to rotate
That's the whole list. It doesn't need Actions, Workflows, Administration or anything else.
Then put it in /etc/rcmd/env:
RCMD_CONFIG_GITHUB_TOKEN=github_pat_...
RCMD_CONFIG_GITHUB_TOKEN, NOT in GITHUB_TOKEN. They're two variables on purpose. GITHUB_TOKEN is optional, it only lifts GitHub's rate limit on release lookups, and it gets sent to every repository your catalog fetches from, which are other people's. A token that can write to your config repository has no business being attached to requests going to sixty repositories that only need to be read. Keeping them apart means the write token only ever goes to the one place it can write.
Please notice that a fine-grained token scoped to one repository can still read any public repository, so putting it in GITHUB_TOKEN wouldn't break anything today. It's about where the token travels, not about what it can do.
Please notice that rcmd never edits anything under .github/, so the token doesn't need the Workflows permission. If you ever ask it to propose a change to a workflow file, GitHub will refuse the push and tell you exactly that.
A template pins an artifact to something like 5.5.*. * is the only wildcard and it matches at least one character, so a dot is a dot.
"Newest" means the most recently fetched version that matches, not the highest number. Version strings across sixty plugins don't share a scheme, and comparing 1.21.4-SNAPSHOT-b31 against 26.2 is not something a sort order can do honestly.
Versions are read out of the jar, from plugin.yml, bungee.yml, paper-plugin.yml, velocity-plugin.json, fabric.mod.json, version.json or install.properties depending on what the thing is. Never from the file name, because a plugin with a dash in its name is not a version (ask Multiverse-Core, which spent months at version "Core").
A couple of things carry no version anywhere inside the file. Those get from_source in their post_processors, which takes whatever the source called the release. It's not as good as reading it out of the jar, but it beats guessing.
One job, every night: fetch everything the catalog lists, work out which templates pin something that changed, and rebuild only those.
Nothing is deployed by that. A built tar sits there until a server restarts, which is the same contract as before.
An artifact that comes back with a version outside its pin doesn't get shipped either. rcmd pushes a branch and opens a pin bump for review, because that's a decision somebody should make. Running the same bump twice doesn't force push over a branch you may already be reading.
A few plugins are paid and sit behind a bot check. Those are source: manual in the catalog, and you upload the jar yourself:
curl -T CMI9.8.1.jar -H "Authorization: Bearer <your_token>" http://127.0.0.1:8484/api/v1/artifacts/CMI/versions
It gets deduplicated, pinned, built and deployed like anything else.
A template is a tar. Applying it removes and rewrites every path the tar names, and leaves everything else alone.
Basically, if plugins/ is in the tar then your whole plugins/ directory is deleted and rebuilt from the template. Your worlds, logs and caches aren't in the tar, so they survive.
plugins/ are the usual victim.
Template tars are also never trusted with paths. An entry trying to write outside the server directory is refused instead of applied, because "the bucket is ours" isn't a security model.
rcmd checks every second whether a server it believes is running still has a session. If it doesn't, that's a crash, and rcmd applies a fresh template before starting it again. This is on purpose: a crash is exactly the moment you want the server put back the way the template says it should be.
An explicit restart doesn't do that, unless you ask for it. Restarting from the web interface gives you both buttons.
By default, a server that crashes more than 3 times in 120 seconds is left down and marked as a bootloop, because reviving it a fourth time won't fix it either. Starting it yourself clears that, a crash revive doesn't.
Every change, from every surface, goes through the same check and lands in the same audit log. The web interface, the API and MCP can't drift apart on what's allowed, because there's only one place that decides.
There are three roles. A viewer reads, an operator runs servers and builds, an admin also hands out tokens.
On top of the role, a few rules apply to everybody:
- A stop or a restart is refused while players are connected. You can pass
forceto do it anyway - If
player_count_urlis set and doesn't answer, that counts as busy, not as empty. Guessing wrong drops a full server, and the one time it matters is the one time the API is down - A server in
frozen_serversis never restarted, andforcedoesn't change that - Pushes to a public repository only happen inside
push_window, unless you're an admin
The web interface asks before it draws the buttons, so a restart that would drop two people is greyed out with the reason on it, and one you're allowed to override turns red and asks first.
Tokens are rcmt_ followed by a secret, and only the sha256 is stored. Losing one means making a new one, which is the point.
The console is a tmux pane. It holds a few hundred lines and forgets the rest, and the thing you want to know almost always happened before that, so rcmd reads the log file instead when there is one.
Which file is log_files, because logs/latest.log is a Minecraft convention and not everything rcmd supervises is Minecraft. The answer says which it gave you, file or console, so a surprising result can be explained.
By default it looks for logs/latest.log then any logs/*.log, most recently written one first when several match. A map renderer that logs to the pane and nothing else just gets the pane, which is the right answer for it.
A config file in the repository carries a placeholder like MYSQL_PASSWORD where the real value goes, and the template's secrets block says which environment variable holds it on the machine doing the build. That's what makes the config repository safe to make public.
If a placeholder has no value, rcmd refuses to pack the template and tells you which ones are missing. It doesn't write a tar at all.
password: 'MYSQL_PASSWORD' literally, and a forwarding.secret containing the string VELOCITY_FORWARDING_SECRET, which is not a secret at all: anybody who guesses it can connect straight to a backend claiming to be whoever they like. A tar like that looks completely normal and DESTROYS the config of every server it touches.
So if you're moving template building onto the box, move the secrets first.
rcmd knows how to run servers. It doesn't know who's playing on them, and it shouldn't have to: that answer comes from a proxy, or a plugin on the proxy, or an endpoint somebody wrote, and every network does it differently.
So a plugin is a small named thing that answers one kind of question:
[[plugins]]
kind = "rcc"
url = "https://rcc.redcraft.org/players.json"rcc reads RedCraftChat's players endpoint. json_roster reads anything serving the same shape, {"players": {"hub": [...]}} or a bare {"hub": [...]}.
Plugins are declared in the config and built into the binary rather than loaded at runtime. A single file you can copy onto a box is worth more than being able to drop shared objects next to it.
Please notice that configuring one turns on the restart guard: without a plugin rcmd doesn't know whether anybody is connected, so it doesn't pretend to, and it never refuses. With one, it refuses to drop people unless you pass force.
Anything a player can type ends up in the console, so the API marks console responses as untrusted and the web interface shows a banner. Over MCP it comes back inside a fence that's measured against the content, so somebody typing a code block into chat can't close it and keep writing outside.
Keep that in mind if you ever pipe this into something that reads instructions, like an LLM (yes, that's the point).
The web interface is just a client of this, it has no privileged path of its own. Everything needs an Authorization: Bearer <your_token> header.
GET /api/v1/serverslists every server with its stateGET /api/v1/servers/<server_name>for a single oneGET /api/v1/servers/<server_name>/preflightsays what the rules would allow, without doing anythingPOST /api/v1/servers/<server_name>/start,/stop,/restart,/backupPOST /api/v1/servers/<server_name>/commandwith a JSON body like{"command": "say hello"}GET /api/v1/servers/<server_name>/console?lines=100GET /api/v1/servers/<server_name>/log?lines=200&contains=<text>for the log file, with far more history than the console.containsis a literal substring, not a patternGET /api/v1/playersfor who's connected, when a plugin reports itGET /api/v1/artifactsandGET /api/v1/artifacts/<artifact_name>/versionsPOST /api/v1/artifacts/<artifact_name>/versionsto upload a jar by handGET /api/v1/templatesandPOST /api/v1/templates/<template_name>/buildPOST /api/v1/pipeline/runandPOST /api/v1/config/reloadGET/POST /api/v1/tokens,DELETE /api/v1/tokens/<token_id>GET /api/v1/auditfor what everybody has been doingGET /api/v1/whoami,GET /healthz,GET /version
restart takes {"update_template": true} or false (true by default), {"warn_minutes": 15}, and {"force": true}.
A refusal you can override comes back as a 409 with "overridable": true. One you can't is a 403.
rcmd speaks MCP on POST /mcp, with the same token and the same rules:
claude mcp add --transport http rcmd http://127.0.0.1:8484/mcp --header "Authorization: Bearer <your_token>"
The tools are thin wrappers around the API, so an assistant gets refused for exactly the reasons you would, and the refusal reads the same. There's a check_first tool that returns the verdicts without doing anything, which is worth asking before proposing a restart.
propose_catalog_entry and propose_pin open pull requests against the config repository rather than changing anything directly, so an assistant adding a plugin goes through the same review as anybody else adding one.
rcmd validate <config_directory> loads a config directory and complains about it without running anything: a pin naming an artifact the catalog doesn't have, a template inheriting from something that isn't there, a loop in the chain, a placeholder that appears in no config file.
That last one is worth having. A secret that substitutes to nothing looks exactly like an empty password, and you find out at boot.
Please notice that the config repository can run this in its own CI, so a broken pin gets caught by a robot on a pull request rather than at 3am by you.
Some files come back different after a server has been running, and that's normal:
- Paper, Purpur and Spigot rewrite
bukkit.yml,spigot.yml,purpur.yml,pufferfish.yml,server.propertiesandconfig/paper-global.ymlat boot, filling in defaults and a version marker - Plugins do the same to their own config, LuckPerms and FastAsyncWorldEdit among them
- CMI reads
portals.yml,kits.ymlanddataBaseInfo.ymlat boot and then DELETES them, which makes them look unused right up until you remove one from the template and the portals stop working (yes, that happened)
You are free to suggest changes by opening an issue ticket.
You can also open PRs, remember to bump the version in Cargo.toml before opening a pull request.