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.jsWindows:
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/.versionWindows
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_BUILDBuild it with --build-arg GAME_BUILD=2026.09.15-abc123.
Image requirements
Put the game at
/opt/gameorC:\game. The supervisor copies that tree onto the server's data volume at install time.Ship a
.versionfile 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_ROOTandGAME_IMAGE_VERSIONare yours to set withENVif your build lands somewhere else.GAME_IMAGE_ROOTmust 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 thegameserveruser when the image lacks it, and spawns the game under that user itself. A non-rootUSERbreaks the chown and leaves the game unable to write its own files. If a build step has to run as another user, switch back withUSER rootat the end of the Dockerfile.Keep the
gshelperuser on Linux. See "SFTP and steamcmd run as a separate user" below.Do not rely on
ENTRYPOINTorCMD. 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
chownorchmodthe game tree afterCOPYin 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.4or later to get it. An image built on an older base has nogshelper, and the supervisor does not create it. It logs a warning and runs SFTP and steamcmd asgameserver, 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 asgameserver.Files uploaded over SFTP or downloaded by steamcmd are owned by
gshelperuntil the container restarts. The game can read, write, rename and delete them, but it cannotchmod,chownor change the timestamps of such a file, and a file steamcmd saved without group write is read-only for the game. Ownership goes back togameserveronly 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 runschmod +xon 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:
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_PATHis a reserved name (see below). The aptsteamcmdpackage puts it there. A tarball unpacked as root under/optwithchmod -R go-w, then symlinked to/usr/games/steamcmd, works too. Never install it under/gameserveror/home/gameserver. If the executable or a directory above it is owned bygameserver, group-writable for thegameservergroup, or world-writable, the game could replace it, so on the1.3.4base or later every steamcmd job fails withRefusing to run steamcmd as gshelper: <path> is <reason>, anonymous downloads included.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 rootSkipping 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:devThen check the supervisor is up:
curl http://localhost:8080/healthIt 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
Add the image under Workspace > Content > Container Images, with the Registry, Repository and Tag of your build. See Pushing Images to the Registry.
Select it on the game's main container, under Workspace > Games, open the game, Containers.
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.