A one-command dev environment for the SourceBans++ web panel. Spins up
PHP/Apache, MariaDB, Adminer, and Mailpit, seeds the database, and creates a
ready-to-use admin login. Source under web/ is bind-mounted, so edits show
up on the next request — no rebuilds needed.
Dev or prod? This page documents the development stack. For the production install path (immutable image pulled from GHCR, hardened entrypoint, no install wizard, multi-arch / cosign-signed) see the Docker quickstart docs page plus
docker-compose.prod.yml+.env.example.prodat the repo root. The two stacks are deliberately separate — the dev stack shipsadmin/admin, bind-mounts the worktree, definesSBPP_DEV_KEEP_INSTALL(a panel-takeover guard bypass), and exposes the DB to host. None of that is safe outside a developer's laptop.
- Docker 24+ with the Compose plugin (
docker compose, notdocker-compose) - Ports
8080,8081,8025,1025,3307free on the host (override in.env)
./sbpp.sh upThat builds the web image, starts everything in the background, runs
composer install on first boot, and seeds the schema + a default admin.
Then:
| Service | URL | Notes |
|---|---|---|
| SourceBans++ panel | http://localhost:8080 | login: admin / admin |
| Adminer (DB UI) | http://localhost:8081 | server db, user sourcebans |
| Mailpit (catch-all) | http://localhost:8025 | SMTP on mailpit:1025 |
| MariaDB (host port) | localhost:3307 |
user sourcebans pw same |
To stop:
./sbpp.sh down # keep volumes
./sbpp.sh reset # nuke volumes too (fresh DB on next up)Dockerfile php:8.5-apache + pdo_mysql, intl, zip, mbstring, opcache, composer
docker-compose.yml web, db, adminer, mailpit
docker/php/
web-entrypoint.sh waits for DB, renders config.php, runs composer install, fixes cache perms
dev-prepend.php normalizes HTTP_HOST so init.php's localhost guard accepts :8080
docker/db-init/
00-render-schema.sh on first DB init: substitutes {prefix}/{charset}, loads struc/data, seeds admin
sbpp.sh thin wrapper around `docker compose` + common dev tasks
The web container mounts ./web from the host, with two named-volume
overlays (vendor/ and cache/) so Composer artifacts and Smarty cache
don't leak onto the host filesystem. The ./docker tree is also bind-
mounted read-only at /var/www/html/docker so file-shape integration
tests under web/tests/integration/ can read production-only configs
(docker/apache/sbpp-prod.conf, docker/Dockerfile.prod, etc.) the
runtime panel never touches; CI's actions/checkout@v4 pulls the full
repo, so this mount keeps the local ./sbpp.sh test runner symmetric
with the CI gate. The ./docs tree, AGENTS.md, and CHANGELOG.md
are mounted read-only at the same level for the same reason — gates
like DocsUpgradeLinkRegressionTest (#1474) verify that
panel-side deep-links to sbpp.github.io still resolve against the
matching docs source file on disk.
./sbpp.sh logs # tail everything
./sbpp.sh logs web # tail one service
./sbpp.sh shell # bash in the web container (root)
./sbpp.sh shell db # mariadb client connected to dev DB
./sbpp.sh composer install # run composer in the web container
./sbpp.sh phpstan # phpstan analyse with the project's phpstan.neon
./sbpp.sh ts-check # tsc --checkJs gate over web/scripts (mirror of CI)
./sbpp.sh e2e # Playwright E2E gate (lazy chromium install) against the running stack
./sbpp.sh db-dump backup.sql # mysqldump to host file
./sbpp.sh db-load fixtures.sql # pipe a SQL file into the DB
./sbpp.sh db-reset # drop the DB volume + wipe MD5-named demo files from web/demos/
./sbpp.sh db-seed # populate the dev DB with realistic synthetic data + write demo files to web/demos/
./sbpp.sh rebuild # `--no-cache` rebuild of the web imagedb-seed lights up every data-driven panel surface — banlist + commslist
beyond a single page, dashboard "Latest …" cards, the drawer's history /
comments / notes panes (B/C/S/P comment types covered, so the moderation
queues and protest threads paint with real text), admin moderation
queues, admin audit log, multiple groups/admins/servers, and demo files
on disk under web/demos/ (so the banlist's "Review Demo" link returns
an actual download body) — without touching data.sql / struc.sql
(the install path stays minimal). Idempotent: every run truncates the
synthesizer-owned tables first and re-seeds. Deterministic given
--seed=<int> so two devs see the same data; pinned default seed in
code. Three scale tiers:
./sbpp.sh db-seed # default scale (~200 bans, ~100 comms, ~80 demos)
./sbpp.sh db-seed --scale=small # ~30 bans, ~15 demos, fast iteration
./sbpp.sh db-seed --scale=large # ~2000 bans, ~800 demos, pagination / perf
./sbpp.sh db-seed --seed=42 # alternate RNG seedDemo files: each :prefix_demos row lands a paired ~1 KiB opaque file
in web/demos/ (host bind-mount; gitignored alongside production
upload evidence). Filenames are MD5(seed|demtype|demid) so re-runs with
the same seed produce stable basenames and the truncate-time wipe can
clean them up by re-reading the table. The synth payload is text, not a
playable Source-engine demo — the panel chrome and getdemo.php stream
the bytes verbatim with Content-Type: application/octet-stream, which
is enough for the download affordance to work end-to-end, but the SDK
demoviewer won't replay them. Manual uploads (panel-side or anything
else not referenced by :prefix_demos) are never touched by the wipe.
db-reset drops the entire DB volume AND wipes MD5-named files from
web/demos/ (orphans after the DB drop — the _demos rows that
pointed at them vanished with the volume). The wipe is scoped to
[0-9a-f]{32} basenames so .gitkeep stays put; non-MD5 manual
uploads (the icon / mapimg flows preserve original filenames) survive
too, though those don't normally land in web/demos/.
Refusal guard: the seeder strictly refuses any DB_NAME other than
sourcebans, so the PHPUnit DB (sourcebans_test) and Playwright DB
(sourcebans_e2e) cannot be wiped by accident. The Synthesizer source
is at web/tests/Synthesizer.php (Sbpp\Tests\Synthesizer); the CLI
driver is web/tests/scripts/seed-dev-db.php.
Re-login required after each run — the seeder truncates sb_login_tokens
along with the rest of the user-data tables, which invalidates any open
browser session against the dev panel. Just hit /index.php?p=login
again with admin / admin.
Six gates run in CI on every PR; the first five each have a one-shot wrapper for local runs. The sixth (plugin compile) is CI-only — the dev stack ships no spcomp, and panel-only PRs never trigger it.
./sbpp.sh phpstan # static analysis (web/phpstan.neon, baseline at web/phpstan-baseline.neon)
./sbpp.sh test # PHPUnit against the dedicated sourcebans_test DB
./sbpp.sh ts-check # tsc --checkJs over web/scripts (#1098)
./sbpp.sh composer api-contract # regenerate web/scripts/api-contract.js (#1112)
./sbpp.sh e2e # Playwright + axe against the dev stack (#1124)
# Plugin compile (no sbpp.sh wrapper — install spcomp locally if you want to mirror it)
(cd game/addons/sourcemod/scripting && spcomp -i include sbpp_*.sp)ts-check runs the TypeScript compiler in --checkJs mode against the
vanilla JS in web/scripts/, using web/scripts/tsconfig.json plus the
@ts-check directives and JSDoc annotations on each file. The first run
inside a fresh container does an npm install (cached afterwards) — total
cold cost is a few seconds, subsequent runs are sub-second. There is no
build step; nothing in web/node_modules/ ships to production.
e2e runs the Playwright suite under web/tests/e2e/ inside the web
container against a dedicated sourcebans_e2e database (so dev data
and PHPUnit's sourcebans_test are both untouched). First run installs
@playwright/test + the chromium browser + its system dependencies via
npx playwright install --with-deps chromium; subsequent runs reuse
the cached install. Forwards args to npx playwright test, e.g.
./sbpp.sh e2e --grep @screenshot for the per-PR screenshot gallery.
./sbpp.sh phpstan runs PHPStan inside the web container with
staabm/phpstan-dba wired up against
the running db service. The wrapper exports DBA_HOST=db (plus DBA_USER,
DBA_PASS, DBA_NAME, DBA_PREFIX) so web/phpstan-dba-bootstrap.php can
introspect the live schema and type-check raw SQL strings — column names,
table names, and statement syntax in every Database::query(...) call get
validated against web/install/includes/sql/struc.sql as it would be loaded
by the seed script.
To skip the DBA pass (useful when the DB container is down or you're
iterating on unrelated rules), set PHPSTAN_DBA_DISABLE=1:
PHPSTAN_DBA_DISABLE=1 ./sbpp.sh phpstanThe bootstrap also degrades gracefully if it can't reach the DB at all, so a
fresh checkout without ./sbpp.sh up won't break the gate — it just runs the
non-DBA rules.
CI mirrors this: .github/workflows/phpstan.yml spins up MariaDB 10.11,
renders struc.sql (no data.sql — phpstan-dba only needs structure), and
points the same env vars at it. Renaming or removing a column in struc.sql
without updating its callers will fail the PHPStan job. CI also sets
DBA_REQUIRE=1 so a missing service or credentials drift fails the job
loudly instead of silently disabling the SQL checks.
- DB: MariaDB only runs
/docker-entrypoint-initdb.d/*on the first boot of a fresh data volume. Our00-render-schema.shreadsweb/install/includes/sql/struc.sqlanddata.sql, replaces{prefix}withsband{charset}withutf8mb4, pipes them in, then inserts anadminrow with a pinned bcrypt hash for the passwordadmin. - Web: the entrypoint waits until MariaDB answers, generates
web/config.phpfrom env vars (only if absent or empty), runscomposer installifvendor/is missing, thenexecs Apache. HTTP_HOSTshim:init.phpblocks the panel when theinstall/directory is present unlessHTTP_HOST == "localhost". The bind mount means we can't deleteinstall/from the container, and our forwarded port produceslocalhost:8080.dev-prepend.phpis loaded viaauto_prepend_fileand rewrites the host tolocalhostfor any loopback request, satisfying the guard without weakening it elsewhere.
Drop a .env next to docker-compose.yml to override published ports — see
docker/.env.example. To change DB credentials, edit the environment:
blocks in docker-compose.yml and ./sbpp.sh reset to re-seed.
To pre-seed your own data, drop additional *.sql or *.sh files into
docker/db-init/. They'll be picked up on the next fresh init (after
./sbpp.sh reset).
To run two stacks side-by-side (e.g. one per git worktree, or one per
parallel agent), drop a worktree-local docker-compose.override.yml
that sets a unique top-level name:, renames each container_name,
and remaps the host ports. The file is auto-loaded by docker compose
and gitignored. See AGENTS.md → "Parallel stacks"
for the canonical template.
- Dev only. The
HTTP_HOSTshim, the seeded admin password, and the exposed DB port are not safe in production. - Composer cache. First
upcan take a couple of minutes to download dependencies. Subsequent boots reuse thevendorvolume. - macOS / Windows file watching. Bind mounts on non-Linux Docker hosts
can be slow; OPcache is set to revalidate every request which may amplify
this. Bump
opcache.revalidate_freqindocker/Dockerfileif it bites.