Skip to content

feat: production Docker image + compose for self-hosters (#1381)#1391

Merged
rumblefrog merged 13 commits into
mainfrom
feat/prod-docker-image
May 16, 2026
Merged

feat: production Docker image + compose for self-hosters (#1381)#1391
rumblefrog merged 13 commits into
mainfrom
feat/prod-docker-image

Conversation

@rumblefrog

Copy link
Copy Markdown
Member

Closes #1381.

Summary

Ships the five deliverables from #1381 — a production-ready Docker image, a self-hoster compose file, GHCR publishing + cosign signing in CI, and matching docs — together with the cross-cutting one-click-deploy friendliness constraints (PORT, DATABASE_URL, *_FILE secrets, SBPP_CONFIG_PATH, mod_remoteip trust list, unauthenticated DB-aware healthcheck).

The image is a multi-stage build distinct from the dev docker/Dockerfile. The contracts diverge enough (no SBPP_DEV_KEEP_INSTALL, no auto_prepend_file, no nodejs/npm/git/composer/mysql-client in the runtime stage, OPcache tuned for prod, non-root www-data) that a single Dockerfile would either bloat dev or risk prod silently inheriting a dev-only knob. The prod entrypoint owns the install + migrate state machine the dev stack ducks via SBPP_DEV_KEEP_INSTALL + the seeded DB — it drives a headless install from INITIAL_ADMIN_* env vars, runs pending updater migrations, and removes install/ + updater/ from the writable layer before handing off to Apache so the runtime install-guard from #1335 C1 stays armed.

The whole change went through an adversarial-review pass before merge: a reviewer surfaced 6 CRITICAL + 5 HIGH + 6 MEDIUM + 4 LOW + 4 NIT findings (URL-decoder broken under dash, INITIAL_ADMIN_PASSWORD leaking into long-running Apache env, healthcheck leaking PDO error text to unauth callers, Host::isSecure() trusting X-Forwarded-Proto from anyone, migration failures silently swallowed, unvalidated DB_PREFIX/DB_NAME in SQL/sed, web/index.php ignoring SBPP_CONFIG_PATH, wizard hardcoding the write path, default-mysql-client shipping against spec, CI path-filter missing files that affect the image, the "log in as CONSOLE row only" nudge being impossible, …). Every CRITICAL + HIGH is fixed; the higher-impact MEDIUMs (empty-file secret resolver, config.php syntax validation, _settings.version sentinel against the _admins-exists race, telemetry-tick skip on the healthcheck, SBPP_CONFIG_PATH to an unmounted volume) plus all cheap LOW/NITs are fixed too.

Notable design choices:

  • Headless install is the default (SBPP_AUTO_INSTALL=1) — app platforms preview-deploy containers before they're routable, so a wizard-driven flow would never get an interactive operator. SBPP_AUTO_INSTALL=0 is the escape hatch for operators who want the wizard (in that path, install/ is left in place).
  • SBPP_CONFIG_PATH decouples config.php from the image's read-only layers; some app platforms run with read-only roots + a writable /data mount. Threaded through init.php, index.php, init-recovery.php, the install wizard's render step, and already-installed.php.
  • Host::isSecure() now gates HTTP_X_FORWARDED_PROTO on $_SERVER['REMOTE_ADDR'] being in SBPP_TRUSTED_PROXIES (CIDR + literal IPv4/IPv6 supported). Pre-fix any client could spoof X-Forwarded-Proto: https and silently flip the panel's Secure cookie / redirect / link-builder logic. New regression guard at web/tests/unit/Auth/HostIsSecureTest.php (13 cases).
  • Schema bootstrap + migration runner is pure PHP via docker/php/sb-db.php so the runtime image doesn't need default-mysql-client; saves ~22MB and removes a shell-injection surface in the schema-loading path.
  • web/health.php is unauthenticated, DB-aware, and surfaces only unhealthy\n on failure — the full PDOException chain stays in error_log() for operator debug but doesn't leak DB host / username / auth-mode to public callers.
  • Cosign keyless OIDC signing of the image so self-hosters can cosign verify against the GitHub Actions identity.

The dev stack is untouched. ./sbpp.sh up keeps the bind-mount + SBPP_DEV_KEEP_INSTALL + dev OPcache model that's load-bearing for PR review velocity.

Test plan

Quality gates that ran clean locally:

  • php -l on the 13 touched PHP files (web/health.php, web/init.php, web/init-recovery.php, web/index.php, web/install/already-installed.php, web/install/bootstrap.php, web/install/pages/page.5.php, web/includes/Auth/Host.php, web/includes/Telemetry/Telemetry.php, the three new tests, docker/php/sb-db.php).
  • ./sbpp.sh phpstan[OK] No errors (238 files).
  • ./sbpp.sh testOK (611 tests, 2529 assertions).
  • ./sbpp.sh ts-check — clean.
  • ./sbpp.sh composer api-contract — no diff (no handler changes).
  • shellcheck docker/php/prod-entrypoint.sh — clean (set -euo pipefail with the SC3040 disable carrying its rationale).
  • hadolint docker/Dockerfile.prod — clean.
  • actionlint .github/workflows/docker-image.yml — clean.

Docker-specific smoke testing (recommended to run before / shortly after merge — can't be exercised inside the worktree's sibling-container constraint):

  • Local multi-arch image build: docker buildx build --platform linux/amd64,linux/arm64 -f docker/Dockerfile.prod -t sbpp:test .. Verify docker run --rm sbpp:test php -m lists exactly pdo_mysql, gmp, intl, zip, mbstring (plus core) and no git/composer/xdebug/mysql.
  • First-boot install end-to-end: cp .env.example.prod .env, fill SB_SECRET_KEY / DB_* / INITIAL_ADMIN_*, docker compose -f docker-compose.prod.yml up -d. Verify the entrypoint logs walk the state machine, /health.php returns 200 with body OK\n, panel login as INITIAL_ADMIN_NAME works, install/ and updater/ are absent in the running container (docker exec sbpp-web ls web/install web/updater).
  • Idempotent restart: docker compose restart web — verify entrypoint short-circuits (no schema re-install, no updater re-run), panel stays logged in (SB_SECRET_KEY persisted through config.php).
  • DATABASE_URL parse path with URL-encoded password (mysql://user:p%40ss%23word@db:3306/sourcebans?charset=utf8mb4) — verify PDO connect succeeds (the URL-decoder fix for dash-vs-bash).
  • *_FILE secret resolution: mount /run/secrets/db_password, set DB_PASS_FILE=/run/secrets/db_password, verify connection. Then truncate the secret file to 0 bytes — verify entrypoint dies with is empty rather than connecting passwordless.
  • PORT env injection: docker run -e PORT=8080 -p 8080:8080 sbpp:test — verify Apache listens on 8080 (Render/Fly contract).
  • Reverse-proxy headers: set SBPP_TRUSTED_PROXIES=172.16.0.0/12, hit panel via the bundled Caddy stub, verify $_SERVER['REMOTE_ADDR'] is the real client IP in Log::add entries. Then hit / directly bypassing the proxy with X-Forwarded-Proto: https — verify Host::isSecure() reports false (the CRIT-4 fix).
  • Healthcheck error-text containment: stop the db service, hit /health.php, verify response body is just unhealthy\n (no SQLSTATE / no internal IP) and HTTP status is 503.
  • Migration-runner failure detection: introduce a deliberately-broken web/updater/data/9999.php that returns false, register it in store.json, restart container — verify entrypoint dies non-zero with the failure marker from the message stack (the CRIT-5 fix).
  • CI dry-run on personal fork: confirm the docker-image.yml workflow builds both arches, pushes to GHCR, and produces a cosign signature.

rumblefrog added 13 commits May 15, 2026 20:28
…1381)

Adds `sbpp_resolve_config_path()` to `init-recovery.php` so the panel
runtime + the install wizard read config.php from the same path. When
unset, both halves fall back to the legacy `<panel-root>/config.php`
location (tarball / wizard installs continue to work unchanged).

This unblocks the production Docker image (#1381 deliverable 4d):
operators can mount config.php from a Docker secret at e.g.
`/run/secrets/sbpp-config.php` and set `SBPP_CONFIG_PATH` so both the
runtime loader and the wizard's already-installed guard check the
mounted path, instead of the read-only image layer.

`web/install/already-installed.php` re-implements the env-var read
inline (rather than reaching for `init-recovery.php`) per its
self-contained "no Sbpp\…, no Smarty, no vendor/" docblock — the C2
guard runs upstream of Composer autoload for the same defensiveness
reasons as `recovery.php`.

Tests:
- `testResolveConfigPathHonorsEnvVar` pins the helper's behaviour
  (unset → default; set → env value wins).
- `testWizardGuardHonorsConfigPathEnvVar` pins the wizard-side
  contract so the panel-takeover surface stays closed when config
  lives outside the panel root.
Deliverables 1, 2, 5b from #1381.

`docker/Dockerfile.prod` — multi-stage build separate from the dev
`docker/Dockerfile`:

- `builder` stage on `php:8.5-cli` + `composer:2` runs the same
  `composer install --no-dev --optimize-autoloader --prefer-dist`
  shape as `release.yml` so the image ships byte-identical vendor/
  to the release tarball.
- `runtime` stage on `php:8.5-apache` carries ONLY runtime
  extensions (pdo_mysql, intl, zip, mbstring, gmp) — no nodejs /
  npm / git / composer / dev-prepend.
- Multi-arch-friendly (no `--platform=...` pins; workflow drives
  linux/amd64 + linux/arm64 via buildx + qemu).
- Production OPcache: validate_timestamps=0, ~6k accelerated files.
- HEALTHCHECK via curl against /health.php.
- OCI labels for GHCR auto-population + `docker inspect` provenance.

`docker/php/prod-entrypoint.sh` — pure POSIX shell state machine
(no bash-isms). Drives:
- *_FILE secret resolution (Docker Swarm + k8s).
- DATABASE_URL parsing (Render / Fly / Railway-style).
- PORT injection (Render / Fly / Heroku-style — rewrites
  /etc/apache2/ports.conf + the vhost).
- mod_remoteip via SBPP_TRUSTED_PROXIES (defaults to "no trust"
  so plain Docker isn't accidentally trust-everyone; the conf
  also mirrors X-Forwarded-Proto -> $_SERVER['HTTPS'] for the
  legacy PHP-side checks via SetEnvIfExpr).
- Wait-for-DB via mysqladmin ping (60s ceiling).
- config.php render (only when missing/empty — config.php is the
  install-state sentinel per AGENTS.md, never auto-regenerated).
- First-boot install: pipes struc.sql + data.sql with {prefix} /
  {charset} substituted, seeds initial admin from
  INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD} env vars.
- Pending migrations: drives the headless Updater via PHP heredoc
  (each `web/updater/data/<N>.php` is idempotent per the AGENTS.md
  contract, so re-running on every container start is safe).
- Strips install/ + updater/ from the writable layer so the
  panel-runtime guard in init-recovery.php passes — production
  panels MUST NOT define SBPP_DEV_KEEP_INSTALL.
- Ensures writable cache/ + templates_c/ + demos/.
- exec apache2-foreground.

`docker/php/prod-php.ini` — 12-factor error config (display_errors
Off, log_errors On, errors to /dev/stderr), expose_php Off, OPcache
production knobs, UTC timezone default.

`docker/apache/sbpp-prod.conf` — denies access to dotfiles,
vendor/, configs/, includes/, install/, updater/, cache/,
templates_c/, config.php, composer.{json,lock}. RemoteIPHeader
X-Forwarded-For declared here so the entrypoint's per-deploy
conf only has to add `RemoteIPInternalProxy` lines.

`web/health.php` — unauthenticated DB-aware healthcheck. Loads
init.php (the authoritative bootstrap; the entrypoint's step 7
removes install/+updater/ before Apache binds so the guard passes
by the time this file serves traffic) and runs `SELECT 1` via
`$GLOBALS['PDO']`. Returns 200 OK on success, 503 + plain-text
failure reason otherwise. Cache-Control: no-store, X-Robots-Tag:
noindex.
Deliverable 4 from #1381. `docker-compose.prod.yml` lives alongside
the dev compose (not replacing it) so self-hosters with the repo
checked out can pick either one.

Shape:
- `web` service pulls `ghcr.io/sbpp/sourcebans-pp:${SBPP_IMAGE_TAG:-latest}`
  (NOT a build context — operators run `docker compose pull` to
  upgrade, not a local build).
- `db` service is mariadb:11 LTS with a `dbdata` volume + healthcheck.
  The DB port is NOT exposed to the host by default — the web
  container reaches the DB via the compose network's service-name
  DNS. A commented block shows operators how to opt into a
  localhost-only host binding for tooling access.
- Three named volumes per the spec:
  - `dbdata` — MUST persist (panel's only source of truth for
    ban / admin / log rows).
  - `demos` — uploaded ban-evidence demos; MUST persist (admin
    user content, not regenerable).
  - `cache` + `smarty` — Smarty compile + sessions; CAN be
    ephemeral (panel rebuilds on first request).
- Commented `caddy` service stub at the bottom: uncomment to opt
  into automatic Let's Encrypt TLS termination via the
  Caddyfile.example shipped under docker/caddy/. The stub uses the
  service-name DNS so the panel container doesn't need a
  host-port exposure when Caddy is in front.

`.env.example.prod` documents every supported env var grouped by
required / recommended / first-boot-install / optional / advanced.
Calls out the `*_FILE` Docker-secret pattern for Swarm/k8s deploys
explicitly. Required vars (SB_SECRET_KEY, DB_PASS, DB_ROOT_PASS)
use the `${VAR:?...}` "fail with this message" compose syntax so a
fresh-deploy operator who forgot to set them gets a useful
container-startup error instead of a silent JWT-cookie reset on
every restart.

`docker/caddy/Caddyfile.example` — one-line reverse_proxy to the
`web` service plus standard Caddy goodies (zstd/gzip encode, a
year-long cache header for static assets). Documented to be paired
with `SBPP_TRUSTED_PROXIES=172.16.0.0/12 10.0.0.0/8` so the panel
trusts Caddy's X-Forwarded-* headers from the Docker bridge range.
Deliverable 3 from #1381. `.github/workflows/docker-image.yml`
builds `docker/Dockerfile.prod` for linux/amd64 + linux/arm64 via
buildx + qemu, publishes to GHCR under
`ghcr.io/sbpp/sourcebans-pp:<tags>`, and signs each tag with
Sigstore cosign in keyless (OIDC) mode.

Triggers + tag mapping:
- push to main         → :main, :sha-<short>
- X.Y.Z tag            → :X.Y.Z, :X.Y, :X, :latest
- pull_request         → verify-build-only (no push, no sign)
- workflow_dispatch    → manual rerun (same shape as main push)

The `:latest` tag is gated on `type=semver,pattern={{version}}` +
`enable=${{ startsWith(github.ref, 'refs/tags/') }}` so main
pushes can't accidentally claim it; only formal X.Y.Z tags do.

Path filter scope is intentionally narrow — Dockerfile.prod, the
docker/ assets it touches, composer.{json,lock} for vendor churn,
the panel files the entrypoint relies on (init.php /
init-recovery.php / health.php), and the workflow file itself.
Every web/** edit doesn't rebuild the prod image; that would burn
action minutes for surface changes the next release tag picks up
anyway. Main and tag pushes always rebuild.

Cosign keyless signing requires `id-token: write` at the job
permissions level. Each computed tag gets its own signature
against the immutable digest (`<image>@<digest>`) so a future
re-tag doesn't invalidate the signature. Verifiers can pin
identity / issuer:

  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'

Caches via `type=gha,mode=max` so a Composer-only change doesn't
bust the apt-install layer of the builder stage.
Deliverable 5 from #1381 and the paired AGENTS.md / docker README sync.

New doc:

- `docs/src/content/docs/getting-started/quickstart-docker.mdx` —
  walks a self-hoster from `mkdir ~/sourcebans-prod` to "logged in
  as admin". Covers env-var configuration, the *_FILE Docker-secret
  pattern, persistent-volume layout, Caddy reverse-proxy setup,
  the cosign-verify command, upgrading by tag bump, and a
  troubleshooting table for the common boot-time failures. Reserves
  a "Deploy with one click" section pointing at #1382 (the app-platform
  buttons follow-up).

Updated docs:

- `overview.mdx` — adds a `<Tabs syncKey="install-path">` block
  with three arms (tarball / docker / app-platform) so the install
  path is the first decision a new self-hoster makes. The
  "Things you'll set up" list defers the Step 2 instructions to
  whichever path the operator picks.
- `prerequisites.mdx` — adds the same Tabs at the top so Docker
  operators see "Docker 24+, Compose v2, 512MB RAM" instead of
  "PHP 8.5+, four extensions" which doesn't apply to them. The
  PHP / DB sections stay for the tarball path.
- `astro.config.mjs` — adds the new quickstart-docker page to the
  Getting Started sidebar group, ordered after the tarball
  quickstart.

AGENTS.md changes:

- Quality gates table: new "Prod Docker image" row pointing at
  the local `docker buildx build` command + `docker-image.yml`
  workflow. Bumped the header from "six gates" to "seven gates".
- New "Prod Docker image specifics" block (paired with the
  existing "Plugin build specifics" / "ts-check specifics" /
  "PHPStan specifics" blocks). Documents the path-filter scope,
  multi-arch via qemu, PR-builds-verify-only contract, tag
  mapping logic, cosign keyless signing requirements, and the
  "no local ./sbpp.sh wrapper" decision.
- "Where to find what" table: three new rows for the prod image
  / compose stack / SBPP_CONFIG_PATH plumbing.
- "Keep the docs in sync" table: the install-quickstart row
  now points at BOTH `quickstart.mdx` and `quickstart-docker.mdx`
  with the per-path note. New row pinning that any change to
  the prod Docker cluster ships paired updates to AGENTS.md
  (quality gates + where-to-find) + the operator-facing docs
  + docker/README.md.

`docker/README.md` — adds a "Dev or prod?" pointer block at the
top so anyone landing on the dev README to learn about Docker
discovers the prod path exists. Explains the dangerous dev-only
behaviours (admin/admin, bind-mounted worktree,
SBPP_DEV_KEEP_INSTALL, DB port exposed) so the read-cost-vs-
take-this-to-production trade-off is obvious.
…016 in prod image (#1381)

Lint pass over the new prod docker assets after running the upstream
linters in CI-equivalent containers:

- `docker/php/prod-entrypoint.sh`: `resolve_file_secret`'s
  `export "$name"` is correct POSIX shell, but shellcheck SC2163 fires
  on the deferred expansion. Swap to the documented `${var?}`
  silencing shape, which also gates against `$name` being unset —
  strictly tighter behaviour than the bare form.
- `docker/php/prod-entrypoint.sh`: the `php -r 'echo password_hash(\$argv[1], …)'`
  call uses single quotes intentionally so the shell doesn't rewrite
  the literal `\$argv` before php sees it. Mark the line with
  `# shellcheck disable=SC2016` + the rationale instead of converting
  to double quotes (which would either need shell escaping or risk
  variable expansion of `\$argv`).
- `docker/Dockerfile.prod`: add `# hadolint ignore=DL3008` to the three
  `apt-get install` blocks. Debian-base apt packages don't pin cleanly
  across releases; the project pins the BASE IMAGE tag (`php:8.5-cli`)
  instead, which gives a reproducible Debian snapshot. Pinning
  individual apt packages would force a coupled bump every time the
  base image rolls forward.
- `docker/Dockerfile.prod`: add `# hadolint ignore=SC2016` to the sed
  block that rewrites `/var/www/html` -> `\${APACHE_DOCUMENT_ROOT}` in
  Apache's vhost configs. The literal `\${APACHE_DOCUMENT_ROOT}` is
  resolved at Apache STARTUP TIME via Apache's own env-var support, not
  by the shell — letting the shell pre-expand would bake the build-time
  value in and lose the runtime-overridability of the env var.

Also rewrite the `gmp` comment in the runtime stage: the original
attributed `gmp` to `Sbpp\\SteamID` math, but that class uses native
PHP integer arithmetic. The actual justifications are (a) the spec'd
runtime-extension list in #1381 deliverable 1 and (b) the
`maxmind-db/reader` soft-dependency for the pure-PHP MMDB integer
decoding path.

All four linters (shellcheck, hadolint, actionlint, `docker compose
config`) now report clean on the prod-side files. The dev
`docker/Dockerfile` carries the same DL3008 + SC2016 patterns but is
intentionally out of scope here — touching it would balloon the PR.
…IGH-3/5, MED-1/2/3/5, LOW-1, NIT-2)

Address ten findings from the #1381 adversarial review by reworking
`docker/php/prod-entrypoint.sh` and introducing the bundled PHP CLI
helper `docker/php/sb-db.php`.

* CRIT-1 — `parse_database_url` now URL-decodes the user / password
  fields via `php -r 'echo urldecode(...);'` instead of the previous
  `sed | printf '%b'` hack, which was a silent no-op on Debian's
  /bin/sh (dash). Passwords carrying any URL-reserved character
  (`@`, `:`, `/`, `?`, `#`, `&`, `%`, `+`, space) survive the
  round-trip now. Verified `us%40er` → `us@er` and `p%40ss%23word`
  → `p@ss#word` against `php:8.5-apache`.
* CRIT-2 — `main()` unsets `INITIAL_ADMIN_*`, `INITIAL_ADMIN_*_FILE`,
  `DB_PASS`, `DB_PASS_FILE`, `SB_SECRET_KEY`, `SB_SECRET_KEY_FILE`,
  `STEAMAPIKEY`, `STEAMAPIKEY_FILE`, and `DATABASE_URL` immediately
  before `exec "$@"`. Apache children would otherwise inherit
  cleartext credentials reachable via `$_ENV` / `phpinfo()` /
  `getenv()` for the lifetime of the container.
* CRIT-5 — `run_pending_migrations` inspects the `Updater` message
  stack for failure markers (`Error executing:`, `Update Failed!`)
  after each pass and `exit(1)`s if any are found, so the shell's
  `if [ "$rc" -ne 0 ]` is no longer dead code. A failing migration
  now aborts the boot loop instead of silently leaving the panel
  on a half-applied schema.
* CRIT-6 — New `validate_identifiers` stage runs between
  `apply_defaults` and `configure_apache`. It rejects `DB_PREFIX` /
  `DB_NAME` / `DB_USER` / `DB_CHARSET` (`[A-Za-z0-9_]+`), `DB_HOST`
  (`[A-Za-z0-9._-]+`), and `DB_PORT` (`[0-9]+`) before any sed
  substitution or SQL interpolation, closing the
  `DB_PREFIX="foo'; DROP --"` class of bug.
* HIGH-3 — `wait_for_db` and `run_sql` no longer call `mysqladmin`
  / `mysql`; both go through the new `sb-db.php` PDO helper. Pairs
  with the Dockerfile.prod change in a follow-up commit that drops
  `default-mysql-client` from the runtime apt layer (~22MB image
  size win) — the entrypoint side is the load-bearing half so it
  ships first.
* HIGH-5 — `first_boot_install` no longer emits the "log in as
  CONSOLE" nudge (impossible to follow — the CONSOLE row carries
  an empty password and `NormalAuthHandler` rejects empty auth).
  Instead: if `SBPP_AUTO_INSTALL=1` and any of
  `INITIAL_ADMIN_{NAME,STEAM,EMAIL,PASSWORD}` are empty, die loud
  with a clear remediation message. If `SBPP_AUTO_INSTALL=0`,
  skip the schema bootstrap AND `strip_install_dirs` so the wizard
  surface stays reachable at `/install/`.
* MED-1 — `resolve_file_secret` rejects empty files (`[ -z … ]
  die "${file_path} is empty"`) — a zero-byte secret was previously
  treated as a successfully-resolved empty value.
* MED-2 — `render_config` runs `php -l "${SBPP_CONFIG_PATH}"` on
  the existing config before short-circuiting, so a corrupted /
  partially-truncated config.php from a crashed prior run surfaces
  in the boot log instead of as a 500 on every page load.
* MED-3 — Install sentinel switched from "does `{prefix}_admins`
  table exist" to "does `{prefix}_settings.config.version` row
  exist". The new check is the second-to-last `INSERT` in
  `data.sql`, so its presence proves `data.sql` ran to completion.
  Pre-fix, a crash mid-`data.sql` would leave the admins table
  populated and skip the install pass on next boot, booting against
  a half-seeded `:prefix_settings`. Implemented via the new
  `sb-db.php has-version-row <prefix>` subcommand.
* MED-5 — `render_config` rejects an explicit `SBPP_CONFIG_PATH`
  whose parent directory doesn't exist (die — the operator
  intended a mount that isn't there) and warns if the parent is on
  the same `st_dev` as `/` (likely the writable layer; config
  won't persist across `docker run` recreations).
* LOW-1 — `set -eu` upgraded to `set -euo pipefail` so pipeline
  failures (e.g. `sed | run_sql` for the schema apply) propagate.
  Paired with a `shellcheck disable=SC3040` annotation explaining
  why dash 0.5.11+ / busybox ash 1.31+ both support pipefail
  despite POSIX-pre-2024 not requiring it.
* NIT-2 — `resolve_file_secret` strips trailing `\r\n` so the
  ergonomic `echo "secret" > /run/secrets/db_pass` shape (which
  appends a newline) Just Works.

New file `docker/php/sb-db.php` is the load-bearing replacement for
the `mysqladmin` / `mysql` CLI tools the entrypoint previously
shelled out to. Three subcommands:

* `ping` — open a PDO connection (server-level, no `dbname=`) and
  exit 0/1. Wraps `mysqladmin ping`.
* `has-version-row PREFIX` — return 0 if the
  `:prefix_settings.config.version` row exists, 1 otherwise.
  PDOException 42S02 (table missing) maps to 1 so a truly fresh DB
  still triggers the install pass.
* `exec` — read SQL from STDIN, split on top-level `;` while
  respecting `'` / `"` / `\`` quoted strings, `--` / `#` / `/* */`
  comments, and backslash escapes, then run each statement via
  `PDO::exec`. Fails loud (exit 1, stderr diagnostic) on the
  first error. Replaces `mysql < schema.sql`. The conservative
  parser is the load-bearing piece — pre-fix the wizard's
  `page.5.php` uses `explode(';', $sql)` against `data.sql`
  which happens to work today because no value-side `;` exists in
  the shipped seeds, but the production entrypoint runs against
  operator-mounted schema deltas where that's not safe to assume.

Smoke-tested every subcommand against the dev MariaDB
(`docker exec sbpp-1381-web php /tmp/sb-db.php ...`):
ping=0 with right creds, ping=1 with wrong host, has-version-row=0
on the seeded `sb_` prefix, has-version-row=1 on a non-existent
prefix, exec=0 on a heredoc carrying `;` inside string literals and
each of the three comment shapes.

shellcheck clean (the two `SC2016` disables on `php -r 'echo
urldecode(...)'` lines are intentional — single quotes there are
PHP source, not shell expansion).
…HIGH-3)

Pair with the previous entrypoint commit that moved `mysqladmin ping`
+ `mysql < schema.sql` to the bundled `docker/php/sb-db.php` PDO
helper. The runtime apt line shrinks by `default-mysql-client`
(~22 MB) — closing the spec violation the #1381 reviewer flagged
("the spec explicitly says NO `default-mysql-client`; worker shipped
it anyway").

Also stage `sb-db.php` to `/usr/local/lib/sbpp/sb-db.php` so it
stays reachable after the entrypoint's step-7
`rm -rf install/+updater/` pass — keeping it under
`/var/www/html/web/` would have made it disappear mid-boot on every
restart.

hadolint clean.
…es (#1381 CRIT-3 + MED-4)

* CRIT-3 — `/health.php` previously echoed
  `FAIL: $e->getMessage()` to any unauth caller. PDO carries
  SQLSTATE plus DB host plus internal IP plus username plus auth
  mechanism in its exception text (`SQLSTATE[HY000] [1045] Access
  denied for user 'sourcebans'@'10.0.5.13'`) — a reconnaissance
  gift the probe handed out on every misconfigured boot. Body is
  now just `FAIL\n` with the full detail going to `error_log` for
  the operator. Status code unchanged at 503; the contract for
  Docker HEALTHCHECK / k8s liveness / app-platform routers is
  preserved.

* MED-4 — `/health.php` now `define`s `SBPP_SKIP_TELEMETRY`
  BEFORE requiring `init.php`. The shutdown function
  `init.php` registers (`Telemetry::tickIfDue`) early-returns
  when the constant is set, so the orchestrator's ~30s probe
  doesn't cost a `:prefix_settings.last_ping` `UPDATE` per call
  (~2880/day of pure noise). The rate-limit guard inside the
  tick would already prevent a real cURL POST per probe, but
  the slot-reservation `UPDATE` is the actual wasted write.

Constraint: `web/health.php` is now 49 lines (was 78) and still
well under the <50-line review-ability cap.

Regression guard: new `TelemetryOptOutTest::testTickIfDueShortCircuitsWhenSbppSkipTelemetryDefined`
verifies the constant blocks both the slot reservation AND the
instance-ID mint, in a `#[RunInSeparateProcess]` so the rest of
the suite doesn't inherit the opt-out. All 4 tests in the file
pass (3 pre-existing + the new one).
…T-4)

Pre-fix `\Sbpp\Auth\Host::isSecure()` returned `true` for any
request that arrived with `X-Forwarded-Proto: https`, regardless
of where the request actually came from. An attacker hitting the
panel directly on HTTP could spoof the header and trick the panel
into:

* setting `Secure` on every cookie it issued (which the browser
  would then refuse to send back over the attacker's plain-HTTP
  channel — silent session breakage);
* emitting `https://…` redirects from `Host::protocol()`;
* lying to every `Host::complete()` caller about the actual
  request scheme.

The fix is a two-arm resolution order:

1. `$_SERVER['HTTPS'] === 'on'` — authoritative. On the production
   Docker image Apache's `mod_remoteip` plus the paired
   `SetEnvIfExpr` mirror a trusted upstream's
   `X-Forwarded-Proto: https` into `HTTPS=on`, keeping the
   trust decision in Apache (the right layer).

2. `$_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https'` ONLY if
   `$_SERVER['REMOTE_ADDR']` matches the operator-defined
   `SBPP_TRUSTED_PROXIES` constant. This is the fallback for
   non-Docker deployments where the operator never wired up
   `mod_remoteip` and pins the trust list in `config.php`
   instead.

`SBPP_TRUSTED_PROXIES` lives in `init.php`: a `config.php`
`define()` wins, otherwise the `SBPP_TRUSTED_PROXIES` env var
(which the prod entrypoint already exports), otherwise empty
(the secure default — XFP ignored entirely). The list is
whitespace-separated and accepts IPv4 + IPv6 literals AND CIDR
ranges; the new `Host::isTrustedProxy()` / `ipMatchesRange()`
helpers parse it via `inet_pton` so both address families share
one code path. Family mismatch (IPv4 IP against IPv6 CIDR or
vice versa) is rejected explicitly so an attacker can't
construct an IPv4 binary that prefix-matches an IPv6 CIDR.

The dev container's behaviour is preserved: the dev compose
stack doesn't define `SBPP_TRUSTED_PROXIES`, so the env var is
absent, the trust list is empty, the XFP fallback never fires,
and `isSecure()` returns `false` on every plain-HTTP request
(unchanged from before).

Regression guard: 13 new test cases in
`web/tests/unit/Auth/HostIsSecureTest.php` covering every arm —
the four primary scenarios called out by the reviewer
(`HTTPS=on` → true; no headers → false; XFP from untrusted IP
→ false; XFP from trusted IP → true) plus IPv6 CIDR + literal
+ family-mismatch + whitespace-only-trust-list + empty-trust-list
cases. Each case runs in `#[RunInSeparateProcess]` so the
process-global `SBPP_TRUSTED_PROXIES` constant doesn't leak
between scenarios.

PHPStan + PHPUnit (the 13 new + the existing suite) clean.
…HIGH-1 + HIGH-2)

* HIGH-1 — `web/index.php` no longer carries the redundant
  `include_once('config.php')` from line 8. `init.php` already
  loaded config via `sbpp_resolve_config_path()` (the helper that
  honours `SBPP_CONFIG_PATH` for Docker-secret mounts); the
  second literal include either fails outright (no `config.php`
  in `web/`) or shadows the secret-mounted values with whatever
  stale on-disk `web/config.php` happened to exist from a prior
  install.

  Removes one entry from `web/phpstan-baseline.neon`
  (`includeOnce.fileNotFound` against `index.php` line 8) — the
  baseline entry was load-bearing because PHPStan couldn't see
  through the `getenv()` fork in `sbpp_resolve_config_path` and
  flagged the literal `config.php` as "may not exist". With the
  literal gone, the rule fires zero times and the baseline shed
  one entry.

* HIGH-2 — `web/install/pages/page.5.php`'s
  `sbpp_install_render_config()` call site now resolves the
  config-write path via `sbpp_resolve_config_path(PANEL_ROOT .
  'config.php')` instead of hardcoding `PANEL_ROOT . 'config.php'`.
  An operator running the wizard manually (typical
  `SBPP_AUTO_INSTALL=0` shape: prod Docker image with a
  Docker-secret-mounted config) gets the wizard's "config.php
  written successfully" green light AND the file actually lands
  at the path the panel runtime will read at boot. Pre-fix it
  landed in the writable layer that gets blown away on every
  `docker run --rm` recreation.

  Wired up by sourcing `web/init-recovery.php` from
  `web/install/bootstrap.php` — the helper has zero `Sbpp\…`
  dependencies so it's safe to pull into the wizard's
  no-Composer-chrome contract.

PHPStan + InstallGuardTest (8 tests, 28 assertions) clean. The
existing `testWizardGuardHonorsConfigPathEnvVar` test already
pins the matching guard on `web/install/already-installed.php`,
so the surfaces stay symmetric.
…EXPERIMENTAL (#1381 MED-6 + HIGH-4 + NIT-1)

* MED-6 — `push:` previously specified `branches`, `tags`, AND
  `paths` at the same level, which GitHub Actions AND-combines.
  A release-tag push pointing at a commit that didn't itself
  touch the prod-image surface (the common case — release tags
  are typically attached to commits that already shipped through
  `main` and the image was rebuilt there) would silently skip
  the workflow and `:latest` would lag behind `:main` indefinitely.

  The fix drops the `paths` filter from `push:` so every push
  to `main` AND every release-tag push triggers a full
  multi-arch build. Action minutes for tag pushes are
  unconditional (which is the right contract for a release
  surface), and main pushes are rare enough (PRs aggregate via
  squash merges) that the per-merge cost is acceptable. The
  PR trigger keeps its `paths` filter so the per-PR
  action-minute optimisation that originally motivated the
  whole filter is preserved on the trigger that actually runs
  on every PR commit.

* HIGH-4 — the `pull_request:` `paths:` filter was missing the
  panel-side files the prod image bakes in:

  - `web/health.php` — the file Docker HEALTHCHECK pings.
  - `web/init.php` / `web/init-recovery.php` — the
    `SBPP_CONFIG_PATH` plumbing the entrypoint relies on.
  - `web/install/includes/sql/**` — the schema + seed data the
    entrypoint loads on first-boot install.
  - `web/includes/Auth/Host.php` — the new
    `SBPP_TRUSTED_PROXIES` gate (#1381 CRIT-4).
  - `web/includes/Telemetry/Telemetry.php` — the
    `SBPP_SKIP_TELEMETRY` hook health.php depends on (MED-4).
  - `web/composer.json` / `web/composer.lock` — vendor churn.
  - `docker/php/sb-db.php` — the new PDO helper (HIGH-3) the
    entrypoint shells out to.

  Adding these closes the gap where a PR could ship an
  entrypoint-incompatible change to a panel-side file
  (e.g. a refactor of `Telemetry::tickIfDue` that drops the
  `SBPP_SKIP_TELEMETRY` early-return) and the prod-image CI
  would happily report green because it never ran.

* NIT-1 — `COSIGN_EXPERIMENTAL=1` is the legacy gate for
  cosign 1.x keyless signing. Cosign 2.0+ promoted keyless to
  the default, and 2.4.x (the version pinned by
  `cosign-installer@v3` above) silently ignores the env var.
  Carrying it implied "there's still an experimental flag in
  play here" — drop it; replace with an inline comment so a
  future reader doesn't add it back.

actionlint clean.
… LOW-3)

`PORT` and `SBPP_HOST_PORT` look interchangeable at a glance but
do opposite things:

* `PORT` rewrites Apache's `Listen` directive INSIDE the
  container. Used by app-platform deploys (Render / Fly / Heroku)
  that inject the env var and expect the app to bind to it.
* `SBPP_HOST_PORT` is the HOST-side port `docker-compose.prod.yml`
  maps to the container's hardcoded port 80.

Setting `PORT=8000` on a compose deploy would rewrite the
container's listen to 8000 but compose would still map the host
port to the container's port 80 (which nothing is listening on),
making the panel unreachable — exactly the LOW-3 trap the #1381
reviewer flagged.

* `.env.example.prod` grew a commented-out `PORT=` entry with an
  inline warning so an operator who reads top-to-bottom sees the
  trap before they reach for the knob.
* `quickstart-docker.mdx` grew a `:::caution` admonition next to
  the `PORT` mention in the app-platform "one-click deploy"
  section, explaining the asymmetry between the two surfaces.

No-op behavioural change; docs / .env clarity only.
@rumblefrog
rumblefrog added this pull request to the merge queue May 16, 2026
Merged via the queue into main with commit ba3c887 May 16, 2026
7 checks passed
@rumblefrog
rumblefrog deleted the feat/prod-docker-image branch May 16, 2026 02:22
@github-actions github-actions Bot locked and limited conversation to collaborators May 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Production Docker image + production docker-compose for self-hosters (and one-click-deploy gateway)

1 participant