Docker Compose
The production stack pulls a published, signed image rather than building from source. That is the point of it: an update becomes a digest change and a rollback becomes the previous digest.
One stack is one customer and one database. The same file runs managed deployments and self-hosted ones.
cp .env.prod.example .envchmod 600 .env# edit .env, then:docker compose --env-file .env -f docker-compose.prod.yml up -dGenerate every secret fresh, per deployment. Never copy one from another install, from these docs, or from a demo.
openssl rand -base64 32What the stack runs
Section titled “What the stack runs”| Service | What it is |
|---|---|
postgres | PostgreSQL 16, on the internal network only, with no published port |
migrate | Runs to completion before the app starts. Forward-only and idempotent |
api | The API process |
web | The web process |
worker | Background jobs |
caddy | Optional ingress, TLS from Let’s Encrypt |
tunnel | Optional ingress, Cloudflare Tunnel |
Nothing publishes a port unless you attach an ingress profile. The database is never reachable from the LAN.
Configuration
Section titled “Configuration”The important part of .env:
# What to run. Pin a digest in production.PLUGBOARD_IMAGE=ghcr.io/samiossoftware/plugboardPLUGBOARD_VERSION=0.2.0
# stable or betaRELEASE_CHANNEL=stable
# Your address, as users type it.PUBLIC_URL=https://helpdesk.yourschool.orgPUBLIC_HOST=helpdesk.yourschool.org
# Only for the caddy profile: where Let's Encrypt sends expiry warnings.
# Only for the tunnel profile.# TUNNEL_TOKEN=
# Secrets. Generate each one.POSTGRES_PASSWORD=JWT_SECRET=SECRETS_MASTER_KEY=
# Issued to you.LICENSE_KEY=
# Email, for notifications, invitations and password resets.SMTP_HOST=SMTP_PORT=587SMTP_USER=SMTP_PASSWORD=SECRETS_MASTER_KEY encrypts connector credentials and backups. Back it up
somewhere that is not the server it protects.
Full list: environment variables.
Running more than one stack on a host
Section titled “Running more than one stack on a host”ENV_FILE parameterises which file the containers read, so a staging stack or a
second customer can run from its own:
ENV_FILE=.env.staging docker compose --env-file .env.staging \ -f docker-compose.prod.yml -p plugboard-staging up -dIngress, pick one
Section titled “Ingress, pick one”Caddy, with Let’s Encrypt
Section titled “Caddy, with Let’s Encrypt”For a school pointing its own domain at its own server, with ports 80 and 443 reachable from the internet.
docker compose --env-file .env -f docker-compose.prod.yml --profile caddy up -dCaddy obtains and renews the certificate itself, redirects HTTP to HTTPS and sends HSTS. Nothing else to do.
Cloudflare Tunnel
Section titled “Cloudflare Tunnel”No inbound ports at all, which is often the only option on a school network you do not control.
docker compose --env-file .env -f docker-compose.prod.yml --profile tunnel up -dSet TUNNEL_TOKEN first. The tunnel dials out to Cloudflare and traffic arrives
through it, so no firewall change is needed.
Your own reverse proxy
Section titled “Your own reverse proxy”Start with no profile. The stack listens on the internal network only. Proxy
/api/* to api:4000 and everything else to web:3000.
One setting matters: your proxy must pass /api/events/ through without
buffering, or the desk’s live updates arrive in bursts every thirty seconds
instead of immediately. See HTTPS and
certificates.
Your own certificate with Caddy
Section titled “Your own certificate with Caddy”Replace the tls line in deploy/Caddyfile.prod and mount the directory into
the caddy service:
tls /certs/fullchain.pem /certs/privkey.pemFor an internal ACME CA such as step-ca or AD CS with an ACME endpoint:
tls { ca https://step-ca.internal/acme/acme/directory}Updating
Section titled “Updating”One script does the whole sequence, and it is the same script we run on managed deployments.
./scripts/plugboard-update.sh 0.2.0It refuses to start from an unhealthy stack, takes and integrity-checks a
database dump, pulls by digest where possible, verifies the cosign signature if
cosign is installed, migrates, swaps the image, waits for /health/ready and for
the instance to report the version you asked for, and rolls back the image
automatically on any failure after the migration step.
Rehearse without changing anything:
./scripts/plugboard-update.sh 0.2.0 --dry-runFull detail, including digest pinning and signature verification, is in updating.
Backups
Section titled “Backups”The stack mounts ./backups into the postgres container, and the update script
writes its pre-update dump there. That is a safety net for updates, not a backup
regime.
For scheduled encrypted backups with retention, use Admin, Backups inside the product. For getting them off the machine, see backups and restore.
Operational commands
Section titled “Operational commands”# What is runningdocker compose -f docker-compose.prod.yml ps
# Logs, followingdocker compose -f docker-compose.prod.yml logs -f api
# A database shelldocker compose -f docker-compose.prod.yml exec postgres psql -U plugboard
# A manual dumpdocker compose -f docker-compose.prod.yml exec postgres \ pg_dump -U plugboard -Fc plugboard > backups/manual-$(date +%F).dump
# Stop everything, keeping datadocker compose -f docker-compose.prod.yml down
# Stop and delete the database volume. Destructive.docker compose -f docker-compose.prod.yml down -vChecking what you are about to run
Section titled “Checking what you are about to run”Every release image is signed with cosign, keyless, and carries an SPDX SBOM attestation.
cosign verify ghcr.io/samiossoftware/plugboard@sha256:1a2b3c... \ --certificate-identity-regexp '^https://github.com/samiossoftware/plugboard/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.comcosign download attestation ghcr.io/samiossoftware/plugboard@sha256:1a2b3c...