Table of Contents
- minecraft-server
- Prerequisites
- Installation
- Configurable values
- Operating notes
- The Java line is the chart's central fact
- Only the newest line is maintained; the older ones are frozen
- Running an older Java line: image.tagOverride
- Nothing reaches this server by itself
- What a green rollout does and does not prove
- The whitelist can lock out everyone, invisibly
- Operators are synchronized from values, not from the game
- Two memory numbers that must not drift apart
- The world lives on one PVC that helm uninstall deletes
- One replica, one world
- Rotating a secret does not restart the pod
- RCON is declared, not published
- The metrics port is declared, not served
- The serverpack init script has one sharp edge
- With a ClusterIP Service, the router's namespace must be allowed
- RCON is the rule with the most security value here, and it costs nothing
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- Renovate
- CI
- See also
minecraft-server
Deploys a Minecraft server (itzg/minecraft-server) as a single-replica StatefulSet on one RWO world volume. No Ingress, no HTTP: clients speak the Minecraft protocol over raw TCP, routed in the cluster by the separate minecraft-router release. Only the newest Java line is maintained out of this directory.
| Image | itzg/minecraft-server — the tag fixes the JVM |
| Versions | not listed on purpose — read the current pair off minecraft-server/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica), plus an init container with curseforgeServerpack |
| Storage | one RWO PVC, <release>-mc-volume-claim, two subPaths (mc-data → /data, mc-modpacks → /modpacks) |
| Ingress | none — ClusterIP by default, NodePort optional |
| External dependencies | Playerdb (operator name resolution), CurseForge or FTB API when a modpack source is on |
| Secrets | 1 × OnePasswordItem, rendered unconditionally |
| Install-test | ci/minecraft-server/ (fixtures + test values + settings.env) |
Prerequisites
- 1Password operator in the cluster, and a 1Password item — the CR is rendered unconditionally, so
secrets.minecraftServerRefmust be set even when nothing consumes a key from it. See the fields below. - A storage class that provisions RWO volumes, named in
minecraft.resources.storageClassName(defaultlonghorn). - A way in for players: either an entry in the
minecraft-routerrelease's static mappings table pointing at this Service, orserver.service.type: NodePort. See Nothing reaches this server by itself. - A
minecraft.versionthat fits the Java line of the image tag. Since chart 5.0.0 the chart checks the pairing and fails the render — see The Java line is the chart's central fact.
Installation
The registry URL and its credentials live in 1Password (item jlab-registry-access: username, password, charts-url) and never in a repository or a shell history file.
helm repo add jlab "$CHARTS_URL" --username "$REG_USER" --password "$REG_PASS"
helm repo update
helm install mc jlab/minecraft-server \
--namespace minecraft --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
secrets:
minecraftServerRef: op://vault/minecraft-server
Everything else has a default that matches the published Java line. helm install prints the chart's NOTES.txt, which renders the same information against the values actually set — including the effective image tag, its Java line, the two memory numbers and whether the whitelist currently locks everyone out. Read it — it is the per-release version of this page.
helm install returns before any of this happens: without --wait, success is reported the moment the objects are applied, while the image is still downloading the server jar (and the whole modpack). Watch the rollout instead of trusting the exit code:
kubectl -n <namespace> rollout status statefulset/<release>-minecraft-server-sfs
What the 1Password item must contain
The chart renders a OnePasswordItem CR; the operator turns it into a Kubernetes Secret whose keys are the field names of the 1Password item. A missing field does not fail the install: the release goes through and the pod then sits in CreateContainerConfigError while the Secret itself exists, so it looks as though the operator had done its job.
| Values key | Kubernetes Secret | Fields |
|---|---|---|
secrets.minecraftServerRef |
<release>-minecraft-server-secret |
rcon-password → RCON_PASSWORD, only when server.rcon.enabled is true; curseforge-api-key → CF_API_KEY, only when curseforge.enabled is true |
With both features off the item may be empty — but the values key is still required. Since chart 6.0.0 required() enforces that: unset, the render aborts instead of producing a CR with an itemPath of null. What the operator actually delivered:
kubectl -n <namespace> get secret <release>-minecraft-server-secret -o jsonpath='{.data}'
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over server.replicas. The world PVC, the Service and the OnePasswordItem stay. |
global |
object | unset | Helm's conventional cross-chart values. Unused by this chart; declared in the schema and kept open so an umbrella install cannot break on it. |
image
| Key | Type | Default | Description |
|---|---|---|---|
image.tagOverride |
string | "" |
Tag of the itzg/minecraft-server image. Empty means the chart's appVersion. An emergency exit, not a knob — see Running an older Java line. Deliberately outside Renovate's reach. The Java line of the effective tag is validated against minecraft.version at render time. |
The whole image block may be null; the tag then falls back to appVersion, which is the pre-5.0.0 behaviour.
secrets
| Key | Type | Default | Description |
|---|---|---|---|
secrets.minecraftServerRef |
string | null |
Required, always — the CR is rendered unconditionally, and required() enforces the value since 6.0.0 (the render aborts, naming the key). op:// item path; the item needs fields only for the features actually switched on. |
server
| Key | Type | Default | Description |
|---|---|---|---|
server.replicas |
int | 1 |
Replica count; 0 is allowed. More than one cannot work — see One replica, one world. |
server.port |
int | 25565 |
Port inside the container. Passed as SERVER_PORT, used as the containerPort and read by the image's mc-health script, so an override stays consistent everywhere. |
server.service.type |
string | ClusterIP |
ClusterIP or NodePort (enumerated in the schema). ClusterIP is the default because routing is done by minecraft-router over the in-cluster DNS name. |
server.service.port |
int | 25565 |
Port the Service publishes inside the cluster. |
server.service.outwardPort |
int | 30565 |
Node port, used only with type: NodePort. Must be inside the cluster's node-port range (30000–32767 by default); NOTES.txt warns when it is not. |
server.motd |
string | Running + modpack name and version, with Java-properties unicode escapes (backslash-u00A7) for the Minecraft colour-code character |
Those escapes must stay literal backslash sequences — server.properties is a Java properties file and unescapes them itself. Copy the default out of values.yaml rather than retyping it. |
server.rcon.enabled |
bool | false |
Only when true does the chart set ENABLE_RCON, declare the RCON container port and wire RCON_PASSWORD from the Secret. |
server.rcon.port |
int | 25575 |
RCON port. The Service does not publish it — see RCON is declared, not published. |
server.initImage |
string | alpine:3.24 |
Init container that downloads and unpacks a CurseForge server pack; used only with curseforgeServerpack.enabled. Pinned per repo convention — auxiliary images are never unpinned. |
server.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
server.resources.requests.cpu |
string/number | "2" |
|
server.resources.requests.ephemeral-storage |
string/number | "100Mi" |
Both spellings (ephemeral-storage, ephemeralStorage) are accepted; the helper normalizes to the Kubernetes key. |
server.resources.requests.memory |
string/number | null — derived |
Deliberately unset: the chart derives it from minecraft.resources.memory (the JVM heap) so heap and container memory cannot drift apart. With the default 4G heap that is a 4Gi request and a 12Gi limit. Setting it explicitly decouples the two — see Two memory numbers that must not drift apart. |
server.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
env
| Key | Type | Default | Description |
|---|---|---|---|
env |
list | [] |
Extra environment variables for the server container, appended to everything the chart sets. Rendered through tpl; value, secretKeyRef, configMapKeyRef forms. Because the chart's own variables come first, an entry repeating one of them is what the image sees last — the escape hatch for anything the chart does not model, and equally the way to break a setting it does. A plain value is readable by anyone who can read pods in the namespace; anything sensitive belongs in valueFrom.secretKeyRef. |
Probes
All three run the image's own mc-health script — a real Minecraft server-list ping against SERVER_PORT via mc-monitor, which is the only health check this image offers. An erased block falls back to these defaults rather than removing the probe.
| Key | Type | Default | Description |
|---|---|---|---|
livenessProbe |
object | exec: ["mc-health"], period 30s, timeout 10s, 3 failures |
|
readinessProbe |
object | exec: ["mc-health"], period 15s, timeout 10s, 3 failures |
|
startupProbe |
object | exec: ["mc-health"], period 15s, timeout 10s, 80 failures (= 20 minutes) |
Gates liveness and readiness, so a slow start can no longer be killed. The budget has to cover a fresh volume: server jar download and, for FTB/CurseForge, the whole modpack, before the world even begins to load. |
securityContext
| Key | Type | Default | Description |
|---|---|---|---|
securityContext.pod |
object | non-root 1000:1000, fsGroup: 1000, fsGroupChangePolicy: OnRootMismatch |
uid/gid 1000 is the image's minecraft user. Starting as that user directly makes the entrypoint take its non-root branch, which skips a recursive chown of /data and a write to /etc/nsswitch.conf — the latter would fail on a read-only root filesystem. OnRootMismatch instead of Always avoids a recursive chown of the whole world on every pod start (minutes on a 32Gi world) while still running once if the volume root lacks the group. This block is read twice: as the pod's securityContext and by the serverpack init script, which chowns migrated files to the same uid/gid. |
securityContext.container |
object | readOnlyRootFilesystem: true, runAsNonRoot: true, all caps dropped, no privilege escalation |
Applied to the server container and the serverpack init container. The read-only root filesystem works because the chart mounts an emptyDir at /tmp: the image writes its server-icon scratch file and mc-server-runner's console named pipe there; everything else goes to the PVC. |
minecraft
| Key | Type | Default | Description |
|---|---|---|---|
minecraft.difficulty |
string | easy |
peaceful, easy, normal, hard (enumerated in the schema). |
minecraft.whitelist.enabled |
bool | false |
Wired to ENABLE_WHITELIST in both positions since chart 2.0.0, which is what makes false actually switch the whitelist off again in a persisted server.properties. Also drives ENFORCE_WHITELIST. true without players locks everyone out, including the ops — see The whitelist can lock out everyone, invisibly. |
minecraft.whitelist.overridePolicy |
string | MERGE |
EXISTING_WHITELIST_FILE — how an already existing whitelist.json is treated: SKIP leaves it alone, MERGE adds these players, SYNCHRONIZE makes it match exactly and removes everyone else. Only applies while enabled. |
minecraft.whitelist.players |
string | null |
Comma-separated names, passed as WHITELIST only while enabled — a non-empty WHITELIST enables the whitelist in the image regardless of ENABLE_WHITELIST=false, so it must stay unset while disabled. |
minecraft.management.opPlayers |
string | null |
Comma-separated operators, passed as OPS with EXISTING_OPS_FILE=SYNCHRONIZE pinned — see Operators are synchronized from values. Every name is resolved against the external Playerdb API at startup, and a name that does not resolve aborts the start. |
minecraft.management.maxPlayers |
int | 10 |
MAX_PLAYERS. |
minecraft.management.forceGameMode |
bool | true |
FORCE_GAMEMODE. |
minecraft.management.gameMode |
string | survival |
creative, survival, adventure, spectator (enumerated). |
minecraft.management.worldSaveName |
string | world |
LEVEL. |
minecraft.management.serverName |
string | mc-server |
SERVER_NAME. |
minecraft.resources.storage |
string | 32Gi |
Size of the world PVC. Growing it works only if the storage class allows expansion; shrinking is refused by Kubernetes and fails the next helm upgrade on the PVC. |
minecraft.resources.storageClassName |
string | longhorn |
Immutable once the volume is bound — changing it does nothing to an existing release. |
minecraft.resources.memory |
string/number | "4" |
JVM heap in gigabytes, passed as MEMORY=<value>G. Also drives the container's memory request/limit unless server.resources.requests.memory is set. |
minecraft.version |
string/number | "26.2" |
Minecraft version, pinned rather than LATEST (which would drift on every restart). Must match the Java line of the effective image tag; checked at render time. Quote it — unquoted YAML reads 1.20 as the number 1.2, and the chart would pass "1.2" to the image as VERSION. |
Modpack sources
curseforge, ftb and curseforgeServerpack are mutually exclusive; enabling more than one fails the render by name. Before chart 1.0.0 it silently rendered a duplicate TYPE entry and the last one won.
| Key | Type | Default | Description |
|---|---|---|---|
curseforge.enabled |
bool | false |
TYPE=AUTO_CURSEFORGE. The image resolves the pack against the CurseForge API at every start, using curseforge-api-key — so each start depends on a third-party API and on that key still being valid; an expired key is a server that stops coming up, not a warning. |
curseforge.forceReload |
bool | false |
CF_FORCE_SYNCHRONIZE. True re-synchronizes the pack on every single start; turn it off again once the reload has happened. |
curseforge.modpack.slug |
string | null |
CF_SLUG. |
curseforge.modpack.versionId |
string/int | null |
CF_FILE_ID. |
curseforge.modpack.exclude |
string/int | null |
CF_EXCLUDE_MODS. |
curseforge.modpack.excludeOverride |
string/int | null |
CF_OVERRIDES_EXCLUSIONS. |
curseforge.modpack.include |
string/int | null |
CF_FORCE_INCLUDE_MODS. |
ftb.enabled |
bool | false |
TYPE=FTBA. No API key, but the start depends on the Feed-the-Beast API being reachable, and the first one downloads the whole pack before the world loads. |
ftb.modpackId |
string/int | null |
FTB_MODPACK_ID. |
ftb.versionId |
string/int | null |
FTB_MODPACK_VERSION_ID. |
curseforgeServerpack.enabled |
bool | false |
TYPE=CURSEFORGE; an init container downloads and the server unpacks. downloadUrl becomes required — the chart fails the render otherwise, rather than letting the init container download an empty URL. |
curseforgeServerpack.downloadUrl |
string | null |
Required when curseforgeServerpack.enabled is true. Rendered into the init.sh ConfigMap. |
curseforgeServerpack.workingDirectory |
string | /data/serverpack |
CF_BASE_DIR, and where the init script unpacks. Also rendered into the ConfigMap. |
A change to either of the last two rolls the pod (checksum/config on the StatefulSet), which is what makes it take effect at all — init.sh is mounted with subPath and only read at pod start. The restart is not free: ~124 s measured for ftb-skies on Longhorn.
networkPolicy
The tightest policy in the repository, and the reason is its probes: all three are exec probes, which run inside the container and put no packet on the network. So there is no kubelet traffic to accommodate on any port, every port can be restricted by source, and there is no probeSources value at all.
- the game port (
server.port): open whenserver.service.typeisNodePort(players arrive from the internet, already masqueraded), and restricted to this namespace plusallowedNamespacesotherwise — which is the default. Both branches read the same value the Service does, so they cannot disagree. - RCON (
server.rcon.port, rendered only whileserver.rcon.enabled) and metrics (19565): always restricted by source.
Outbound: DNS and TCP 443 (Mojang's session server for online-mode, plus resource packs and mods).
| Value | Type | Default | Meaning |
|---|---|---|---|
networkPolicy.enabled |
bool | true |
Renders the policy. Only a boundary where the CNI enforces one — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. |
networkPolicy.allowedNamespaces |
list | [] |
Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the kubernetes.io/metadata.name label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to every pod in it. |
networkPolicy.ingressController |
object | kube-system / app.kubernetes.io/name: rke2-traefik |
The controller allowed to reach the routed port once probeSources restricts it. The two keys render as one from entry, i.e. an AND — which is why this is not a line in allowedNamespaces, where it would open the port to every pod in kube-system. Default measured on the cluster, not assumed. |
networkPolicy.egress.extraPorts |
list | [] |
Additional outbound TCP ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. |
Operating notes
The Java line is the chart's central fact
The JVM is fixed by the image tag, not by a value of the game. appVersion is that tag, and since chart 2.0.0 it is a dated upstream release (2026.8.2-java25) rather than the rolling line tag (java25): rolling tags are rebuilt continuously, so a pod restart could pull a different image than the last one without anything being recorded anywhere. The dated tags are immutable, so the image is pinned by the chart version like every other chart here.
The boundaries are Mojang's, not this chart's — javaVersion.majorVersion from the piston-meta version manifest, swept over all 102 releases on 2026-09-02. There are exactly four transitions and they are monotonic in release order:
| Minecraft | Java line |
|---|---|
| up to 1.16.5 | java8 |
| 1.17 – 1.17.1 | java16 (never published as a chart line here) |
| 1.18 – 1.20.4 | java17 |
| 1.20.5 – 1.21.11 | java21 |
| 26.1 and later | java25 |
Since chart 5.0.0 the pairing is checked at render time and a mismatch fails with both values named and the corrected tag suggested. Two cases pass unchecked on purpose: a tag without a recognizable java<N> part, and a minecraft.version that is not a plain numeric x[.y[.z]] (a snapshot, a pre-release, LATEST). The chart cannot compare those, and a guard that rejects everything it does not understand gets worked around instead of respected. In those two the old failure mode still applies: the release installs, and the container fails afterwards — CrashLoopBackOff once the 20-minute startup budget is spent, with the reason in the log and nowhere else.
Only the newest line is maintained; the older ones are frozen
Up to chart 4.1.0 the chart was published once per line (java8 / java17 / java21 / java25) out of this single directory, which is what made the lines drift apart: whatever change landed while a line happened to lie in the directory reached that line only. That is over (#158). This directory carries exactly one line — the newest — and only it gets chart changes.
The older lines stay published as frozen artifacts and remain installable, but will never be updated again:
| Line | Frozen at chart version |
|---|---|
| java8 | 4.0.0 |
| java17 | 4.0.0 |
| java21 | 4.1.0 |
Running an older Java line: image.tagOverride
Installing a frozen chart version means giving up every later fix, so the line became a decision at rollout time instead of a property of the chart:
image:
tagOverride: "2026.8.2-java21"
minecraft:
version: "1.21.11"
It is called tagOverride, not tag, because it decouples the running image from the chart version: helm list then reports 6.0.0+up2026.8.2.java25 while the pod runs something else, and NOTES.txt says so. Prefer moving to the current line; use this when a modpack pins you to an older Minecraft version.
It is also deliberately outside Renovate's reach — renovate.json5 tracks appVersion in Chart.yaml and nothing else. A tag set here is never bumped by anything; it is yours to review.
Moving across a line boundary is therefore no longer a change of chart version: set image.tagOverride and minecraft.version in the same upgrade. Changing only one now fails the render instead of leaving a broken pod behind.
Nothing reaches this server by itself
There is no Ingress, no certificate and no URL.
ClusterIP(default): the server answers at<release>-minecraft-server-service.<namespace>.svc.cluster.local:<server.service.port>. The way in for players is the separateminecraft-routerrelease, and that router does not discover backends: it routes on the hostname out of the Minecraft handshake, using the static mappings table in its own values file. Until an entry pointing at this Service is added there, the server is unreachable from outside no matter how healthy it is here — and no annotation on this release changes that. (Before chart 1.0.0 anmcRouterIntegration.enabledvalue added a misleading auto-discovery annotation; it is gone, only the service type remains, now named for what it does.)NodePort: players dial<any-node-ip>:<server.service.outwardPort>. Note which of the three ports a player types — notserver.service.port(in-cluster) and notserver.port(in the container).
Either way, a port-forward reaches it without changing the Service:
kubectl -n <namespace> port-forward svc/<release>-minecraft-server-service 25565:25565
What a green rollout does and does not prove
mc-health is a real server-list ping, so Ready means more here than "the process is alive": the server is up and answering pings. It does not mean anyone can play. A server-list ping is answered before any login is attempted, so every reason a player is refused at login is invisible to all three probes — the whitelist is the usual one, and the rollout stays green throughout. The probes also run inside the pod and say nothing about whether players can reach it: with ClusterIP, a missing router mapping is a perfectly healthy server nobody can join.
The whitelist can lock out everyone, invisibly
minecraft.whitelist.enabled: true with players unset emits ENABLE_WHITELIST=true and no WHITELIST, which is white-list=true against an empty whitelist.json: every login is refused, including the operators. That is the image's own semantics and it is intended — and it is exactly the state the probes cannot see. NOTES.txt prints the case explicitly.
server.properties lives on the PVC, which is why this matters historically: before chart 2.0.0 the chart only ever emitted whitelist variables while enabled was true and had no way to say "off", so a release that had once written white-list=true kept it forever. ENFORCE_WHITELIST follows the same switch for the same reason — the image only ever sets it to true and never resets it.
Operators are synchronized from values, not from the game
EXISTING_OPS_FILE=SYNCHRONIZE is pinned: the image makes ops.json on the volume match minecraft.management.opPlayers exactly on every start. A player promoted in-game with /op is not in the value, so the next restart removes them again. Promote people in the values file.
Those names are resolved against the external Playerdb API during startup, and a name that does not resolve aborts the start. A typo is therefore not a missing operator but a server that never comes up — and an outage of that third-party lookup delays every pod start.
Two memory numbers that must not drift apart
minecraft.resources.memory is the JVM heap (MEMORY=<n>G). While server.resources.requests.memory is unset, the chart derives the container request from it and the two cannot drift; the limit follows via limitFactor. Setting the request explicitly decouples them — keep the limit above the heap plus the JVM's own overhead. A heap that does not fit the limit is not a Java OutOfMemoryError: the kernel kills the container, the pod shows OOMKilled, and nothing in the server log explains it.
The world lives on one PVC that helm uninstall deletes
/data and /modpacks are two subPaths of a single ReadWriteOnce claim, <release>-mc-volume-claim. The name is a deliberate exception from the naming scheme (style guide): renaming a PVC makes Kubernetes provision a new, empty volume and orphan the existing world.
It is an ordinary PVC in this release, not a StatefulSet volumeClaimTemplate. helm uninstall therefore deletes it, and with a Delete reclaim policy the world goes with it. Back it up before uninstalling — a resource-policy annotation added to the live PVC afterwards does not help, because Helm reads that annotation from the release manifest.
server.properties lives on this volume too, which is why settings the chart does not send as an environment variable persist across upgrades unchanged.
One replica, one world
Every pod of the StatefulSet mounts the same RWO claim — there is no volumeClaimTemplate and no per-pod volume. Further pods stay stuck before start on a node that does not hold the volume, and two Minecraft servers on one world directory would corrupt it in any case. NOTES.txt says so when server.replicas is raised.
Rotating a secret does not restart the pod
The StatefulSet hashes the rendered OnePasswordItem into checksum/secret-ref, so pointing secrets.minecraftServerRef at a different item rolls the pod. Changing the value inside the same item does not: Helm never sees that content, and the keys are consumed as environment variables, which Kubernetes never refreshes in a running container (style guide §9).
The operator's own operator.1password.io/auto-restart: true annotation does not close that gap here and is therefore deliberately not set: verified in the operator source (main, 9c3b1b9, 2026-08-11), restartWorkloadsWithUpdatedSecrets lists nothing but DeploymentList and getPodTemplate returns "unsupported type" for everything else. This chart is a StatefulSet, so the annotation would do nothing while suggesting the opposite.
kubectl -n <namespace> rollout restart statefulset/<release>-minecraft-server-sfs
Budget for that restart: a full server start — ~124 s measured for ftb-skies on Longhorn.
RCON is declared, not published
With server.rcon.enabled: true the container declares the RCON port and the chart wires RCON_PASSWORD. The Service publishes only the Minecraft port, so RCON is reachable from inside the pod's network and through a forward:
kubectl -n <namespace> port-forward <release>-minecraft-server-sfs-0 25575:25575
Exposing it more widely is a decision to make deliberately: it is a remote console with full server rights, protected by that one password.
The metrics port is declared, not served
The container declares port 19565 named metrics. Declaring a containerPort opens nothing: the Service does not publish it, the chart ships no ServiceMonitor, and it sets no variable that would switch the image's metrics on. Treat that entry as documentation, not as a scrape target.
The serverpack init script has one sharp edge
Its migration branch runs only when curseforgeServerpack.workingDirectory already exists; on a first install the script just downloads and the server unpacks. When it does run, it copies banned-ips.json, banned-players.json, ops.json and whitelist.json out of the backup unconditionally under set -e — so an existing serverpack directory missing any one of those four fails the init container and the pod never starts. Look there first when a serverpack upgrade hangs before the server logs anything:
kubectl -n <namespace> logs <release>-minecraft-server-sfs-0 -c download-serverpack
Its closing chown reads the same pod security context helper as the pod spec (they used to be able to disagree in silence) and is explicitly non-fatal: an unprivileged process cannot chown foreign files, and under set -e a failure there would keep the whole pod from starting.
With a ClusterIP Service, the router's namespace must be allowed
In this cluster players reach the server through minecraft-router, which runs in its own namespace. With server.service.type left at ClusterIP the game port is restricted to this namespace, so that namespace has to be in networkPolicy.allowedNamespaces — otherwise the server is unreachable and the symptom is a connection timeout in a game client with a perfectly healthy pod behind it. The same applies to a Prometheus scraping 19565 from elsewhere.
RCON is the rule with the most security value here, and it costs nothing
RCON is full administrative control of the server. It has no Service and no probe, so restricting it to this namespace is free — and it is gated on server.rcon.enabled, exactly as the containerPort is: a rule for a port the pod does not declare is one nobody can verify, and the render test rejects it.
A NetworkPolicy matches CIDRs and selectors and never hostnames, and this cluster's CNI (Canal — Flannel plus Calico) has no FQDN rule, so an egress rule can only ever name a port and not a destination.
The policy is only a boundary where the CNI enforces one
The API server accepts a NetworkPolicy whatever the cluster does with it. Where the CNI does not enforce policies, everything looks configured and nothing is filtered — which is worse than no policy, because it invites the assumption that something is being filtered. Verify by connecting from an unrelated namespace rather than by reading it off the manifest.
Measured for this cluster: the CNI is Canal (Flannel for the network, Calico for policy), 6/6, and it does enforce. Measured for CI: k3s brings its own policy controller and enforces too — but it exempts the kubelet's probe traffic, so a blocked probe path does not turn the install-test red. What CI does catch is anything the application itself needs.
Upgrades and backup
- Back up the world before uninstalling, and before a line change. The PVC is a chart object, so
helm uninstalltakes it with the release. - A line change is two values in one upgrade (
image.tagOverride+minecraft.version). Changing only one fails the render — which is the improvement; before 5.0.0 it installed silently. - App major jumps go stepwise and through PRs, per Versioning — not through the
change-app-version.ymldispatch workflow, which commits straight to main and bypasses both pipelines. minecraft.resources.storageClassNameis immutable on a bound volume, and shrinkingstoragefails the upgrade on the PVC instead of touching data.
Renovate
The chart's customManagers block tracks appVersion against itzg/minecraft-server with
versioning: regex:^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)-(?<compatibility>java\d+)$
The compatibility group is the point: the tags encode the JRE as a suffix, and without the group Renovate would happily offer a tag from another Java line — a silent runtime switch. image.tagOverride is not tracked by anything. See Renovate.
CI
ci/minecraft-server/ holds three files:
test-values.yaml— a real 2Gi world, a 1G heap and a 500m CPU request so a 4-vCPU runner copes.minecraft.versionis deliberately not set: leaving it at the chart default means the install-test exercises exactly what a user gets, so a line published with a mismatched default version fails CI instead of passing on a CI-only override.opPlayersstays null because the image resolves every name against Playerdb and aborts the start on a miss.server.service.typestaysClusterIP, which is what the real deployments run and which keeps CI free of node-port allocation.fixtures.yaml— a no-provisioner StorageClass namedlonghornplus a pre-bound hostPath PV (the chart's storage class does not exist in k3d), a node-setup Job that pre-creates the directory world-writable (hostPath volumes do not supportfsGroup), and the dummy Secret with both optional keys.settings.env—INSTALL_TIMEOUT=600, because the image is large and the server downloads its vanilla jar on first start before the container settles.
The install-test runs on every PR that touches the chart — see CI/CD.
See also
- Chart style guide — the rules this chart implements; it is the pilot for the strict
values.schema.json - Versioning — what a bump of
6.0.0+up2026.8.2.java25means, and why the+upsuffix normalizes the dash - Renovate — the Java-line compatibility group
- Lessons learned
- Chart index
helm-charts wiki
How we work
How it is built
How charts are written
Charts
- apache-tika
- bentopdf
- bulwark
- calibre-web-automated
- convertx
- cyberchef
- freesailarr
- garage
- gotenberg
- homeassistant
- keycloak
- matrix-synapse
- minecraft-router
- minecraft-server
- outline
- paperless-ngx
- patchmon
- rallly
- rss-bridge
- samba
- satisfactory
- searxng
- stalwart
- sure
- teamspeak-server
- template-chart
- urlaubsverwaltung
- valkey
- voucher-vault
- ytdlp-web-player
- zipline