Building Your Own Server Image

The supervisor contract every bring-your-own image has to meet, the Dockerfile for Linux and Windows, and how to test the image before you point a branch at it.

A custom game server image on PingCore has to contain the PingCore supervisor. The platform pins the main container's command to the supervisor and ignores whatever ENTRYPOINT or CMD the image declares:

  • Linux: /usr/bin/tini -- node /node-service/src/index.js

  • Windows: C:\Program Files\nodejs\node.exe C:\node-service\src\index.js

If those paths hold nothing, the container has no process to run, so it fails to start and the deployment job records the failure. Agones and GameLift require their SDK inside your build in the same way. There is no sidecar mode and no "run any container" mode.

What the supervisor provides

Everything the panel and the platform do inside a running server goes through the supervisor:

  • the live console and console commands

  • restart, stop and start

  • content sync from the CDN, from Steam, or from the image

  • the memory governor, which restarts a leaking server before it affects the node

  • scheduled tasks

  • log and output capture

  • SFTP access to the server's files

  • the Discovery fleet agent

An image without the supervisor has none of this.

Start from the supervisor base image

Build FROM repo.pingcore.io/pingcore/supervisor-base:<tag> and copy your game into /opt/game (C:\game on Windows). The base already holds the supervisor, its runtime, its tools and the directory layout, so there is nothing to install to satisfy the contract. Supervisor Base Images lists the tags.

If your game needs a base the PingCore images cannot provide, see Advanced: using your own base image.

The Dockerfile

ARG the build id so one line stamps both the label and the .version marker.

Linux

FROM repo.pingcore.io/pingcore/supervisor-base:ubuntu-24.04-1.3.4

ARG GAME_BUILD=dev

RUN apt-get update && apt-get install -y --no-install-recommends \
      libsdl2-2.0-0 \
    && rm -rf /var/lib/apt/lists/*

COPY ./build /opt/game
RUN echo "${GAME_BUILD}" > /opt/game/.version

Windows

FROM repo.pingcore.io/pingcore/supervisor-base:windows-ltsc2025-1.3.4

ARG GAME_BUILD=dev

COPY ./build C:/game
RUN Set-Content -Path 'C:\game\.version' -Value $env:GAME_BUILD

Build it with --build-arg GAME_BUILD=2026.09.15-abc123.

Image requirements

  • Put the game at /opt/game or C:\game. The supervisor copies that tree onto the server's data volume at install time.

  • Ship a .version file at the root of that tree, holding one line with an opaque build id. Only the first non-empty line is read. Without it the content is treated as unversioned and only installs on first boot and on reinstall.

  • GAME_IMAGE_ROOT and GAME_IMAGE_VERSION are yours to set with ENV if your build lands somewhere else. GAME_IMAGE_ROOT must be an absolute path outside the data volume. The supervisor refuses a relative path, a filesystem root, the gameserver directory, any path under the data volume, and any path that contains the data volume.

  • Do not set USER. The supervisor runs as root, chowns the data volume, creates the gameserver user when the image lacks it, and spawns the game under that user itself. A non-root USER breaks the chown and leaves the game unable to write its own files. If a build step has to run as another user, switch back with USER root at the end of the Dockerfile.

  • Keep the gshelper user on Linux. See "SFTP and steamcmd run as a separate user" below.

  • Do not rely on ENTRYPOINT or CMD. Both are ignored on the main container. Put startup work in a container_start or process_start script on the template set instead.

  • Never bake BOOTSTRAP_FILE. The platform emits it as an empty value on every container, which is the instruction to fetch the real bootstrap from the API. An image-supplied bootstrap file would hand the supervisor a configuration the image author controls.

  • Write runtime data under the data volume. The main container carries an ephemeral-storage limit: a 20Gi baseline plus the size limits of the disk-backed ephemeral volumes mounted by any container in the pod. A process that fills the container's own writable layer gets the pod evicted.

  • Do not chown or chmod the game tree after COPY in a Linux Dockerfile. Overlay copy-up duplicates the whole tree into a new layer, doubling the image size. The supervisor sets ownership on the copy it makes, never on the image root.

SFTP and steamcmd run as a separate user

On a Linux image built on the 1.3.4 base or later, the supervisor runs the SFTP server and every steamcmd job as gshelper, a second unprivileged user in the gameserver group. The SFTP password and Steam logins then live in processes the game cannot read. Files either user writes on the data volume stay readable and writable by the other.

What this means for your image:

  • Rebuild on 1.3.4 or later to get it. An image built on an older base has no gshelper, and the supervisor does not create it. It logs a warning and runs SFTP and steamcmd as gameserver, where the game can read the SFTP password. Rebuilding is enough: nothing else in your Dockerfile has to change.

  • Do not remove gshelper, and do not recreate it by hand with a different group. Without the user, SFTP and steamcmd run as gameserver.

  • Files uploaded over SFTP or downloaded by steamcmd are owned by gshelper until the container restarts. The game can read, write, rename and delete them, but it cannot chmod, chown or change the timestamps of such a file, and a file steamcmd saved without group write is read-only for the game. Ownership goes back to gameserver only when the container starts: a restart with Hard restart (recreate the container) ticked, or a new pod. The ordinary Restart restarts the game process inside the same container and leaves ownership as it is. If your startup script runs chmod +x on an uploaded file, expect it to fail until the container restarts.

  • Windows images have no gshelper, and this separation does not apply there.

steamcmd in your image

The base image has no steamcmd. If you install it for Workshop downloads, follow both rules:

  1. Install it at /usr/games/steamcmd, as root, with no group or world write on the executable and on every directory above it. That is the default path the supervisor runs on Linux, and it is not a setting you can change: STEAMCMD_PATH is a reserved name (see below). The apt steamcmd package puts it there. A tarball unpacked as root under /opt with chmod -R go-w, then symlinked to /usr/games/steamcmd, works too. Never install it under /gameserver or /home/gameserver. If the executable or a directory above it is owned by gameserver, group-writable for the gameserver group, or world-writable, the game could replace it, so on the 1.3.4 base or later every steamcmd job fails with Refusing to run steamcmd as gshelper: <path> is <reason>, anonymous downloads included.

  2. Initialise it once as gshelper, so its self-update runs at build time and not on the first job of every new server:

USER gshelper
RUN /usr/games/steamcmd +login anonymous +quit || true
USER root

Skipping rule 2 still works, but every new server downloads the full steamcmd client on its first Workshop job. Steam Guard is not supported: authenticated downloads need a Steam account without Steam Guard.

Environment variable names the platform owns

These names are reserved. An environment variable row with one of them is refused when you save the container. Do not bake them into your image either: the platform sets SERVER_ID, API_KEY, API_URL, BOOTSTRAP_FILE, CORS_ORIGINS and the PVC_ paths on every container and PINGCORE_IMAGE_REF on the main container, which overrides an image value, and the rest are platform settings the supervisor reads:

SERVER_ID, API_KEY, API_URL, BOOTSTRAP_FILE, CORS_ORIGINS, PINGCORE_IMAGE_REF, STEAMCMD_PATH, DEPOTDOWNLOADER_PATH, POD_IP, AGONES_SDK_HTTP_PORT, UPDATE_POLL_INTERVAL, DEPOT_UPDATE_POLL_INTERVAL, DEPOT_MAX_DOWNLOADS, RESOURCE_SAMPLE_INTERVAL, SYSTEM_DISABLE_MEMORY_MONITORING, ALERT_RETENTION_MS, TASK_CHECK_INTERVAL, TASK_MIN_UPTIME_SECONDS, ENVIRONMENT.

Two whole prefixes are reserved as well: PVC_ (mount paths the platform derives per volume, including PVC_DATA) and MEMORY_ (the memory governor's thresholds).

GAME_IMAGE_ROOT and GAME_IMAGE_VERSION are deliberately not reserved, because they describe your image rather than the platform.

Test the image locally

Write a minimal bootstrap next to your Dockerfile. It stands in for the payload the platform normally serves.

{
  "gameId": 1,
  "gameServerId": 1,
  "features": { "autoBoot": true, "autoUpdate": false, "updateOnBoot": false },
  "imageContent": { "enabled": true },
  "configs": {
    "cli": [{ "command": "./MyGameServer -port 7777" }],
    "files": [],
    "scripts": []
  },
  "apiService": { "enabled": true, "port": 8080 },
  "healthCheck": { "gamePorts": [7777], "initialDelaySeconds": 10 }
}

Run the image with the four variables the supervisor needs. BOOTSTRAP_FILE has to be an absolute path outside PVC_DATA, so keep it out of the data volume:

docker run --rm -it \
  -e SERVER_ID=1 \
  -e API_KEY=local-only \
  -e PVC_DATA=/gameserver \
  -e BOOTSTRAP_FILE=/etc/pingcore/bootstrap.json \
  -v "$PWD/bootstrap.json:/etc/pingcore/bootstrap.json:ro" \
  -p 8080:8080 \
  yourstudio/your-game:dev

Then check the supervisor is up:

curl http://localhost:8080/health

It answers {"status":"ok"} without authentication. Every other control API route except /ready and /download needs the API_KEY you passed, as Authorization: Bearer local-only (see How PingCore Runs Your Servers for the rules on a deployed server). /health is the same check the platform runs against every server after a deployment, so an image that fails it locally fails the deploy too. The panel's live console and file browser run over Socket.IO against the platform, so they are not part of the local check.

Point a branch at the image

  1. Add the image under Workspace > Content > Container Images, with the Registry, Repository and Tag of your build. See Pushing Images to the Registry.

  2. Select it on the game's main container, under Workspace > Games, open the game, Containers.

  3. Go to Branches, edit the branch, and in Data Configuration set Data Source to Baked into container image.

There is nothing else to configure on the branch. Baked-In Game Files covers when the copy runs and how updates reach existing servers.