Skip to content

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.

Terminal window
cp .env.prod.example .env
chmod 600 .env
# edit .env, then:
docker compose --env-file .env -f docker-compose.prod.yml up -d

Generate every secret fresh, per deployment. Never copy one from another install, from these docs, or from a demo.

Terminal window
openssl rand -base64 32
ServiceWhat it is
postgresPostgreSQL 16, on the internal network only, with no published port
migrateRuns to completion before the app starts. Forward-only and idempotent
apiThe API process
webThe web process
workerBackground jobs
caddyOptional ingress, TLS from Let’s Encrypt
tunnelOptional ingress, Cloudflare Tunnel

Nothing publishes a port unless you attach an ingress profile. The database is never reachable from the LAN.

The important part of .env:

Terminal window
# What to run. Pin a digest in production.
PLUGBOARD_IMAGE=ghcr.io/samiossoftware/plugboard
PLUGBOARD_VERSION=0.2.0
# stable or beta
RELEASE_CHANNEL=stable
# Your address, as users type it.
PUBLIC_URL=https://helpdesk.yourschool.org
PUBLIC_HOST=helpdesk.yourschool.org
# Only for the caddy profile: where Let's Encrypt sends expiry warnings.
ACME_EMAIL=ict@yourschool.org
# 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=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM="ICT Service Desk <helpdesk@yourschool.org>"

SECRETS_MASTER_KEY encrypts connector credentials and backups. Back it up somewhere that is not the server it protects.

Full list: environment variables.

ENV_FILE parameterises which file the containers read, so a staging stack or a second customer can run from its own:

Terminal window
ENV_FILE=.env.staging docker compose --env-file .env.staging \
-f docker-compose.prod.yml -p plugboard-staging up -d

For a school pointing its own domain at its own server, with ports 80 and 443 reachable from the internet.

Terminal window
docker compose --env-file .env -f docker-compose.prod.yml --profile caddy up -d

Caddy obtains and renews the certificate itself, redirects HTTP to HTTPS and sends HSTS. Nothing else to do.

No inbound ports at all, which is often the only option on a school network you do not control.

Terminal window
docker compose --env-file .env -f docker-compose.prod.yml --profile tunnel up -d

Set TUNNEL_TOKEN first. The tunnel dials out to Cloudflare and traffic arrives through it, so no firewall change is needed.

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.

Replace the tls line in deploy/Caddyfile.prod and mount the directory into the caddy service:

tls /certs/fullchain.pem /certs/privkey.pem

For an internal ACME CA such as step-ca or AD CS with an ACME endpoint:

tls {
ca https://step-ca.internal/acme/acme/directory
}

One script does the whole sequence, and it is the same script we run on managed deployments.

Terminal window
./scripts/plugboard-update.sh 0.2.0

It 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:

Terminal window
./scripts/plugboard-update.sh 0.2.0 --dry-run

Full detail, including digest pinning and signature verification, is in updating.

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.

Terminal window
# What is running
docker compose -f docker-compose.prod.yml ps
# Logs, following
docker compose -f docker-compose.prod.yml logs -f api
# A database shell
docker compose -f docker-compose.prod.yml exec postgres psql -U plugboard
# A manual dump
docker compose -f docker-compose.prod.yml exec postgres \
pg_dump -U plugboard -Fc plugboard > backups/manual-$(date +%F).dump
# Stop everything, keeping data
docker compose -f docker-compose.prod.yml down
# Stop and delete the database volume. Destructive.
docker compose -f docker-compose.prod.yml down -v

Every release image is signed with cosign, keyless, and carries an SPDX SBOM attestation.

Terminal window
cosign verify ghcr.io/samiossoftware/plugboard@sha256:1a2b3c... \
--certificate-identity-regexp '^https://github.com/samiossoftware/plugboard/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Terminal window
cosign download attestation ghcr.io/samiossoftware/plugboard@sha256:1a2b3c...