Self-hosted Minecraft network on a k3s cluster (MetalLB + Traefik + cert-manager already in place), with a Velocity proxy in front and backend servers (Forge, Paper, Fabric, or Vanilla) behind it. Everything is versioned in this repo: the effective configuration (secrets included) can be recreated on a new machine without retyping any manual command.
All management (create/edit/delete a server, deploy, view pod status and logs) is done through a Rust TUI - see Using the TUI below. That's the recommended way to interact with this repo.
Internet โโโถ LoadBalancer Service (MetalLB, port 25565)
โ
โผ
Velocity pod (proxy)
โ "modern" forwarding + shared secret
โผ
ClusterIP Service <server>:25565 (one per backend server)
โ
โผ
Backend pod (Forge/Paper/Fabric/Vanilla)
- Velocity (
itzg/mc-proxy): config mounted from a ConfigMap (velocity.toml), forwarding secret mounted from a k8s Secret, exposed as aLoadBalancer. - Backends (
itzg/minecraft-server):ONLINE_MODE=FALSE(Velocity handles authentication), exposed internally only (ClusterIP). Forge/Paper/Fabric get the mod/plugin that lets them understand Velocity's "modern" forwarding; Vanilla can't (see Known limitations).
do-games/
โโโ .sops.yaml # SOPS encryption rule (age)
โโโ .gitignore
โโโ servers/ # every game's servers live here, one folder ("group") per game
โ โโโ minecraft/ # the "minecraft" group - currently the only one
โ โโโ namespaces.yaml
โ โโโ kustomization.yaml # generated by the TUI - do not hand-edit
โ โโโ cf-api-key.enc.yaml # optional, SOPS/age-encrypted - only if using CurseForge mods
โ โโโ velocity/
โ โ โโโ settings.toml # proxy settings (motd, max players) - editable
โ โ โโโ configmap.yaml # generated by the TUI
โ โ โโโ deployment.yaml
โ โ โโโ service.yaml
โ โ โโโ forwarding-secret.enc.yaml # shared secret, SOPS/age-encrypted
โ โโโ <server-name>/ # one folder per backend server
โ โโโ server.toml # source of truth - editable (or via the TUI)
โ โโโ deployment.yaml # generated by the TUI
โ โโโ pvc.yaml # generated by the TUI
โ โโโ service.yaml # generated by the TUI
โโโ tui/ # management tool (Rust/Ratatui)
โโโ Cargo.toml
โโโ src/...
Servers are organized into group folders under servers/ (g in the TUI
creates a new one) - in prep for other games later, each getting its own
group alongside minecraft. Today only the minecraft group is wired up
to anything (Velocity proxy, kustomization.yaml, the do-games-minecraft
namespace); a server placed in any other group gets its manifests
generated but isn't deployed by deploy.sh/D yet.
Important rule: every .yaml file under servers/minecraft/ (except
the SOPS secret) is a generated artifact produced by the TUI from the
server.toml files and velocity/settings.toml. Don't hand-edit them -
running cargo run in tui/ rewrites them entirely on every change, so
any manual edit would be lost. The only files meant to be edited directly
are servers/minecraft/<name>/server.toml and
servers/minecraft/velocity/settings.toml (or, more simply, use the TUI,
which edits them for you).
On the machine used to manage/deploy the cluster:
kubectl, configured with a context pointing at the k3s clustersops(โฅ 3.13) andage(age,age-keygen)- An
agekey whose public key matches the one in.sops.yaml, present locally at~/.config/sops/age/keys.txt - Rust/
cargo(to run the TUI) - installable via rustup
To recreate secret access on a new machine: copy
~/.config/sops/age/keys.txt from a machine that already has the key
(this file is never in the repo, see .gitignore). Without this key,
sops cannot decrypt forwarding-secret.enc.yaml and deployment will
fail at the secret step.
cd tui
cargo runPrebuilt binaries (Linux x86_64, macOS x86_64/arm64) are attached to
GitHub Releases - built by .github/workflows/release.yml
whenever a v*.*.* tag is pushed. CI (.github/workflows/ci.yml) runs
cargo fmt --check, clippy, build, and test on every push/PR that
touches tui/.
It can be launched from any working directory (it locates the repo root
automatically), but cargo run itself must be run from tui/ (or use
cargo run --manifest-path tui/Cargo.toml from the repo root). If no
servers/ directory is found in any parent of the current directory, it
falls back to treating the current directory as the repo root and creates
servers/minecraft/ there (with a default Velocity config) instead of
failing - useful when running the built binary outside a checkout, but
usually not what you want; run it from inside the repo.
On startup, it re-reads every servers/*/*/server.toml and immediately
regenerates all manifests - that's expected, it guarantees what's on
disk always matches the current server.toml files exactly.
Servers are listed grouped by folder (minecraft/ today, more later),
ungrouped servers (directly under servers/) first.
| Key | Action |
|---|---|
โ/โ |
change selection |
a |
add a new server (defaults to the selected row's group) |
e / Enter |
edit the selected server |
d |
delete the selected server, or the selected group folder if empty (confirm with y/n) |
g |
create a new group folder |
v |
edit the Velocity proxy settings (MOTD, max players shown) |
D |
deploy (kubectl apply -k servers/minecraft/, then decrypts+applies each secret) |
X |
teardown: delete the whole do-games-minecraft namespace (confirm by typing its name) |
s |
view live pod status |
q |
quit |
A form with these fields: name, group (folder - leave empty for
servers/<name>/, or type an existing/new group name), type (โ/โ to
pick between forge/paper/fabric/vanilla), version, memory (e.g. 8G),
PVC storage size (e.g. 20Gi), extra Modrinth mods (comma-separated),
CurseForge mods (comma-separated, see below), MOTD, difficulty, Hub
(โ/โ to toggle yes/no).
โ/โorTab/Shift+Tab: next/previous fieldโ/โ: change the type or the Hub toggle (only while that field is focused)- type directly to edit a text field,
Backspaceto delete Enter: submit (regenerates all manifests if the input is valid)Esc: cancel without saving anything
Hub: marks this server as where new players land first. It's put at
the front of Velocity's try list (velocity/configmap.yaml) regardless
of alphabetical server-folder ordering - Velocity connects new players to
the first reachable entry in try. If more than one server is marked, they
all end up at the front, in their existing relative order (no error, just
means whichever of them is reachable first wins). Shown as (hub) next to
the server's name on the list screen.
Changing the group of an existing server on save physically moves its folder (server.toml plus its generated manifests) to the new location.
The server name can't be changed once created (the field is locked in edit mode - renaming a server means deleting it and creating another one). The forwarding mod (PCF for Forge, FabricProxy-Lite for Fabric) is added automatically based on the chosen type; no need to list it among the extra mods. If you pick vanilla, a warning is shown: this type cannot do "modern" forwarding with Velocity (see Known limitations).
Paste the CurseForge download URL you get from a mod's page (project page
or a specific file's page, e.g.
https://www.curseforge.com/minecraft/mc-mods/reign-of-nether-rts-in-minecraft/download/8400217)
into the "CurseForge mods" field, comma-separated for more than one. The TUI
normalizes it to slug:fileId (or just slug/projectId if the URL has no
specific file, in which case the newest compatible file is auto-selected)
and writes it to curseforge_files in server.toml; that list becomes the
CURSEFORGE_FILES env var read by itzg/minecraft-server at container
start, which downloads and installs the mods automatically โ no manual
upload to the PVC needed. A slug:fileId/projectId:fileId string typed
directly also works, if you already have it.
This requires a CurseForge API key (mandatory for any CurseForge
auto-download, regardless of format): get one at
https://console.curseforge.com/, then have it SOPS/age-encrypted into
servers/minecraft/cf-api-key.enc.yaml (same pattern as
forwarding-secret.enc.yaml โ a v1/Secret named curseforge-api-key,
namespace do-games-minecraft, with a stringData.apiKey key). D deploy
decrypts and applies it right after the forwarding secret, but only if the
file exists โ clusters with no CurseForge mods configured anywhere don't
need it. Only servers with at least one CurseForge mod get the secret
mounted (as CF_API_KEY_FILE); other servers are unaffected even if the
secret hasn't been set up yet.
A small form with two fields: MOTD and max players (shown). Tab/Shift+Tab
or โ/โ to switch fields, Enter to save (rewrites velocity/ settings.toml and regenerates velocity/configmap.yaml), Esc to cancel.
Unlike a server, Velocity has no d delete action anywhere in the TUI - it's
the always-on proxy for the whole minecraft group, not a list entry.
Runs, in order, kubectl apply -k servers/minecraft/ (creates the
do-games-minecraft namespace and all generated manifests) then
sops --decrypt ... | kubectl apply -f - for each *.enc.yaml secret (the
forwarding secret, and the CurseForge API key if that file exists),
streaming output live to the screen. The kustomize apply must run first
since the secrets target a namespace it creates. Esc returns to the list
without interrupting the deployment if it's still running.
Deletes the entire do-games-minecraft namespace (kubectl delete namespace do-games-minecraft), which cascades to everything in it: Velocity, every
backend server, every server's PVC (world data included - unrecoverable),
and both secrets. There's no equivalent of deploy.sh for this: it's TUI
(or manual kubectl) only.
Confirmation requires typing the namespace name exactly, not just y/n -
this is the only irreversible, whole-cluster action in the TUI, so it gets
more friction than deleting one server. Esc at the confirmation prompt
cancels with nothing changed. Once confirmed, it streams on the same screen
as D deploy; Esc there returns to the list without interrupting it if
still running. servers/ on disk (your server.toml files, generated
manifests) is untouched either way - this only affects the cluster, so D
redeploys everything from scratch afterwards.
Table of pods in the do-games-minecraft namespace, refreshed roughly
every 2 seconds (green = Running, yellow = starting up, red =
error/CrashLoopBackOff). l opens live logs (kubectl logs -f) for the
selected pod. Esc to go back.
- Add a server:
cargo runโaโ fill in the form โEnterโDto deploy. - Change memory or disk size of an existing server:
cargo runโ select the server โeโ edit the field โEnterโDto apply the change (โ ๏ธ shrinking/growing a PVC afterwards may require manual intervention on the Kubernetes side depending on the storage class - online resizing isn't guaranteed by every provisioner). - Delete a server:
dtheny- removes itsservers/minecraft/<name>/folder and takes it out ofkustomization.yamland Velocity's[servers]table, but does not remove resources already applied to the cluster (PVC included): you need to run a deployment (D) afterwards for the removal to take effect, and delete the PVC by hand if you also want to free the disk (kubectl -n do-games-minecraft delete pvc <name>-data).
If Velocity's own LoadBalancer Service (servers/minecraft/velocity/service.yaml,
port 25565) is directly reachable on your network, kubectl -n do-games-minecraft get svc velocity gives you the IP to point a DNS record
at (A + SRV _minecraft._tcp, see below).
If, like this project's own deployment, only ports 80/443 reach your
cluster (see infra/velocity-sslh/), point DNS at whatever IP already
serves your other HTTPS sites instead, on port 443:
- A record:
<your-subdomain>โ that IP - SRV record:
_minecraft._tcp.<your-subdomain>โ0 5 443 <your-subdomain>(so players can type the domain with no port - the SRV target's port is what matters here, not the record type's usual 25565 default)
sslh routes all non-TLS traffic on :443 to Velocity regardless of which
domain was typed - there's only one Minecraft network behind the IP, so no
Traefik/Velocity-side per-domain config is needed.
- Vanilla doesn't support "modern" forwarding with Velocity (no
mod/plugin layer to read the proxy's data): a vanilla server is
declared in
velocity.tomlbut not added to the defaulttrylist, and players may not be able to connect to it at all through the proxy. Prefer fabric + FabricProxy-Lite for a vanilla-like experience with secure forwarding. - The exact key names expected by Proxy-Compatible-Forge,
paper-global.yml, and FabricProxy-Lite were verified against
their documentation but not yet tested on a real first boot - fix in
tui/src/render/{forge,paper,fabric}.rsif the mod/server requires other keys on first boot. - The deploy and status screens require real cluster access (
kubectlconfigured and connected) - not tested end-to-end due to lack of cluster access from the development machine.
See CONTRIBUTING.md for the dev workflow and code style, and MAINTAINERS.md for who to reach out to.
MIT - see LICENSE