gaming servers
  • Dockerfile 67.1%
  • Shell 32.9%
Find a file
Jesse Garcia 49cbfe8126
All checks were successful
ci/woodpecker/push/woodpecker/2 Pipeline was successful
ci/woodpecker/push/woodpecker/1 Pipeline was successful
fix: pass image between CI steps as OCI archive, not docker daemon
2026-10-23 19:15:04 -06:00
scripts updates 2026-09-10 19:17:43 -06:00
.dockerignore +woodpecker 2026-09-10 19:30:34 -06:00
.gitignore wip 2026-03-09 09:45:23 -06:00
.justfile wip 2026-03-09 09:45:23 -06:00
.woodpecker.yml fix: pass image between CI steps as OCI archive, not docker daemon 2026-10-23 19:15:04 -06:00
compose.yml +woodpecker 2026-09-10 19:30:34 -06:00
Dockerfile fix: publish OCI-compatible images 2026-10-23 18:52:17 -06:00
README.md fix: publish OCI-compatible images 2026-10-23 18:52:17 -06:00
renovate.json +woodpecker 2026-09-10 19:30:34 -06:00

frost

Containerized dedicated game servers for the homelab.

A single multi-stage Dockerfile produces one image per game. A shared base stage installs steamcmd on debian:bookworm-slim; each game stage adds that game's runtime dependencies and ships a small entrypoint script that pulls the latest build from Steam on container start, then execs the server binary. Images are published to oci.bvoy.dev/homelab/frost by Woodpecker CI.

Target Steam App ID Ports Status
valheim 896660 2456-2457/udp Working
astroneer 728470 8777/udp WIP — the entrypoint installs the app but does not yet launch the server

Layout

Dockerfile          multi-stage build; shared steamcmd base + one stage (target) per game
compose.yml         local/homelab deployment
scripts/*.sh        per-game entrypoints (steamcmd update + server launch)
.justfile           helper recipes for poking at containers
.woodpecker.yml     CI: matrix build of every target, published via skopeo
.env                deployment config (gitignored)

Inside the container everything lives under SERVER_ROOT=/var/lib/frost:

  • /var/lib/frost/servers/<game> — game files installed by steamcmd
  • /var/lib/frost/saves — world/save data

Both are bind-mounted from the host so that game updates and worlds survive a container rebuild.

Configuration

.env is not committed. Create one alongside compose.yml:

STORAGE_SAVES=/mnt/tardis/pool/data/frost/steam
STORAGE_SERVERS=/mnt/tardis/pool/data/frost/servers

# valheim
SERVER_NAME=OGABOGA
WORLD_NAME=OGABOGA
WORLD_PASSWORD=CHANGEME

# published image tag; pin to <target>-<commit-sha> to roll back
FROST_TAG=valheim
Variable Default Purpose
STORAGE_SAVES — Host path bind-mounted to /var/lib/frost/saves
STORAGE_SERVERS — Host path bind-mounted to /var/lib/frost/servers
STEAM_USER anonymous Steam account used by steamcmd
SERVER_NAME OGABOGA Server name shown in the browser
WORLD_NAME OGABOGA Valheim world to load or create
WORLD_PASSWORD — Valheim join password (min. 5 characters)
FROST_TAG valheim Image tag to pull

Running

Pull and run the published image:

docker compose up -d valheim
docker compose logs -f valheim

Build locally instead of pulling:

docker compose build valheim
docker compose up -d valheim

The entrypoint runs steamcmd +app_update … validate on every start, so the first boot takes a while and each restart picks up the latest game build.

just recipes

just                       # list recipes
just start <service>       # run ./start.sh in a throwaway container
just exec <service> [cmd]  # shell into a running container (default: sh)
just run <service> [cmd]   # one-off container with sh as the entrypoint
just login [user] [pass]   # run steamcmd +login, e.g. to handle Steam Guard

The recipes default to a steamcmd service; pass a service defined in compose.yml (e.g. just exec valheim) for anything real.

CI

.woodpecker.yml builds every target in the TARGET matrix on pushes to main (and on manual/deployment events). Each target runs two steps:

  1. build — plugin-docker-buildx builds the stage and loads it into the local daemon.
  2. publish — skopeo copy --format=oci pushes it under two tags.

Tags per target:

  • oci.bvoy.dev/homelab/frost:<target> — moving tag
  • oci.bvoy.dev/homelab/frost:<target>-<commit-sha> — immutable, use it in FROST_TAG to roll back

Registry credentials come from the DOCKER_AUTH_CONFIG secret. Renovate keeps base images and CI images up to date.

Why skopeo

The registry is zot, which is OCI-native and rejects Docker schema-2 manifests with MANIFEST_INVALID unless http.compat: ["docker2s2"] is enabled server-side. Builders generally propagate the base image's manifest format, so anything built FROM a Docker-format base gets rejected on push. skopeo copy --format=oci normalises the manifest to application/vnd.oci.image.manifest.v1+json on the way out, which keeps the pipeline working no matter what format a base image happens to use.

Adding a game

  1. Add a stage to the Dockerfile off base (which already provides steamcmd), installing whatever runtime libs the server needs and EXPOSE-ing its ports.
  2. Drop an entrypoint in scripts/<game>.sh that runs steamcmd +app_update <appid> validate and then execs the server binary.
  3. Add the stage name to the TARGET matrix in .woodpecker.yml.
  4. Add a service to compose.yml pointing at the new target.