Table of Contents
- minecraft-router
- Prerequisites
- Installation
- Configurable values
- Operating notes
- The hostname routes, not the port
- mappings is the only source of routes
- A routing change is a planned outage
- What a Ready router does not prove
- Writes over the API do not survive a restart
- More than one replica
- server.env cannot change the ports
- The backend rule is by port, not by destination
- The policy names pod ports, never the NodePorts
- The probed port is open inside the cluster by default
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
minecraft-router
Deploys mc-router as a stateless Deployment in front of several Minecraft servers. The router reads the hostname out of the Minecraft handshake and proxies the raw TCP connection to the backend named in its routing table. It is not a web service: no Ingress, no certificate, no URL.
| Image | docker.io/itzg/mc-router |
| Versions | not listed on purpose — read the current pair off minecraft-router/Chart.yaml, see Chart index |
| Workloads | Deployment (1 replica) |
| Storage | none — the only mount is the routing ConfigMap |
| Ingress | no; a NodePort Service, TCP only |
| External dependencies | the backend Minecraft servers (their Services) |
| Secrets | none |
| Install-test | ci/minecraft-router/ (test values only, no fixtures) |
Prerequisites
- A cluster whose API server runs with an extended
--service-node-port-range. The defaultserver.outwardPortis25565— the port Minecraft clients dial — and that is outside the Kubernetes default range30000-32767. Without the extension the API server rejects the Service, and the release fails on that one object while everything else applies. - Backend Minecraft servers that already exist, each reachable under a cluster-internal Service name. The router dials
internalUrlby name; nothing in this chart starts, watches or health-checks a backend. - DNS for every mapped hostname pointing at a node of this cluster (or at whatever sits in front of it). Routing is decided by the hostname the player typed, so a connection to a node's bare IP carries no hostname and is routed nowhere.
- No 1Password item, no cert-manager, no storage class.
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-router jlab/minecraft-router \
--namespace minecraft --create-namespace \
-f my-values.yaml
Minimal my-values.yaml:
mappings:
- domain: mc.example.com
internalUrl: survival-service.minecraft.svc.cluster.local
- domain: creative.example.com
internalUrl: creative-service.minecraft.svc.cluster.local
port: 25566
mappings is the only value without a default. A missing or empty list aborts the render with a message naming the value — deliberately loud, because a router without routes is not operable.
helm install prints the chart's NOTES.txt, which renders the routing table actually in effect. Read it — it is the per-release version of this page.
Configurable values
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the Deployment with replicas: 0 without uninstalling. Takes precedence over server.replicas. Service and ConfigMap stay. |
mappings
| Key | Type | Default | Description |
|---|---|---|---|
mappings |
list | null |
Required, and must not be empty. The routing table, and the only source of routes in this release. Rendered into the ConfigMap <release>-minecraft-router-configmap (key custom-routing-config.json). A change here rolls the pod — see A routing change is a planned outage. |
mappings[].domain |
string | — | Required per entry. Client-facing hostname the player types; matched against the Minecraft handshake. |
mappings[].internalUrl |
string | — | Required per entry. Cluster-internal address of the backend. Point it at the backend's Service — the name survives a restart of the Minecraft server, a pod IP does not. |
mappings[].port |
int | server.backendPort |
Overrides the backend port for this single entry. |
server
| Key | Type | Default | Description |
|---|---|---|---|
server.replicas |
int | 1 |
Replica count; 0 is allowed. See More than one replica. |
server.outwardPort |
int | 25565 |
nodePort of the Service — the port a player dials. Outside the default node-port range on purpose; do not "fix" it into 30000-32767 unless you also change what the clients connect to. |
server.port |
int | 25565 |
Port mc-router listens on (--port); containerPort and the Service's cluster-internal port. Changing it does not change what a player dials. |
server.backendPort |
int | 25565 |
Default port of the backends, used for every mapping without its own port. |
server.apiPort |
int | 8080 |
Port of the mc-router HTTP API (--api-binding). Not published by the Service; only the probes talk to it. |
server.resources.limitFactor |
number | 3 |
Memory and ephemeral-storage limits are computed as factor × request. |
server.resources.requests |
object | cpu: 100m, memory: 50Mi, ephemeral-storage: 50Mi |
|
server.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here. |
server.livenessProbe |
object | GET /routes on port api-port, Accept: application/json, period 10s, timeout 5s, 3 failures |
See What a Ready router does not prove. |
server.readinessProbe |
object | same request, period 10s, timeout 5s, 3 failures | |
server.startupProbe |
object | same request, period 2s, 15 failures (= 30s) | Gates liveness and readiness. mc-router is one static Go binary reading one file; 30s is generous. |
server.securityContext.pod |
object | non-root 10000:10000, fsGroup: 10000, fsGroupChangePolicy: Always, seccompProfile: RuntimeDefault |
Passed into the pod spec. The uid/gid are pinned by the chart even though the image declares USER 65532 since 1.46.0, so the runtime identity does not move with the base image. The seccomp profile sits here on purpose: it covers every container including init containers. |
server.securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
The image ships nothing but the static binary and a CA bundle and writes nothing, so no deviation is needed. |
server.env |
list | [] |
Extra environment variables appended to the chart's own (ROUTES_CONFIG). Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. Additive, not overriding — see Environment variables. |
Probe, securityContext and resources blocks may be erased (null) — the chart then falls back to exactly these defaults instead of rendering an unhardened, unprobed or unaccounted container. mappings is deliberately not null-safe: it fails instead.
networkPolicy
Inbound: the proxy port (server.port) open — it is published as a NodePort and the senders are players on the internet — and the API port (server.apiPort), which is the probe path. Outbound: DNS plus the backend ports derived from the routing table itself — server.backendPort and the port override of every mappings entry that sets one.
That makes this one of the few egress caps here that follows the configuration instead of guessing at it: add a backend on a non-standard port and the policy moves with it.
| 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.probeSources |
list | [] |
CIDRs allowed to reach the probed port. Empty means open, not blocked — the rule is then rendered with no from at all. Probes come from the IP of the worker node running the pod, which the chart cannot know; a list that misses it drops every probe and the pod never becomes Ready. |
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 hostname routes, not the port
Every mapping is reached over the same port. mc-router reads the server address out of the handshake and looks it up in the table, so the player has to enter a mapped name. A name that is not in the table is connected to nothing: the chart configures no default route. mc-router knows the concept (POST /defaultRoute) and parses every flag equally as an environment variable, so server.env is where one would be set.
mappings is the only source of routes
The chart does not run mc-router in its Kubernetes auto-discovery mode — there is no ServiceAccount and automountServiceAccountToken is false, so the router cannot watch the API and never reads routing annotations on a backend Service. A server announced that way stays unreachable until its name stands in mappings; where both exist, this table wins.
A routing change is a planned outage
The table is mounted with subPath at /data/custom-routing-config.json, the kubelet never refreshes a subPath mount, and mc-router reads ROUTES_CONFIG only at startup. Since chart 2.0.0 the Deployment therefore carries checksum/config over the rendered ConfigMap, so helm upgrade restarts the pod for you — at the price of a few seconds in which no client can connect and open sessions are dropped.
This chart is the one that produced the outage behind the rule: before the annotation existed, a changed routing table stayed inert and the router kept routing to Services that no longer existed while every bundle was green and the pod was Ready (style guide §9, Lessons learned).
What a Ready router does not prove
All three probes call GET /routes on the mc-router API — there is still no /health endpoint in 1.46.x, and the API only starts after the config has been read and the listener is up. The API answers just as happily when every backend is down and when a domain or internalUrl is misspelled. A green rollout is a statement about the proxy, never about a Minecraft server; nothing in this release watches the backends.
An unreachable backend also does not restart the router: it accepts the client, fails to dial and closes the connection, which the player sees as an ordinary connection failure. The log is the only place that names the address it tried:
kubectl -n <namespace> logs deploy/<release>-minecraft-router-deployment
The API is not published by the Service, so ask the pod directly:
kubectl -n <namespace> port-forward deploy/<release>-minecraft-router-deployment 8080:8080
curl -s -H 'Accept: application/json' http://127.0.0.1:8080/routes
/metrics on the same port returns Prometheus metrics.
Writes over the API do not survive a restart
POST /routes, POST /defaultRoute and DELETE /routes/{addr} change the routing of the running process only. The mounted ConfigMap is the source of truth and the next rollout silently reinstates it. Fix routing in mappings, not over the API.
More than one replica
mc-router keeps no state between connections and every replica mounts the same ConfigMap, so the Service can spread clients across them. Two things change: an established connection is pinned to the replica that accepted it, so a rollout drops open sessions pod by pod; and a runtime change through the API reaches only the pod you forwarded to, leaving the replicas disagreeing until the next restart.
server.env cannot change the ports
The chart passes --port and --api-binding as command-line arguments, and those take precedence over the matching environment variables — PORT or API_BINDING set in server.env change nothing. Use server.port and server.apiPort. Everything else mc-router accepts as a flag is settable here (LOG_LEVEL, BACKEND_DIAL_TIMEOUT, CONNECTION_RATE_LIMIT), because it parses its flags with go-flagsfiller. A plain value is stored in the pod spec in clear text; anything sensitive belongs in valueFrom.secretKeyRef.
The backend rule is by port, not by destination
mappings name hosts (internalUrl), and 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. — so the rule permits those ports to any address, which is wider than your routing table. Anything else listening on a backend port is reachable through it too.
The upside is that adding a backend needs no change here: the policy reads the same value the router does.
The policy names pod ports, never the NodePorts
A NetworkPolicy is evaluated at the pod's network interface, after kube-proxy has already translated the NodePort to the target port. So a rule naming the NodePort matches nothing there: the object would be accepted, would read as strict, and — being the only rule for that port — would block every inbound connection instead.
This is the easiest mistake to make in a NodePort chart, which is why ci/run-networkpolicy-test.sh asserts every ingress port against the containerPorts of the pod spec the policy selects, and flags a number in the NodePort range explicitly.
The probed port is open inside the cluster by default
networkPolicy.probeSources is empty out of the box, so the rule for the port the probes address is rendered with no from selector at all — any pod the CNI routes to can reach it. Not from the internet (the port carries no NodePort), but this is not pod-level isolation, and reading the object as if it were is the mistake to avoid.
It is that way on purpose. The kubelet's liveness, readiness and startup probes originate from the IP of the worker node running the pod — an address no chart can know and must not guess. A CIDR that excludes the real node blocks every probe: the pod never becomes Ready, the rollout times out, and the symptom reads as a broken application rather than as a policy. Shipping open is the honest default; narrowing it means listing the worker node IPs yourself.
Note the companion trap: the moment probeSources is non-empty the rule is restricted, so everything else that needs the port has to be in the same rule. The chart adds the release's own namespace, ingressController and allowedNamespaces for exactly that reason.
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
- There is nothing to back up. The release creates no
OnePasswordItem, mounts no Secret and owns no volume; the ConfigMap is rendered entirely frommappings.helm uninstalldestroys nothing that is not already in your values file — the worlds live in the backend releases. - Plan an upgrade as a short connection outage, not as a rolling no-op: any change to
mappings(and every app-version bump) replaces the pod, and open sessions go with it. - Scaling to zero (
scaleDown: trueorserver.replicas: 0) leaves Service and ConfigMap in place; the backends keep running and stay reachable through their own Services, only the way in through the router is gone.
CI
ci/minecraft-router/test-values.yaml is the whole fixture set — the chart needs no secrets, no ingress and no persistence. It supplies the required mappings and overrides server.outwardPort to 30565, because the k3d CI cluster runs the default node-port range while the real cluster does not. That override is CI-only; the chart default stays 25565. The install-test runs on every PR that touches the chart — see CI/CD.
See also
- Chart style guide — the rules this chart implements
- Lessons learned — the
checksum/configoutage this chart produced - Versioning — what a bump of
4.1.1+up1.46.5means - 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