Table of Contents
- satisfactory
- Prerequisites
- Installation
- Configurable values
- Operating notes
- The node ports are pinned, so a second release collides
- The first start downloads the game, and it is not quick
- Every start asks Steam for an update
- What a green pod does not prove
- Save games and game files share one volume
- The emptyDirs count against the ephemeral-storage limit
- The uid is pinned, and PUID/PGID do nothing
- No secrets — and where the admin password actually lives
- One replica, one volume, one port
- Seasonal events: the key was renamed in 7.0.0, and it is breaking
- Switching the branch is a one-way move for a save
- Why there is no egress restriction, and why that decision goes this way
- The game port is allowed as both TCP and UDP
- The policy names pod ports, never the NodePorts
- The policy is only a boundary where the CNI enforces one
- Upgrades and backup
- CI
- See also
satisfactory
Deploys a Satisfactory dedicated server (wolveix/satisfactory-server) as a single-replica StatefulSet with one data volume. Players connect to a NodePort; there is no Ingress, no certificate and no hostname.
| Image | ghcr.io/wolveix/satisfactory-server |
| Versions | not listed on purpose — read the current pair off satisfactory/Chart.yaml, see Chart index |
| Workloads | StatefulSet (1 replica) |
| Storage | one RWO PVC <release>-satisfactory-vc, storage class hardcoded longhorn |
| Ingress | no; a NodePort Service with three ports (UDP game, TCP API, TCP messaging) |
| External dependencies | Steam — the first start downloads 10 GB+ of game files |
| Secrets | none |
| Install-test | excluded, see CI |
Prerequisites
- A storage class named
longhorn. It is written into the PVC template and is not a value. On a cluster without it the claim staysPendingforever and the pod never starts — with no error anywhere in the game server's log. - Enough space for game files and saves on one claim. Both live on
<release>-satisfactory-vc, mounted twice under different subPaths; the Steam download alone is north of 10 GB. - The default ports
30777/30888free on every node, or your own values. They are pinned, not allocated. - Outbound access to Steam from the pod, and inbound UDP reachability from the players' side.
- No 1Password item, no cert-manager, no database.
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 satisfactory jlab/satisfactory \
--namespace satisfactory --create-namespace \
-f my-values.yaml
The chart installs with its defaults; a values file is only needed to deviate:
server:
storage: 40Gi
game:
maxPlayers: 8
enableSeasonalEvents: true
Players then add the server in the game client under Server Manager → Add Server as <node-ip>:30777. Node addresses are not rendered anywhere — the chart does not know them (kubectl get nodes -o wide).
helm install prints the chart's NOTES.txt, which renders the ports, the computed limits and the seasonal-event state of this release. Read it — it is the per-release version of this page.
Configurable values
This chart keeps env, the three probes and securityContext at the top level of values.yaml, not under a chart-named key. The paths in its error messages therefore have no satisfactory. prefix.
Global
| Key | Type | Default | Description |
|---|---|---|---|
scaleDown |
bool | false |
Renders the StatefulSet with replicas: 0 without uninstalling. Takes precedence over server.replicas. Service and PVC stay, so no save is lost and the game files do not have to be downloaded again. |
dev
| Key | Type | Default | Description |
|---|---|---|---|
dev.debug |
bool/string | false |
Passed to the image as DEBUG. Changes what you read, not what the server serves — but it makes the log noticeably larger while the pod is pulling 10 GB. |
The block may be erased (null) and falls back to this default. Before 7.0.1 it did not: --set dev=null, or a leftover dev: whose last sub-key was deleted, aborted the render with a nil-pointer error at .Values.dev.debug.
game
| Key | Type | Default | Description |
|---|---|---|---|
game.experimental |
bool/string | false |
STEAMBETA — switches to the experimental Steam branch. See Switching the branch. |
game.maxPlayers |
int/string | 4 |
MAXPLAYERS. |
game.enableSeasonalEvents |
bool/string | false |
Seasonal (holiday) events. Negated by the chart into the image's DISABLESEASONALEVENTS: true renders "false" and the events run. Only true/false are accepted, boolean or quoted; anything else aborts the render. See Seasonal events. |
The whole block may be erased (null) — the chart then falls back to exactly these defaults instead of aborting with a nil-pointer error, which is what an erased game: used to do.
server
| Key | Type | Default | Description |
|---|---|---|---|
server.replicas |
int | 1 |
Replica count; 0 is allowed. More than one does not work — see One replica. |
server.gamePort |
int | 30777 |
Game traffic (UDP) and the server's HTTPS API (TCP), as containerPort and as a pinned nodePort. Also passed to the image as SERVERGAMEPORT, so the port a player types is the port the server believes it runs on. |
server.messagingPort |
int | 30888 |
Messaging port (TCP), SERVERMESSAGINGPORT, likewise pinned as a nodePort. |
server.storage |
string | 20Gi |
Size of the data PVC — game files and saves. The one key of this block that is not null-safe, on purpose: an erased value yields an invalid PVC rather than a silently degraded workload. See the storage exception. |
server.resources.limitFactor |
number | 2 |
Memory and ephemeral-storage limits are computed as factor × request. 2, not the repo-wide 3, on purpose — that is what holds the memory limit at the 8Gi this chart has always published (style guide §12.4). |
server.resources.requests |
object | memory: 4Gi, cpu: 1, ephemeral-storage: 5Gi |
|
server.resources.limits |
object | unset | An explicit limit always wins. A CPU limit is only rendered when set here — CPU is compressible, so an unlimited container is throttled rather than killed. |
The block may be erased (null) and falls back to these defaults — except storage, see below. That is new in 7.0.1 and it closed two different failures. The loud one: --set server=null aborted the render at .Values.server.replicas. The quiet one, which is the reason to care — a single nulled key rendered without complaint into a manifest nothing rejects until the API server sees it:
| Erased key | What 7.0.0 rendered | Now |
|---|---|---|
server.gamePort / server.messagingPort |
Service with port: and nodePort: empty, StatefulSet with an empty containerPort |
falls back to 30777 / 30888 |
server.storage |
PVC requesting no size | unchanged — still a PVC without a size, deliberately |
server.replicas |
fell back to 1 already (the replicas helper was null-safe) |
unchanged |
server.resources is the one key with no default of its own in the fallback — it is passed through to the resources fallback, which carries limitFactor: 2 and the requests. Keeping that in one place is deliberate: a second copy in the server fallback would be a third place for the numbers to drift apart.
The storage exception is deliberate
server.storage is the single key of this block that does not fall back. An erased size — including the one you get from server: null — renders a PVC requesting no storage, and the install fails when it is applied. That is the documented intent: an erased value yields an invalid PVC rather than a silently degraded workload. It was left standing when everything around it was made null-safe in 7.0.1, rather than quietly overturned inside a bugfix.
Whether it is still the right trade is argued in issue #39, not here. The argument against it: every other block in this chart now falls back, so the one that does not is exactly the kind of inconsistency 7.0.1 set out to remove. The argument for it: a volume size guessed by the chart is a worse thing to discover late than a failed apply. Worth knowing either way — the difference only matters on a new install. For an existing release a fallback to 20Gi would fail too, because a PVC cannot shrink.
env
| Key | Type | Default | Description |
|---|---|---|---|
env |
list | [] |
Extra environment variables, appended after the nine the chart always sets (DEBUG, MAXPLAYERS, PGID, PUID, SKIPUPDATE, STEAMBETA, DISABLESEASONALEVENTS, SERVERGAMEPORT, SERVERMESSAGINGPORT). Rendered through tpl; value, secretKeyRef and configMapKeyRef forms. A repeated name is present twice and Kubernetes keeps the last occurrence — which is the only way to override one of the nine, SKIPUPDATE in particular. See Every start asks Steam. |
Probes
| Key | Type | Default | Description |
|---|---|---|---|
livenessProbe |
object | exec /home/steam/healthcheck.sh, period 30s, timeout 10s, 4 failures, 1 success |
The image's own script, which queries the HTTPS API on SERVERGAMEPORT — so it keeps working when the port is overridden, which a tcpSocket default could not. |
readinessProbe |
object | tcpSocket on the named port api, period 10s, timeout 5s, 3 failures |
A bare TCP connect: it proves that something accepts a connection there. |
startupProbe |
object | exec /home/steam/healthcheck.sh, period 30s, timeout 10s, 40 failures (= 20 minutes) |
Gates liveness and readiness. The budget is deliberately huge — the first start downloads the game. |
securityContext
| Key | Type | Default | Description |
|---|---|---|---|
securityContext.pod |
object | non-root 1000:1000, fsGroup: 1000, fsGroupChangePolicy: OnRootMismatch, seccompProfile: RuntimeDefault |
Passed into the pod spec. The uid is pinned because the image supports rootless operation only as its own steam user; its entrypoint rejects any other non-root uid/gid. OnRootMismatch is deliberate (style guide §12.4) — see The uid is pinned. |
securityContext.container |
object | readOnlyRootFilesystem: true, all caps dropped, no privilege escalation, non-root |
Works because the chart mounts emptyDirs at /tmp and over the steam user's ~/.steam, ~/.config and ~/.local; everything else is written to the PVC at /config. |
Probe, securityContext, game 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. Since 7.0.1 that holds for every chart-owned block, dev and server included, so no erased block aborts the render any more. The one key that stays un-defaulted is server.storage, and it is marked as an exception rather than left to look like an oversight.
networkPolicy
Inbound: the three declared game ports only — TCP and UDP on server.gamePort, and TCP on server.messagingPort — open to any source, because they are NodePorts and the clients are players on the internet. There is no egress section at all.
enabled is the only value, and deliberately: the single inbound rule is open to any source and there is no egress section, so allowedNamespaces and probeSources would both reach no rule — and a value that does nothing reads as a control it is not.
| 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. |
Operating notes
The node ports are pinned, so a second release collides
All three nodePorts are set to the same number as the container port, so the port a player types is the port the server believes it runs on. Because they are fixed rather than allocated, a second release of this chart in the same cluster is rejected with provided port is already allocated — and that release fails on the Service alone while the rest of it applies. Give a second server its own server.gamePort and server.messagingPort.
The defaults 30777/30888 sit inside the default node-port range on purpose: they echo the game's usual 7777/8888 in the digits without needing a cluster-level change. Moving them outside 30000-32767 requires an extended --service-node-port-range, or the Service is rejected while the StatefulSet still comes up green.
The first start downloads the game, and it is not quick
The image does not carry the game; it fetches the server files through Steam onto the volume. Until that is done the pod is not Ready and the node port refuses connections — the endpoint only joins the Service once readiness passes. If the download needs longer than the startup budget (20 minutes by default), the kubelet kills the container; the next attempt continues against whatever already lies on the volume, so a slow link still converges over several restarts — but it presents as CrashLoopBackOff, and nothing in that word says "still downloading". Look at the log, not at the restart count.
Every start asks Steam for an update
SKIPUPDATE is pinned to "false" by the chart and is not exposed as a value; only an env entry repeating the name can override it. So this is not a first-boot cost: every start checks for an update, including every restart and every helm upgrade. Plan a rollout as minutes of downtime, not seconds. Overriding it makes restarts fast at the price that a new game version is never picked up — including the one an app-version bump of this chart expects.
What a green pod does not prove
No probe in this release ever touches the UDP game port, the one players actually use. A node firewall, a NetworkPolicy or anything upstream that forwards TCP only leaves all three probes green, the rollout green and the restart count at zero while not one client can join. That failure has to be tested from outside the cluster; kubectl will never show it.
Save games and game files share one volume
<release>-satisfactory-vc is mounted twice from the same claim: config/ → /config (saves and server settings) and gameFiles/ → /config/gamefiles (the Steam download). server.storage has to hold both, and a volume that fills up during a Steam update fails the update, not the save. Growing it only takes effect if the storage class allows volume expansion; shrinking is refused by Kubernetes and makes the next helm upgrade fail on the PVC instead of touching the data.
The claim is named -vc, not -pvc: a documented naming exception, because renaming it would provision a new empty volume and orphan the world.
The emptyDirs count against the ephemeral-storage limit
With a read-only root filesystem, /tmp and the steam user's dot-directories are emptyDirs — steamcmd bootstraps itself into ~/.local/share/Steam. emptyDir usage counts against the container's ephemeral-storage limit (limitFactor × request, 10Gi by default). An overrun is not an error message: the kubelet evicts the pod, and the reason sits on the pod, not in the server's log.
kubectl -n <namespace> describe pod <release>-satisfactory-sfs-0 | grep -A3 Evicted
The uid is pinned, and PUID/PGID do nothing
The pod runs as 1000:1000 because the image supports rootless operation only as its own steam user. PUID and PGID are set in the pod spec but only ever apply to the image's root entrypoint path, which this chart never takes — they are inert. Changing securityContext.pod.runAsUser is how you stop the container from starting, not how you change the uid.
fsGroupChangePolicy: OnRootMismatch skips the recursive chown of the multi-GB game files on every single pod start. The price: Kubernetes looks only at the volume's top-level ownership. If that already reads 1000:1000 it does not descend, so a subtree with wrong ownership — a restored backup, files written by another uid — is never repaired and the server fails to write there. Fix such a case by hand once; do not switch the policy to Always unless you want the chown back on every start.
No secrets — and where the admin password actually lives
This chart creates no OnePasswordItem, no Secret and no ConfigMap, and reads none. The server is claimed and its admin password set from the game client on first connect, and that state lives in the save data on the volume — not in any Kubernetes object. Restoring the volume restores the password with it; losing the volume loses it, and nothing in this release can hand it back.
One replica, one volume, one port
Every pod of this StatefulSet would mount the same ReadWriteOnce claim at the same paths — there is no volumeClaimTemplate — so extra pods stay stuck before start, and where they do start they write the same save from two processes. The pinned nodePort makes the point moot anyway: one port on a node routes to whichever pod is Ready.
Seasonal events: the key was renamed in 7.0.0, and it is breaking
game.seasonalEvents is gone. It is not deprecated and not quietly accepted: values.schema.json rejects it, so a values file that still carries it fails the upgrade with a validation error instead of installing something you did not ask for.
Up to 6.1.0 the value was handed to the container unchanged, into a variable whose name says the opposite: game.seasonalEvents: true wrote DISABLESEASONALEVENTS=true. The shipped default therefore had the events switched off in every install for as long as the chart existed — while the schema described the same key as "true means events are enabled". The new key game.enableSeasonalEvents is negated by the chart and defaults to false, which renders exactly what the chart has always rendered: upgrading alone does not move your server, you have to ask for the events.
Two mechanics come with the fix, and they generalize beyond this chart (style guide §12.6, Lessons learned): Go's not treats every non-empty string as true, so not "false" is false — the chart compares explicitly instead; and a value that is neither true nor false aborts the render rather than being filed under "off".
Switching the branch is a one-way move for a save
Back up /config before setting game.experimental: true. A save opened by an experimental build may not open on the stable build again, and setting the flag back re-downloads the stable files but does not undo what happened to the save. With SKIPUPDATE overridden the branch switch does not take effect at all — the two settings contradict each other.
Why there is no egress restriction, and why that decision goes this way
Two reasons, and they compound.
First, what this server needs outbound is Steam's content delivery, and that is not a port list anyone can pin down: SteamCMD downloads and updates the game over TCP 80 and 443 plus the 27015–27050 range, and the master servers are reached over UDP 27000–27100 and 4380. Valve changes that set, the server re-downloads on every update, and a cap that is wrong leaves a server that cannot start — or worse, one that starts and never appears in the server browser.
Second, and this is the deciding half: satisfactory is the one chart in this repository with no install-test at all (ci/excluded-charts.txt — the image is multiple GB and the first start pulls over 10GB of game files). An egress cap here would be the only policy in the sweep with no empirical check whatsoever behind it, on the chart whose correct port set is least knowable. Review is the only net this chart has, and a review cannot verify a Steam port range.
So policyTypes names only what there is something to restrict, exactly as valkey does for the opposite reason. An installation that wants an egress cap should write one at release level, where Steam's behaviour can actually be observed.
What the Ingress half still buys is real: only the three declared game ports are reachable, so anything else the image binds is not.
The game port is allowed as both TCP and UDP
The server binds it as both. A TCP-only rule would be accepted, would look complete, and would drop half the game traffic.
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 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 volume before every app-version bump and before flipping
game.experimental. The save is the only copy of the world and of the admin password. helm uninstalldeletes the PVC. It is an ordinary release object, not avolumeClaimTemplate, so under aDeletereclaim policy every save goes with it. Ahelm.sh/resource-policyannotation added to the live PVC afterwards does not save you — Helm reads that annotation from the release manifest, not from the cluster.- Raise
startupProbe.failureThresholdbefore a first install on a slow link, not after the crash loop has started. - Every upgrade is minutes of downtime, because the restarted pod asks Steam for an update first.
CI
satisfactory is excluded from the install-test (ci/excluded-charts.txt), with the justification recorded there: the game-server image is multiple GB and Steam downloads 10GB+ game files on first start — impractical on a CI runner. There is no ci/satisfactory/ directory.
An excluded chart gets no install-test job at all — it is dropped from the matrix rather than skipped inside a job, so no check can report green for a chart whose image was never pulled; the PR's job summary lists it with this justification instead (CI/CD). Everything else still runs: strict lint, package, the version-bump check and the resource-quantity render test. No CI job has ever started this workload — treat the first rollout in a new cluster as the real test.
One consequence is easy to miss: none of those steps renders templates/NOTES.txt. helm lint and helm template skip it by design, and the install-test — the only stage that would have run it — does not exist for this chart. This chart's notes read server and dev heavily, so a values bug can be invisible in every check and still abort a real helm install; that is exactly how it went in 7.0.0 (see the server block above). Render the notes by hand when changing them: helm install --dry-run=client still wants a reachable API server, so the cluster-free way is to wrap the verbatim NOTES.txt body in a {{- define }} in a throwaway copy of the chart and include it into a scratch ConfigMap that helm template does render.
See also
- Chart style guide — the rules this chart implements, and the two deviations it is allowed
- Lessons learned — the inverted-value lesson this chart produced
- Versioning — what a bump of
7.0.0+upv1.9.10means - 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