Quickstart (Docker)
This guide stands up a production SourceBans++ install with Docker Compose in a few minutes. By the end you’ll have:
- The panel running behind your domain with automatic Let’s Encrypt TLS,
- A MariaDB database persisted to a named Docker volume,
- An initial admin account ready to log in.
The Docker image (ghcr.io/sbpp/sourcebans-pp) is a multi-arch build
(linux/amd64 + linux/arm64) signed with Sigstore cosign. It ships no
install wizard, no developer tools, and runs the panel as the
non-root www-data user.
Before you start
Section titled “Before you start”You need:
- Docker 24+ and Compose v2 (bundled into Docker Desktop and
recent Docker Engine releases).
docker compose versionshould printv2.xor newer. - A host with at least 512 MB of free RAM. The panel itself uses ~150 MB at idle; MariaDB takes the rest.
- A Steam Web API key from steamcommunity.com/dev/apikey (free, 30 seconds).
- The SteamID2 of the account you want as the first admin
(
STEAM_0:0:12345678form). SteamID I/O converts between formats.
You do not need PHP, Composer, MariaDB, or a webserver on the host. The image carries everything.
Step 1 — Get the compose stack
Section titled “Step 1 — Get the compose stack”The compose file + env template + Caddy stub live in the sourcebans-pp repo. The simplest path is a sparse fetch of the three files you actually need into a deployment directory of your own:
mkdir -p ~/sourcebans-prodcd ~/sourcebans-prodcurl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker-compose.prod.ymlcurl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/.env.example.prodmkdir -p docker/caddycurl -o docker/caddy/Caddyfile.example \ https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker/caddy/Caddyfile.exampleA git clone of the whole repo works too if you’d rather track
upstream changes that way.
Step 2 — Configure environment variables
Section titled “Step 2 — Configure environment variables”The compose file reads its config from a .env file in the same
directory. Copy the template and fill in the values:
cp .env.example.prod .envOpen .env in your editor. The template is grouped into
Required, Recommended, First-boot install, and
Optional sections. At minimum:
-
SB_SECRET_KEY: the JWT signing key. Generate once and keep it forever. Rotating it logs every admin out.Terminal window openssl rand -base64 47Paste the output into
SB_SECRET_KEY=.... -
DB_ROOT_PASSandDB_PASS: the MariaDB root password (used once for first-boot DB creation) and the panel user’s password. Generate two distinct strong passwords:Terminal window openssl rand -base64 24 # DB_ROOT_PASSopenssl rand -base64 24 # DB_PASS -
STEAMAPIKEY: paste your Steam Web API key. -
INITIAL_ADMIN_*: fill in all four fields (name, SteamID, email, password). The entrypoint seeds an Owner-flagged admin from these values on first boot only. Subsequent boots ignore them. Change the password from the panel’s “Your account” page after first login. -
SBPP_DOMAIN: if you plan to use the Caddy reverse-proxy stub for automatic TLS, set this to the public domain that points at your host (e.g.panel.yourcommunity.example).
The template is commented in detail. Read through the rest of it
for the optional knobs (image-tag pinning, trusted-proxy CIDR ranges,
DATABASE_URL for managed databases, the *_FILE Docker-secret
pattern).
Step 3 — Start the stack
Section titled “Step 3 — Start the stack”If you’re putting the panel behind your own existing reverse proxy (nginx, Traefik, Cloudflare Tunnel) or skipping TLS for a private network deploy:
docker compose -f docker-compose.prod.yml up -dThe panel is now reachable at http://your-host:8080. Point your
existing proxy at port 8080 and you’re done.
If you want automatic Let’s Encrypt TLS via the bundled Caddy stub:
-
Point your domain’s DNS A/AAAA records at the host.
-
Open
docker-compose.prod.ymlin your editor, find the commentedcaddy:block near the bottom, and uncomment it. Also uncomment thecaddy-data:+caddy-config:entries undervolumes:. -
Set
SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8in.envso the panel trusts Caddy’sX-Forwarded-*headers from the Docker bridge range. Caddy adds these headers on every reverse-proxy directive. Without trust, the panel would log Caddy’s IP instead of the real client. -
Bring the stack up:
Terminal window docker compose -f docker-compose.prod.yml up -dCaddy provisions the TLS cert on the first request. Subsequent restarts reuse the cached cert from the
caddy-datavolume.
Step 4 — Verify and log in
Section titled “Step 4 — Verify and log in”Watch the container come up:
docker compose -f docker-compose.prod.yml logs -f webYou should see a sequence of [prod-entrypoint] lines:
[prod-entrypoint] starting (image: ...)[prod-entrypoint] step 2: configuring Apache (PORT=80, trusted proxies: ...)[prod-entrypoint] step 3: waiting for DB at db:3306 ...[prod-entrypoint] step 3: DB is up[prod-entrypoint] step 4: rendering /var/www/html/web/config.php from environment[prod-entrypoint] step 5: first-boot install (schema + data + seed admin)[prod-entrypoint] step 5: seeding initial admin '<your name>'[prod-entrypoint] step 6: checking for pending updater migrations[prod-entrypoint] step 7: removing install/ + updater/ from writable layer[prod-entrypoint] step 8: ensuring writable cache/templates_c/demos[prod-entrypoint] boot complete; handing off to: apache2-foregroundOnce the last line lands, the panel is up. Open
https://<your-domain>/ (or http://<your-host>:8080/ if you
skipped Caddy) and log in with the INITIAL_ADMIN_* credentials you
set in .env.
The container’s healthcheck is GET /health.php; you can poke it
directly:
curl -s https://<your-domain>/health.php# OKA 200 OK body means the panel can reach the database. A 503 body
prints the failure reason.
Step 5 — Add a game server
Section titled “Step 5 — Add a game server”Once you’re logged in, the rest of the setup (registering a game server, configuring SourceMod plugins) follows the same flow as a tarball install:
The plugins live on your game servers and read directly from the same database the panel writes to. They don’t need to know the panel is running in Docker.
Persistent volumes
Section titled “Persistent volumes”docker-compose.prod.yml declares four named volumes. Their roles:
| Volume | Path inside container | Persist? |
|---|---|---|
dbdata | /var/lib/mysql | Required. The only source of truth for ban / admin / log rows. Loss = panel reset. |
demos | /var/www/html/web/demos | Required. Uploaded ban-evidence demos; not regenerable from anything else. |
cache | /var/www/html/web/cache | Optional. Smarty compile cache + PHP sessions. Wiping logs every admin out but doesn’t lose data. |
smarty | /var/www/html/web/templates_c | Optional. Same shape as cache, rebuilt on demand. |
Back up the dbdata and demos volumes regularly. The simplest
backup is a mariadb-dump for the DB and a tar for the demos:
# DB dumpdocker compose -f docker-compose.prod.yml exec db \ mariadb-dump -uroot -p"$DB_ROOT_PASS" --single-transaction sourcebans \ > backup-$(date +%F).sql
# Demosdocker run --rm -v <stack>_demos:/data -v "$PWD:/backup" alpine \ tar -czf /backup/demos-$(date +%F).tar.gz -C /data .(<stack> is the project name compose derives from the directory
the compose file lives in, usually the directory’s basename, e.g.
sourcebans-prod_demos.)
Upgrading
Section titled “Upgrading”The image is immutable. Upgrades are a tag bump. Pin
SBPP_IMAGE_TAG in .env to a specific version for reproducible
deploys:
SBPP_IMAGE_TAG=latestSBPP_IMAGE_TAG=1.7.0Then:
docker compose -f docker-compose.prod.yml pulldocker compose -f docker-compose.prod.yml up -dThe new container’s entrypoint runs any pending updater migrations
against the existing database. The migrations are idempotent: every
script in web/updater/data/<N>.php is forward-only and safe to
re-run. The dbdata and demos volumes survive the swap unchanged.
Image tags:
| Tag | What it points at |
|---|---|
latest | The newest published X.Y.Z release. |
X.Y.Z | A specific version. Recommended for production. |
X.Y | Floats forward across patch releases of X.Y.*. |
X | Floats forward across minor releases of X.*.*. |
Only tagged release builds are published. There is no rolling
:main or per-commit :sha-<short> tag. To test an unreleased fix,
git checkout the relevant ref and build the image locally with
docker buildx build -f docker/Dockerfile.prod -t sbpp:dev ..
Every published tag is signed with Sigstore cosign in keyless mode. Verify the signature with:
cosign verify ghcr.io/sbpp/sourcebans-pp:1.7.0 \ --certificate-identity-regexp='https://github.com/sbpp/sourcebans-pp/.github/workflows/docker-image.yml@.*' \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com'Docker secrets / *_FILE pattern
Section titled “Docker secrets / *_FILE pattern”Every secret-shaped env var (SB_SECRET_KEY, DB_PASS,
DB_ROOT_PASS, STEAMAPIKEY, INITIAL_ADMIN_PASSWORD) supports a
sibling _FILE form that reads the value from a file path inside
the container. This is the standard Docker Swarm / Kubernetes
secret-injection pattern.
A worked Swarm example with two secrets:
services: web: environment: SB_SECRET_KEY_FILE: /run/secrets/sbpp_jwt_key DB_PASS_FILE: /run/secrets/sbpp_db_pass secrets: - sbpp_jwt_key - sbpp_db_passsecrets: sbpp_jwt_key: file: ./secrets/jwt_key sbpp_db_pass: file: ./secrets/db_passThe entrypoint prefers the _FILE form when both the env var and
the path are set, and silently falls back to the plain env var when
the path isn’t present.
For deployments that mount config.php from a secret (rather than
letting the entrypoint render it into the image’s writable layer),
set SBPP_CONFIG_PATH to the mount path. Both the panel runtime
and the install wizard’s already-installed guard read the same
value, so the install-state sentinel stays consistent.
Deploy with one click
Section titled “Deploy with one click”This section is reserved for upcoming one-click deploy buttons for
DigitalOcean App Platform, Railway, Render, and Fly.io. Until those
land (tracked in
issue #1382),
deploy on those platforms by pointing the relevant service at the
ghcr.io/sbpp/sourcebans-pp:latest image and setting the same env
vars from .env.example.prod. The image already speaks the
platform-conventional knobs:
-
PORT: the entrypoint rewrites Apache’sListendirective to match. Render / Fly / Heroku-style injection works without extra config. -
DATABASE_URL: parsed before the splitDB_*vars get applied. Railway / Render / Fly’s “attached database” pattern works without extra config. -
/health.php: DB-aware healthcheck. Point the platform’s health probe at this path.
Troubleshooting
Section titled “Troubleshooting”The first place to look is the container’s stderr:
docker compose -f docker-compose.prod.yml logs --tail=200 webCommon failures and fixes:
| Symptom | Likely cause / fix |
|---|---|
Boot stalls at step 3: waiting for DB. | The db service hasn’t passed its healthcheck. Run docker compose ... logs db to see the underlying MariaDB error. Usually a wrong DB_ROOT_PASS on a re-deploy over an existing dbdata volume. |
step 4: minted fresh SB_SECRET_KEY on every restart. | You haven’t set SB_SECRET_KEY in .env. The first boot wrote a randomly-generated key into the writable layer’s config.php, but a container recreation rebuilds the layer fresh. Set the env var to persist. |
Healthcheck flips to unhealthy repeatedly. | Run curl https://<host>/health.php to see the failure reason. If it’s a DB connect error, the panel and DB containers have lost their network. docker compose ... restart usually resolves it. |
| Panel renders but Steam OpenID login fails. | STEAMAPIKEY is empty or wrong. Set it in .env; bring the panel back with docker compose ... up -d. |
Apache logs mod_remoteip warnings about RemoteIPInternalProxy. | The SBPP_TRUSTED_PROXIES value isn’t a valid CIDR. The entrypoint passes the value through verbatim; check for typos. |
For panel-level errors (login fails after Steam OAuth, “Driver not found”, DB connection issues), the existing troubleshooting pages apply identically to the Docker install: