Skip to main content

Docker Compose

All backend services are containerized, built from one shared infrastructure/docker/Dockerfile with a target per service (it needs the whole workspace, the lockfile and packages/, not just one directory). Docker Compose is used for both dev and production; Kubernetes is a documented later option, not introduced before it's needed.

Services​

cloudflared Cloudflare Tunnel (--profile public, optional)
nginx Internal gateway / reverse proxy
web Web client (static bundle)
admin-web Admin panel (static bundle)
migrate Runs Prisma migrations once, then exits
auth-service JWT sessions, OAuth, admin auth
server-service Servers, channels, roles, invites
chat-service Messages, DMs, friends, E2EE keys, uploads
presence-service Online status, typing, voice roster (stateless, Redis-backed)
notification-service Mutes, quiet hours, unread, push devices
call-service Call signalling only
remote-gateway Remote-desktop signalling, permissions, audit
postgres Primary database
redis Presence, pub/sub, rate limiting, temp sessions
db-backup / db-backup-once pg_dump on a schedule / on demand

Networks​

Private Docker networks, so only what needs to talk to a thing can reach it:

Postgres and Redis are never on cloudflare-network and are never published on a host port in production. There is no media network — no container carries media, ever (see Peer-to-Peer Media).

Running it​

cp .env.example .env
docker compose --env-file .env -f infrastructure/docker/docker-compose.yml pull
docker compose --env-file .env -f infrastructure/docker/docker-compose.yml up -d

Or via the root package.json scripts, which pass --env-file .env for you (compose reads .env relative to the compose file's own directory, infrastructure/docker/, not the repo root):

pnpm prod:up # pull + up -d
pnpm prod:up:build # build locally instead of pulling
pnpm prod:down
pnpm db:backup # one-off pg_dump via db-backup-once

deploy.sh​

infrastructure/docker/deploy.sh is the same two commands with the parts that matter when they go wrong:

sh infrastructure/docker/deploy.sh # the channel .env follows
sh infrastructure/docker/deploy.sh 0.0.2 # that version exactly
sh infrastructure/docker/deploy.sh alpha # the newest alpha

It pulls before it stops anything, so a failed pull is a deploy that did not happen rather than an outage. It writes BETWEENUS_VERSION into .env, so a later docker compose up -d by hand gets the same version. And if the gateway does not come back healthy it restores the version that was running and exits non-zero — which is the whole difference between a deploy step and two commands in a README.

It reloads the gateway on every deploy. infrastructure/nginx/nginx.conf is a bind mount rather than part of the nginx image, so compose sees nothing to recreate when only that file changed and the container keeps serving the config it read at its last start. A route added this release would then have no location of its own, fall through to the catch-all that proxies the web container, and answer a JSON call with the SPA's index.html — a 200 the client can only report as a broken response. A config that fails nginx -t fails the deploy instead of being reloaded into the running gateway.

Asked for the version that is already running — which is what a !patch deploy is — it adds --force-recreate, so every container comes down and comes back on the image just pulled rather than on compose's judgement about whether anything changed. DEPLOY_RECREATE=1 asks for that on any deploy.

On success it prunes images older than a week — nine tagged images a release is what fills a small disk — and it prunes nothing else. If you are used to clearing space with docker system prune -a --volumes, don't run it here: it removes backup-data as soon as the container that wrote the pre-migration dump has been cleaned up, and that dump is the answer to a migration that went wrong.

If the rollback also fails it says so loudly. That usually means the migration ran and the previous images cannot read the new schema; the pre-migration dump in the backup volume is what that case is for, and restoring it is a person's decision, not the script's.

The same file ships in the deployment bundle attached to every release, and the release pipeline's deploy job runs it over SSH.

Backups off the host​

The dumps land beside the database they came from, which covers a bad migration and not a dead disk. Set BACKUP_S3_BUCKET and every dump is also PUT to object storage as it is written:

VariableMeaning
BACKUP_S3_ENDPOINTPath-style endpoint — MinIO, R2, B2, Spaces, Wasabi, S3
BACKUP_S3_BUCKETOff when empty. Nothing else here is read without it
BACKUP_S3_PREFIXKey prefix, default betweenus
BACKUP_S3_REGIONSigning region, default us-east-1
BACKUP_S3_ACCESS_KEY / BACKUP_S3_SECRET_KEYCredentials
BACKUP_OFFSITE_REQUIRED1 makes a failed upload a failed backup, which before a migration stops the migration. Default 0

Retention is local only. Nothing prunes the bucket — set a lifecycle rule there, which every S3 implementation has.

Secrets past .env​

.env is one file holding every secret, and its contents are in docker inspect and in the environment of every process a service spawns. Any variable can instead be given as NAME_FILE, the path of a file holding the value — the shape Docker and Podman secrets, Kubernetes projected volumes and systemd credentials all produce. NAME wins when both are set.

docker-compose.secrets.yml is a ready override that mounts a directory of them at /run/secrets:

docker compose --env-file .env \
-f infrastructure/docker/docker-compose.yml \
-f infrastructure/docker/docker-compose.secrets.yml up -d

Rotating a signing secret used to sign out every account on every device, which meant it never happened. JWT_SECRET_PREVIOUS is accepted on verification and never used to sign, so a rotation is: move the old value across, put the new one in place, restart, and delete the old one once nothing can still be carrying it — one access-token lifetime for JWT_SECRET, the refresh lifetime for JWT_REFRESH_SECRET. Deleting it is what ends the rotation. SETTINGS_SECRET_PREVIOUS does the same for the sealed OAuth client secrets in the database.

Images​

Published to ${IMAGE_REPO:-aiyuayaan/betweenus} on Docker Hub, one tag per service per version, built for both linux/amd64 and linux/arm64 under a single manifest list — a Pi, an Ampere VPS or an Apple-silicon Mac pulls the same tag and gets its own architecture. Moving tags:

TagFollows
<service>-<version>One exact release, never moves
<service>-alpha / <service>-betaThe newest release on that channel
<service>-latestThe newest release of any channel

A deployment's .env sets BETWEENUS_VERSION to pin an exact version; unset, it follows latest.

Public ingress​

Two ways, one line of difference — see Ingress:

  • Bring your own cloudflared (already running one tunnel for everything else): add an ingress entry pointing at http://localhost:8080, the gateway's published GATEWAY_PORT.
  • Let BetweenUs run its own: docker compose ... --profile public up -d starts the cloudflared service, gated on the gateway's own healthcheck rather than merely existing.