← Back to Wiki
Monitoring / Automation

Backup-Gated Auto-Updates for a Self-Hosted Game Server Fleet

"Just cron a nightly update" is the easy 80% of this. The other 20% — a backup that's actually verified before anything changes, a health check that isn't fooled by a command lying about its own exit code, and per-game update mechanisms that range from "one Docker pull" to "a whole Kubernetes operator stack" — is where a fleet of game servers actually gets left on stale, occasionally broken builds indefinitely. This is a real per-game breakdown of what that took, including two bugs a naive "no update available" check would have hidden forever.

Why bother automating this at all

The prompt for this wasn't a game server — it was a self-hosted password manager whose browser extension silently stopped syncing. Root cause: the extension auto-updates itself in the Chrome/Firefox store, and a new client version required a server release that had shipped the day before. The server was one version behind, with zero update automation anywhere in the fleet — everything only ever got patched manually, on no particular schedule, which is exactly how a client/server compatibility gap like that goes unnoticed until something breaks. Game servers have the same exposure, just with a friendlier failure mode (usually "can't connect" instead of "silently missing data").

The core pattern, regardless of what's underneath

Every service, no matter how it's actually updated, goes through the same four steps, in this order, every time:

  1. Check whether an update is actually available — compare a "what's installed" value against a "what's live" value. If they match, stop here. No backup, no restart, no risk, for the common case where nothing changed.
  2. Back up, always, before touching anything — and abort the whole run if the backup itself fails. An update is not worth the risk of a bad snapshot being the only thing standing between "minor annoyance" and "lost world."
  3. Apply the actual update.
  4. Health-check independently afterward — not by trusting the apply step's own exit code (see below for exactly why that distinction matters), but by directly checking that the service is actually up and doing its job.

Everything else — Docker vs. a raw binary, a simple systemd service vs. a whole Kubernetes-style operator stack — is just what step 1, 3, and 4 look like for that specific game.

Docker-based servers: the easy tier

Most self-hosted game servers ship as a Docker image with a floating :latest tag. For these, the whole check step is comparing the running container's image ID before and after a docker compose pull — if the ID didn't change, nothing downloaded, and it's safe to stop right there:

CURRENT_ID=$(docker compose images -q <service>)
docker compose pull --quiet
NEW_ID=$(docker compose images -q <service>)
[ "$CURRENT_ID" = "$NEW_ID" ] && exit 0   # nothing changed, don't even take a backup

Apply is just docker compose up -d to recreate the container on the new image. Health check is whatever makes sense for that game — an HTTP endpoint if it has one, or a direct port/process check if it doesn't.

Multi-container stacks need every service pulled, not just the one you care about. A photo-management or file-sync stack with a separate ML/worker container needs both to move together, or you can end up with an API server on a new protocol version talking to a worker that doesn't speak it yet. Pulling every service in the compose file is safe even for the ones that never change — a database or cache container pinned to a specific tag+digest is just a no-op pull every time.

Raw-binary / VM-based servers: the tier that's usually skipped entirely

Not every self-hosted game ships a Docker image. Some only distribute a Steam-hosted dedicated server binary meant to be installed directly with SteamCMD, running as a plain systemd service on its own VM — genuinely common for games that added dedicated-server support without ever officially supporting Linux containers. This is the tier that's easy to leave out of an update automation project entirely, since it doesn't fit the "diff a Docker image ID" pattern at all — and then it just doesn't get automated, quietly, possibly for a long time, with nothing anywhere actually saying so.

The fix is the same four-step pattern, just with different plumbing:

Since this runs on its own VM rather than a container on a shared host, it needs an SSH path in instead of a local exec — a dedicated automation key, authorized only for that one VM, reusing whatever standing sudo access already exists there rather than provisioning a new account just for this.

The harder end of that same tier: an operator-managed server

Some live-service games go a step further and distribute their entire dedicated-server stack as its own lightweight Kubernetes deployment — a database, a message queue, a gateway, and the actual game-world processes, all managed by the publisher's own custom operator rather than a single process. Same SteamCMD-based check step works fine here too — the update itself is still a real Steam depot underneath. Apply and health-check look different:

A management script reporting failure doesn't always mean it failed. One publisher's own update script exits with a non-zero status even on a fully successful update, because of a couple of harmless "symlink already exists" warnings it doesn't suppress internally. Chained the obvious way — update && start — this silently skips the required start step after every single real update, forever, without a single visible error anywhere. It only surfaces if you deliberately force a real update through the automation end-to-end and watch what actually happens, not just trust a dry "no update available" check that never exercises the apply path at all. The fix is to decouple the two steps (update; start, semicolon not &&) and trust only the independent health check afterward — never a command's own exit code when you don't fully control what that exit code actually means.

The bug that predates this project entirely — and the lesson worth generalizing

The operator-managed server above already had its own standalone daily update cron before any of this — and it had never actually worked, the entire time it existed. The original theory (documented at the time) was an intermittent failure in the anonymous Steam login used for the version check. That theory was wrong.

The real cause: the automation's own SSH key had simply never been authorized on the target VM. Every single cron run failed on a plain authentication error before the script — wrapped in a fail-fast/no-partial-execution mode — ever reached the actual update-check step at all. Once the key was properly authorized, the identical version-check command succeeded cleanly and instantly, every time, with no flakiness whatsoever. The server had been running a real build several days stale by the time this was caught, silently, with a "working" cron job logging nothing unusual every single night.

The generalizable lesson: when a script that's supposed to fail loudly on any error instead "just doesn't seem to do anything," check the authentication/connectivity layer directly before theorizing about whatever application sits above it. A permission error and an application-level failure can look identical from a distance if the script itself doesn't distinguish them — and it's much faster to rule out "can this even connect" first than to debug the wrong layer for months.

Warn players before a restart, without building six different notification systems

Checking whether a given game server can broadcast a warning message to connected players before an update-triggered restart is worth doing directly rather than assuming — it genuinely varies. Some expose a real in-game console/broadcast command; several common ones expose no console or RCON interface whatsoever, meaning there's no way to warn players in-game at all, full stop.

Rather than build a different notification mechanism per game (or silently skip it for the ones with no console access), one shared voice-chat server already sitting at the center of the community works as a single broadcast channel for every game at once — a short script posts a warning message to it over its own HTTP query API a few minutes before any update actually restarts anything, reused identically across every server in the fleet regardless of what that particular game can or can't do on its own.

Verify with a real forced update, not just a clean dry run

Every service in this fleet was confirmed via a live manual trigger before being trusted to run unattended — but "confirmed" meant different things depending on whether the server actually had a real pending update at the time. A dry run that correctly detects "already current" and exits cleanly proves the check step works. It proves nothing about the apply or health-check steps, since they never actually ran. Both real bugs above — the silently-skipped restart and the SSH key that was never authorized — would have passed a dry-run-only verification with a straight face. Catching them took deliberately forcing a real update through the full pipeline at least once per service (temporarily pointing the "installed version" check at an artificially old value is enough to force this safely) and watching every step actually execute, not just trusting that a clean exit code meant the whole thing worked end to end.