A CurseForge modpack server running on Kubernetes, exposed through a playit.gg tunnel — no port forwarding required.
The server image is itzg/minecraft-server
in AUTO_CURSEFORGE mode: it downloads the modpack, installs the matching
Forge/Fabric version, and applies the pack's config overrides on its own.
Nothing is built from source and there is no registry.
Internet → playit.gg tunnel → Service (pinned ClusterIP) → server pod → PVC
The playit agent runs as its own Deployment so the tunnel stays connected while the server restarts — which matters, because a modpack upgrade can take several minutes.
minecraft runs a third-party modpack — real, if low-probability, remote-code-execution
surface. This repo assumes that and is built to contain it, not just to run the server:
- Default-deny network policy on both pods.
minecraftandplayitcan each reach only an explicit allowlist of domains/IPs/ports — nothing else, in either direction. See Network security. - Pods run locked down. Non-root where the base image allows it, all Linux capabilities dropped except the handful a startup sequence actually needs, no privilege escalation, no Kubernetes API token mounted (neither pod needs one).
- Images pinned by content digest, not just tag — a mutated or repointed tag can't silently change what gets deployed. See Image digest pinning.
- Secrets encrypted at rest in k3s, optional and scripted. See Secrets encryption at rest.
- Unused attack surface removed, not just firewalled — k3s's bundled
traefik/servicelbaddons are disabled by default since this chart doesn't use either. See Disabled k3s addons. - Optional host-level lockdown for SSH-only hosts (e.g. a Proxmox VM) —
task harden:firewallrestricts the k3s/Cilium control-plane ports to localhost.
None of this is a substitute for keeping k3s, Cilium, and the base images patched, or for securing whatever you SSH into this from — see the Troubleshooting section below for the things deliberately left out of scope.
- A Kubernetes cluster (k3s) with the
local-pathstorage class and Cilium as its CNI —./scripts/setup.shsets both up helm,kubectl,task, andjqon the machine you operate from- A playit.gg account with a TCP tunnel
Run ./scripts/setup.sh to install k3s, Helm, and Task in one shot. It
auto-detects the OS (Debian/Ubuntu and Arch/CachyOS today; see
scripts/setup/ to add more) or takes a target explicitly:
./scripts/setup.sh debian.
On Windows, run it inside WSL2 (Ubuntu) rather than natively — k3s needs a
Linux kernel. WSL doesn't enable systemd by default, which k3s requires; if
it's off, add to /etc/wsl.conf:
[boot]
systemd=truethen run wsl --shutdown from Windows and reopen the distro before running
the script.
Find the pack on CurseForge and take two values from its URLs. Using All the Mods 10 as an example:
-
slug— the path segment right after/minecraft/modpacks/in the pack's main page URL:curseforge.com/minecraft/modpacks/all-the-mods-10 -
fileId— open the pack's Files tab (.../all-the-mods-10/files/all), which lists every release. That list does not show the ID — click into the specific version you want. Its page URL ends in the numeric ID:curseforge.com/minecraft/modpacks/all-the-mods-10/files/8558519
Then edit chart/values.yaml:
modpack:
slug: all-the-mods-10
fileId: "8558519"Both are required — the chart refuses to render without them. fileId is
pinned on purpose so a pod restart can never silently upgrade the pack
underneath a live world. Use a modpack file that includes the pack manifest,
not a "Server Files" download.
Most packs need more than the default memory. Adjust server.memory and keep
resources.limits.memory roughly 2Gi above it.
A resource pack is a texture/UI pack the server pushes to every client on
join. To enable one, edit chart/values.yaml:
resourcePack:
url: https://example.com/my-pack.zip # must be a direct .zip link
sha1: "" # optional but recommended checksum
enforce: false # true kicks clients who decline itLeave url empty to disable it entirely — the default.
cp .env.example .envFill in PLAYIT_SECRET_KEY from the playit.gg dashboard. CF_API_KEY is
optional — the image bundles a working key for installing the pack itself.
Setting your own (free, from the
CurseForge Studio Console) only changes
when the correct Java version gets resolved — see
Java version below.
task upFirst boot downloads the entire modpack and generates a world, so it can take
10+ minutes. Watch it with task logs.
In the playit.gg dashboard, set the tunnel's local destination to the pinned Service address:
10.43.255.65:25565
That value comes from service.clusterIP in chart/values.yaml. It is pinned
so you only ever set it once.
Cilium enforces CiliumNetworkPolicy rules on both pods:
- minecraft: only reachable from the
playitpod on 25565. Outbound traffic is default-deny except an FQDN allowlist (CurseForge, Forge/Fabric, Mojang piston-meta and session-auth) — always open, not just during a deploy, sinceAUTO_CURSEFORGEcalls out toapi.curseforge.comon every server boot to validate the installed modpack, not only the first install. - playit: nothing can reach it at all (it only dials out). Outbound is
scoped to playit.gg's published relay IP ranges and known control domains
(
playit.gg,ply.gg,playit.cloud) — not a broad allow.
If a modpack needs a domain outside the built-in list, add it to
networkPolicy.extraAllowedFQDNs in chart/values.yaml. To find out what's
being blocked, run task cilium:audit-on (logs drops via Hubble without
enforcing, cluster-wide) and watch with task hubble; run
task cilium:audit-off when done.
Playit's egress allowlist (which IP ranges the playit pod can reach) is
synced from playit.gg's published ranges via task playit:sync-ips. Re-run
it occasionally and commit the diff in chart/files/playit-allowed-cidrs.txt
— it isn't run automatically.
WSL2 note: Cilium's eBPF datapath is not an officially supported
environment under WSL2's kernel. It's expected to work but hasn't been
exhaustively verified across WSL2 kernel versions — if task cilium:status
never goes ready, that's the first thing to suspect.
Forge/NeoForge/Fabric all bundle Mixin, which uses an ASM version too old to
even read class files compiled by a too-new JDK — booting the wrong Java
major doesn't just misbehave, it crashes the server outright. Different
modpacks (and different Minecraft versions across a pack's own updates) need
different Java majors, so chart/values.yaml's image.tag can't be a fixed
value.
task up, task upgrade, and task restart all run
scripts/resolve-java-tag.sh before deploying, which resolves the correct
itzg/minecraft-server tag (java17, java21, ...) for the pinned
modpack.fileId and overrides image.tag with it — you never need to pick
this by hand. Resolution has two paths:
CF_API_KEYset in.env— resolves the modpack's Minecraft version via the CurseForge API, on your machine, before any cluster change. The correct tag is applied on the very first deploy.- No
CF_API_KEY— deploys once with whatever tag is currently configured, then polls the pod for/data/.install-curseforge.env. That file is written by the install step as soon as the modpack download+extract finishes, before Forge/NeoForge ever tries to boot the JVM — so it's there even if the wrong Java then crashes the server. Once read, the deploy is redone with the corrected tag. This costs one extra deploy cycle — the pod may crash-loop briefly — whenever the modpack's Minecraft version changes.
Either way, the Minecraft version is resolved to a required Java version via Mojang's own per-version manifest (the same source the real launcher uses), so there's no hand-maintained version-range table to keep up to date.
The resolved tag is cached in .cache/java-tag/<fileId>, so repeat deploys
of the same pinned pack are instant — delete the entry for a fileId to
force re-detection.
| Command | What it does |
|---|---|
task up |
Install or upgrade, waiting for readiness |
task down |
Uninstall (the world PVC is kept) |
task status |
Pods, PVC, and service state |
task mem |
Server pod's memory usage vs its container limit, plus stall time |
task logs |
Stream server logs |
task console |
RCON console — run list, op <player>, etc. |
task restart |
Restart the server pod |
task secrets |
Re-apply the secret after editing .env |
task export |
Snapshot the world to exports/ |
task restore FILE=… |
Replace the world from a snapshot |
task upgrade |
Apply a new pinned fileId (snapshots first) |
task cilium:up |
Install or upgrade Cilium itself |
task cilium:status |
Cilium DaemonSet/operator rollout status |
task cilium:audit-on |
Log policy drops via Hubble cluster-wide without enforcing them |
task cilium:audit-off |
Return Cilium to full enforcement |
task hubble |
Stream live network flows (Ctrl-C to stop) |
task playit:sync-ips |
Fetch playit.gg's published IP ranges (review and commit the diff by hand) |
task secrets:encrypt-rotate |
One-time: migrate existing secrets after enabling k3s secrets-encryption on an already-running cluster |
task harden:firewall |
Optional: restrict k3s/Cilium control-plane ports to localhost via ufw (for SSH-only hosts) |
scripts/setup.sh disables k3s's bundled traefik (ingress controller) and
servicelb (LoadBalancer implementation) addons — this chart uses neither
(the Service is ClusterIP, there's no Ingress resource), and left
enabled, traefik's LoadBalancer Service sits exposed on the host's real
LAN-facing IP for no reason. Same detect/warn/confirm pattern as the other
k3s config changes applies if you're re-running setup on an already-running
k3s that predates this.
scripts/setup.sh configures k3s with secrets-encryption: true, so
Kubernetes Secrets (the tunnel's PLAYIT_SECRET_KEY, CF_API_KEY) are
encrypted in etcd's backing store rather than only base64-encoded.
- Fresh install: nothing else to do — every secret is encrypted from the start.
- Already-running k3s that predates this: re-running
scripts/setup.shdetects the gap, warns it's a cluster-wide restart, and asks to confirm before touching it. After it restarts, runtask secrets:encrypt-rotateonce — new secrets are encrypted automatically the moment the flag is on, but existing ones need this explicit migration pass to actually get rewritten in their encrypted form.
Both images are pinned by content digest, not just tag — chart/values.yaml
requires playit.image.digest (its tag is static, so there's no excuse for
it being unset); image.digest for the minecraft image is resolved fresh
on every task up/upgrade/restart by scripts/resolve-image-digest.sh,
since its tag itself is chosen dynamically per-modpack. This means a tag
being silently repointed at different image content (a compromised registry,
a mutated latest-style tag) can't change what actually gets deployed.
Bump playit.image.tag and re-resolve its digest with:
./scripts/resolve-image-digest.sh ghcr.io playit-cloud/playit-agent <new-tag>Symptom: kubectl -n kube-system logs coredns-... shows repeated
[ERROR] plugin/kubernetes: Failed to watch: ... dial tcp 10.43.0.1:443: i/o timeout, CoreDNS never goes 1/1 Ready, and metrics-server /
local-path-provisioner crash-loop with the same
dial tcp 10.43.0.1:443: i/o timeout against the kubernetes Service
(10.43.0.1 is its ClusterIP).
This is a host firewall problem, not a CiliumNetworkPolicy problem — rule
that out first with kubectl exec -n kube-system <cilium pod> -- cilium-dbg endpoint list; if every endpoint shows POLICY ENFORCEMENT: Disabled, policy isn't involved.
Root cause: ufw (or another host firewall) with a default-deny INPUT
policy and no rule for the pod CIDR (10.0.0.0/24 by default here). Traffic
a pod sends to a Service ClusterIP that resolves to this node's own IP —
which is exactly what kubernetes.default does, since the API server backs
onto the node itself — gets hairpinned by Cilium onto the host stack
(hubble observe shows it as FORWARDED ... to-stack, confirming Cilium
isn't the one dropping it) and then silently dropped by the firewall's
INPUT chain. Pods in hostNetwork: true (there are none in this chart)
wouldn't show the symptom, since same-host traffic goes out OUTPUT, which
ufw allows by default — that asymmetry is the tell.
Fix:
sudo ufw allow from 10.0.0.0/24Then restart whatever was mid-crash when the fix landed — it doesn't self-heal without a kick:
kubectl -n kube-system delete pod -l k8s-app=kube-dns
kubectl -n kube-system delete pod -l k8s-app=metrics-server
kubectl -n kube-system delete pod -l app=local-path-provisionerSymptom: server logs show mc-image-helper excluding a mod at install time
(Excluding mod file '<name>' ... due to configuration for this modpack: <curseforge-url>), and clients that still have that mod installed get kicked
on connect with Forge's ModMismatchDisconnectedScreen — "mismatched mod
channel list", naming that mod specifically.
Root cause: AUTO_CURSEFORGE installs consult a
curated exclude/include list
bundled with mc-image-helper, which globally excludes some mods as
"client-only" to keep them off the server. That default is sometimes wrong —
a mod can register a Forge network channel without declaring itself
absent-from-server-safe, in which case Forge's mod-list handshake hard-rejects
any client that still has it once the server doesn't. The bundled list
already carries per-modpack overrides for a few packs hitting this with
particular-reforged specifically (beyond-depth, ftb-stoneblock-4,
mc-eternal-2) — if your pack isn't one of the overridden ones, you'll hit
the same bug.
Fix: force the server to install the mod anyway, via CF_FORCE_INCLUDE_MODS
(documented for exactly this — "mods incorrectly tagged as client only") in
chart/values.yaml. It takes one or more CurseForge project slugs or numeric
IDs, comma- or space-delimited (same format as modpack.excludeMods):
server:
extraEnv:
CF_FORCE_INCLUDE_MODS: "the-mod-slug"
# or multiple: "the-mod-slug,another-mod-slug"Then task upgrade, same as excluding a mod below —
it flips modpack.forceSynchronize on for the deploy so the exclude/include
list gets re-evaluated. Worth reporting upstream too: if the mod genuinely
needs to be on both sides, that's a gap in itzg's curated list for your
modpack, not just this deployment.
Some mods host their file off CurseForge's own CDN, at a URL controlled by the mod's author. If
that host goes defunct, AUTO_CURSEFORGE's per-mod install step fails on that one file and the
whole modpack install is blocked — even though every other mod is fine.
- Find the mod's CurseForge project slug or numeric ID from its page URL
(
curseforge.com/minecraft/mc-mods/the-mod-slug, or the numeric ID shown further down the page). - Add it to
modpack.excludeModsinchart/values.yaml:modpack: excludeMods: [the-mod-slug]
task upgrade.
Whether it's safe to drop a mod server-side is on you to judge — a client-only mod (UI, shaders
helper) is generally safe to exclude, but a mod with real server-side logic may break the pack or
desync from clients still running the full pack. task upgrade snapshots the world first, so a
bad exclusion is recoverable the same way a bad fileId pin is (see below): task restore FILE=....
You don't need to touch modpack.forceSynchronize yourself — task upgrade flips it on for the
deploy and back off once applied.
- Edit
modpack.fileIdinchart/values.yamland commit it. - Run
task upgrade.
It exports the world before touching anything. If the upgrade goes badly,
task restore FILE=exports/world-<newest>.tar.gz puts the world back.
helm rollback reverts manifests only — it does not revert the world or
the installed mods. Use task restore.
task exportWrites exports/world-<timestamp>.tar.gz. It is safe on a running server:
saving is paused, everything is flushed to disk, the archive is taken, and
saving is re-enabled. The archive includes a PACK.txt naming the modpack
slug and file id, since a modded world needs the identical pack build to open.
- RCON needs no configuration. It is enabled by default, the image generates
a password, and it is never exposed outside the pod —
task consolereaches it throughkubectl exec. task downkeeps the world. To delete it deliberately:kubectl delete pvc minecraft-data.- Everything deploys to the
defaultnamespace. - By using this project you accept the Minecraft EULA.
AGPL-3.0. Provided as-is, with no warranty — see the license for the full disclaimer. You're responsible for your own deployment, secrets, and whatever modpack/mods you choose to run.