Docker Deployment
The supported self-hosted deployment uses the base Compose file together with the production overlay. Always pass both files, in this order:
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
<command>
The overlay extends the local infrastructure model; it is not a standalone Compose file.
Production Topology
| Service | Container port | Role |
|---|---|---|
nginx | 80, 443 | Public TLS entry point and hostname routing |
web | 8000 | Next.js application, tRPC API, and background outbox worker |
ws | 8003 | WebSocket collaboration service |
docusaurus | 3001 | Generated documentation site |
postgres | 5432 | PostgreSQL 16 database |
redis | 6379 | Cache and pub/sub |
minio | 9000, 9001 | S3-compatible object storage and console |
clamav | 3310 | Fail-closed attachment scanning |
minio-init | one shot | Private bucket and least-privilege application user bootstrap |
ownership | one shot | Existing-schema ownership adoption |
schema | one shot | Drizzle schema, reviewed SQL, and RLS verification |
Only Nginx publishes public ports. The database, Redis, MinIO, and ClamAV host
bindings in the production overlay are loopback-only. There is no separate API
container: the tRPC and HTTP routes run inside web.
MVP Support Boundary
This production profile supports the tenant-aware authenticated product core,
public forms, realtime board collaboration, and attachments through the exact
tenant-aware tRPC boundary. Nginx returns 404 for cron handlers, GraphQL,
REST v1, MCP, direct PDF exports, API documentation, enterprise SSO/SAML,
MQTT ingest, and the ADO/Jira/MES inbound webhooks. The separately
signed Stripe webhook remains enabled. Authentik and Azure AD are hard-disabled
in the production build and runtime. Public board/embed/breakout/link sharing,
external approval links, and global template galleries remain outside the MVP
support contract. Do not bypass this boundary by giving web an admin database
connection.
Prerequisites
- Docker 24+ with Docker Compose v2
- At least 4 CPU cores, 8 GB RAM, and persistent storage for production
- DNS for the application, WebSocket, docs, and file-storage hostnames
- TLS certificates at the paths listed in
docker/README.md - A tested PostgreSQL backup and restore procedure
Configure the Environment
Copy the checked-in template and replace every placeholder:
cp .env.example .env
At minimum, configure the three distinct database credentials, Redis password, distinct MinIO root and application credentials, authentication secrets, Resend, and the public application/WebSocket/file URLs. Passwords embedded in connection strings must be URL-encoded.
POSTGRES_DB=probeya
POSTGRES_USER=probeya
POSTGRES_PASSWORD=<random-superuser-password>
PROBEYA_APP_DB_PASSWORD=<random-application-password>
PROBEYA_ADMIN_DB_PASSWORD=<random-schema-owner-password>
DATABASE_URL_DOCKER=postgresql://probeya_app:<encoded-application-password>@postgres:5432/probeya
DATABASE_ADMIN_URL_DOCKER=postgresql://probeya_admin:<encoded-schema-owner-password>@postgres:5432/probeya
REDIS_PASSWORD=<random-redis-password>
REDIS_URL_DOCKER=redis://:<encoded-redis-password>@redis:6379
MINIO_ROOT_USER=<random-root-access-key>
MINIO_ROOT_PASSWORD=<random-root-secret-key>
S3_ACCESS_KEY=<different-application-access-key>
S3_SECRET_KEY=<different-application-secret-key>
S3_ENDPOINT=http://minio:9000
S3_PUBLIC_URL=https://files.example.com
S3_BUCKET=probeya-uploads
AUTH_SECRET=<at-least-32-random-bytes>
WS_AUTH_SECRET=<different-random-secret>
CRON_SECRET=<different-random-secret>
RESEND_API_KEY=<production-resend-key>
EMAIL_FROM=ProBeya <[email protected]>
STRIPE_SECRET_KEY=<production-secret-key>
STRIPE_WEBHOOK_SECRET=<endpoint-signing-secret>
STRIPE_PRICE_STARTER=price_<starter-monthly-id>
STRIPE_PRICE_PRO=price_<pro-monthly-id>
NEXT_PUBLIC_APP_URL=https://probeya.example.com
NEXT_PUBLIC_WS_URL=wss://ws.example.com
DATABASE_URL_DOCKER is the RLS-enforced runtime connection. Only the one-shot
schema service receives DATABASE_ADMIN_URL_DOCKER. Never give the admin URL
to web or ws.
S3_ENDPOINT is reachable from containers. S3_PUBLIC_URL must be reachable
from user browsers. Attachments remain private; only explicit avatar and logo
prefixes receive anonymous read access.
Validate interpolation before changing a running deployment:
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
config --quiet
First Deployment and Upgrades
Database changes are intentionally operator-run. The application image is a
minimal Next.js standalone image and does not contain the database workspace or
CLI. Do not run schema commands inside web.
Use this order for a new V2 installation and every post-baseline V2 upgrade
that changes packages/db.
The one-time Phase 143 cutover does not support an in-place MVP-v0 upgrade. Retain any required v0 backup, provision an empty PostgreSQL 16 database or volume, and run the V2 sequence against that target. The migrator rejects a non-empty schema without the immutable V2 ledger.
-
Stop application writes. On an upgrade, stop the two writers while keeping PostgreSQL available:
docker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \stop web ws -
Create a restorable backup before changing ownership or schema:
docker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \exec -T postgres sh -ec \'pg_dump --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" --format=custom' \> probeya-before-upgrade.dumpOn a brand-new installation, start PostgreSQL first; the backup is optional only while the database is still empty.
-
Adopt ownership and reconcile the two least-privilege database roles:
docker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \--profile ownership run --rm ownershipThis operation is transactional, fail-closed, and safe to replay on a new or existing PostgreSQL 16 database.
-
Apply and verify the schema:
docker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \--profile operations run --build --rm schemaThe schema image runs
db:migrateasprobeya_admin, thenapply-rlsand the fail-closedcheck-rlscatalog audit. The migrator verifies immutable journal, SQL, and snapshot hashes, requires the database ledger to be an exact prefix, serializes operators with an advisory lock, and commits all pending changes in one transaction.--buildprevents a cached image from the previous release applying stale database code. -
Build and start the application stack:
docker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \build web ws docusaurusdocker compose \-f docker/docker-compose.yml \-f docker/docker-compose.prod.yml \up -d
The minio-init service must complete before Nginx and web start. It keeps
the bucket private, creates the application user, and can be replayed during
credential rotation.
Verification
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
ps
curl --fail https://probeya.example.com/api/health
curl --fail https://probeya.example.com/api/ready
curl --fail https://ws.example.com/health
curl --fail https://docs.example.com/healthz
/api/health is the web process liveness probe. /api/ready verifies both
PostgreSQL and Redis and is used by the container health check.
Run the release gate before promotion:
pnpm mvp:gate
Logs and Recovery
# All services
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
logs -f
# Application writers only
docker compose \
-f docker/docker-compose.yml \
-f docker/docker-compose.prod.yml \
logs -f web ws
If ownership or schema fails, keep web and ws stopped, inspect the
failure, and either correct it and replay the failed one-shot operation or
restore the backup. Never bypass a failed RLS verification to bring the
application online.
For the canonical credential, certificate, object-storage, and validation
details, see docker/README.md.