Profile A — single VPS (the default)
docker compose up -d: app + Postgres, local file storage on the app_data
volume, in-memory rate limits, Caddy in front for TLS
(reverse-proxy.md). Serves teams to small companies; a
bigger machine buys roughly 10×. This is the shape the install scripts and
backup scripts assume.
Profile B — horizontal / cloud (reference)
The same image, N autoscaling replicas (e.g. Cloud Run), with the state moved to managed services:
Profile B requires shared storage.
STORAGE_DRIVER=local is
single-replica only: each replica has a private disk, so a blob uploaded
through one replica does not exist on another. A replica that cannot reach a
blob answers a clean 404 not_found on the content route — that is a safety
net against silent corruption, not a supported mode of operation. Any
multi-replica deployment must set STORAGE_DRIVER=s3 (S3, MinIO, R2).
Requirements that make B safe are already the template’s invariants:
AUTO_MIGRATE under an advisory lock means one replica migrates while
others wait; pg-boss’s own install DDL (schema + queue partitions) is
serialized the same way under a second advisory lock (key 7432004,
jobs/pgboss.ts), so concurrent fresh-DB boots of all/worker replicas
do not deadlock; graceful drain means rolling deploys drop nothing (proven:
a SIGTERM mid-download lets the transfer finish); SERVICE_ROLE=api runs no
job supervision (note: it still applies the app migrations unless you set
AUTO_MIGRATE=false on the api pool — only the pg-boss DDL is skipped).
These claims are not just documented — they are exercised as a real
multi-replica stack by the scale drill (scripts/scale-drill.sh, run in CI
on every push): the migration-lock race, cross-replica sessions and API
keys, Redis-shared rate limits (with the in-memory contrast), and the
api/worker split. See scaling.md, including its findings on
first-boot ordering (a fresh database needs one all/worker boot before
api-only replicas can start).
Connection pooling: the app’s pg.Pool ships conservative defaults (max 10
connections per replica, statement_timeout 30 s,
idle_in_transaction_session_timeout 30 s), so N replicas hold at most
10·N connections; size Postgres max_connections or the pooler
accordingly. Behind PgBouncer in transaction mode the two timeouts do
not stick: they are session-level settings, and transaction pooling hands
each transaction a different server session. Set them server-side instead
(ALTER DATABASE platform SET statement_timeout = '30s', same for
idle_in_transaction_session_timeout) or run the pooler in session mode.
Kubernetes is an ops preference some enterprises impose, never a scale
requirement — the image runs unchanged; a Helm chart is a later add-on.