Skip to content

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.

You need:

  • Docker 24+ and Compose v2 (bundled into Docker Desktop and recent Docker Engine releases). docker compose version should print v2.x or 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:12345678 form). SteamID I/O converts between formats.

You do not need PHP, Composer, MariaDB, or a webserver on the host. The image carries everything.

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:

Terminal window
mkdir -p ~/sourcebans-prod
cd ~/sourcebans-prod
curl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker-compose.prod.yml
curl -O https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/.env.example.prod
mkdir -p docker/caddy
curl -o docker/caddy/Caddyfile.example \
https://raw.githubusercontent.com/sbpp/sourcebans-pp/main/docker/caddy/Caddyfile.example

A 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:

Terminal window
cp .env.example.prod .env

Open .env in your editor. The template is grouped into Required, Recommended, First-boot install, and Optional sections. At minimum:

  1. SB_SECRET_KEY: the JWT signing key. Generate once and keep it forever. Rotating it logs every admin out.

    Terminal window
    openssl rand -base64 47

    Paste the output into SB_SECRET_KEY=....

  2. DB_ROOT_PASS and DB_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_PASS
    openssl rand -base64 24 # DB_PASS
  3. STEAMAPIKEY: paste your Steam Web API key.

  4. 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.

  5. 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).

If you’re putting the panel behind your own existing reverse proxy (nginx, Traefik, Cloudflare Tunnel) or skipping TLS for a private network deploy:

Terminal window
docker compose -f docker-compose.prod.yml up -d

The 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:

  1. Point your domain’s DNS A/AAAA records at the host.

  2. Open docker-compose.prod.yml in your editor, find the commented caddy: block near the bottom, and uncomment it. Also uncomment the caddy-data: + caddy-config: entries under volumes:.

  3. Set SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8 in .env so the panel trusts Caddy’s X-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.

  4. Bring the stack up:

    Terminal window
    docker compose -f docker-compose.prod.yml up -d

    Caddy provisions the TLS cert on the first request. Subsequent restarts reuse the cached cert from the caddy-data volume.

Watch the container come up:

Terminal window
docker compose -f docker-compose.prod.yml logs -f web

You 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-foreground

Once 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:

Terminal window
curl -s https://<your-domain>/health.php
# OK

A 200 OK body means the panel can reach the database. A 503 body prints the failure reason.

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.

docker-compose.prod.yml declares four named volumes. Their roles:

VolumePath inside containerPersist?
dbdata/var/lib/mysqlRequired. The only source of truth for ban / admin / log rows. Loss = panel reset.
demos/var/www/html/web/demosRequired. Uploaded ban-evidence demos; not regenerable from anything else.
cache/var/www/html/web/cacheOptional. Smarty compile cache + PHP sessions. Wiping logs every admin out but doesn’t lose data.
smarty/var/www/html/web/templates_cOptional. 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:

Terminal window
# DB dump
docker compose -f docker-compose.prod.yml exec db \
mariadb-dump -uroot -p"$DB_ROOT_PASS" --single-transaction sourcebans \
> backup-$(date +%F).sql
# Demos
docker 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.)

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=latest
SBPP_IMAGE_TAG=1.7.0

Then:

Terminal window
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d

The 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:

TagWhat it points at
latestThe newest published X.Y.Z release.
X.Y.ZA specific version. Recommended for production.
X.YFloats forward across patch releases of X.Y.*.
XFloats 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:

Terminal window
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'

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:

docker-compose.prod.override.yml
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_pass
secrets:
sbpp_jwt_key:
file: ./secrets/jwt_key
sbpp_db_pass:
file: ./secrets/db_pass

The 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.

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’s Listen directive to match. Render / Fly / Heroku-style injection works without extra config.

  • DATABASE_URL: parsed before the split DB_* 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.

The first place to look is the container’s stderr:

Terminal window
docker compose -f docker-compose.prod.yml logs --tail=200 web

Common failures and fixes:

SymptomLikely 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: