Deploy on Docker Swarm
Overview
The Swarm install is driven by a guided installer run from a control workstation, the machine the administrator works from, which is not part of the cluster. The installer connects to the target hosts over SSH (Secure Shell, an encrypted remote-administration protocol) and does everything remotely. No Docker is required on the control workstation, since the container images are built on a target host rather than locally.
A single declarative manifest (infra/cluster.json) describes the target: its mode, hosts, roles and tenants. A generator (infra/cluster/generate.mjs) turns that manifest into ready-to-run deployment artifacts. From there:
- Single host runs everything on one machine in a single-node Swarm. PostgreSQL runs as a container, the same orchestrator script (
out/deploy-ssh.sh) performs the bring-up over SSH, and the result is well suited to evaluation and small sites. - High availability (HA) spreads across several hosts with a replicated database and no single point of failure. One orchestrator script (
out/deploy-ssh.sh) performs the entire bring-up over SSH.
The running system is the same in both cases: stateless application services (web, api, worker, and a shared controlplane front door) backed by three durable stores: PostgreSQL, Redis, and an on-premise S3 (Simple Storage Service) object store. A Traefik reverse proxy terminates TLS and exposes only ports 80 and 443.
Glossary
Acronyms used throughout this guide, defined once here.
| Term | Meaning |
|---|---|
| SSH | Secure Shell: encrypted protocol used to administer the hosts and to drive the installer. |
| HA | High Availability: a topology that survives the loss of a single host without an outage. |
| DNS | Domain Name System: maps host names to network addresses. |
| TLS | Transport Layer Security: the encryption layer behind HTTPS and used between internal components. |
| VIP | Virtual IP: a single floating network address shared by several hosts for failover (provided here by keepalived). |
| S3 | Simple Storage Service: an object-storage interface that became an industry standard. The instance hosts its own S3 store (Garage); no cloud account is involved. |
| etcd | A distributed key-value store used here for cluster coordination. It elects the PostgreSQL leader and holds the database cluster state. |
| DCS | Distributed Configuration Store: the generic term for the coordination store (etcd) that the database HA manager relies on. |
| Quorum | The majority of coordination nodes that must agree for the cluster to make decisions, which is why etcd needs an odd number ≥ 3. |
| LUKS | Linux Unified Key Setup: the Linux standard for full-volume disk encryption (used here in its LUKS2 form for at-rest data encryption). |
| KEK | Key Encryption Key: a master key that wraps (encrypts) data keys but never touches the data itself. |
| DEK | Data Encryption Key: the key that actually encrypts sensitive fields; one per tenant, stored only in wrapped form. |
| Tenant | One isolated customer organization served by the instance. Each tenant has its own database. |
| Patroni | The high-availability manager for PostgreSQL: it runs one elected leader plus streaming replicas and performs automatic failover. |
| JWT | JSON Web Token: a signed session token issued at sign-in. |
| SMTP | Simple Mail Transfer Protocol: protocol for sending the optional email notifications. |
Prerequisites & initial state
The lists below describe the expected starting point before any command is run.
Control workstation
The machine the administrator drives the install from. It does not join the cluster.
- Windows, Linux or macOS with an SSH client (OpenSSH) and Node.js installed (Node is required by the generator).
- No local Docker. Container images are built on a target host.
- A copy of the project repository, via the forge's "Download ZIP" button or
git clone. The download does not contain secrets:.env.localand thekeys/folder are intentionally excluded and must be provided by hand (below);node_modules/anddist/are also absent (the build happens on a host).
Target hosts
- Debian 13 (or compatible), reachable over SSH by key, with
sudoavailable. - Single 1 host. HA 3 hosts is the reference; an odd number satisfies the etcd quorum the database coordination layer needs.
- The administrator's public SSH key already present in
~/.ssh/authorized_keysof the install user on each host. Without it the installer cannot connect. - Known system host names, for example
host1,host2,host3, or real names such assrv-prod-01. The name must match the value returned byuname -n, because it is used to set Swarm node labels and to identify Patroni/etcd members.
Network, DNS & TLS
- Inbound: only ports 80 and 443 need to be reachable from clients. Traefik redirects 80 to 443 and terminates TLS; every other service port stays on the internal/overlay network.
- DNS: each organization's host name (
<slug>.<baseDomain>) must resolve to the entry point, which is the manager host's address or the shared VIP in HA. A single host name is enough for a single-organization install. For a quick test before DNS is ready, the request can be forced to an address withcurl --resolve. - TLS: a self-signed certificate is generated automatically during the install (sufficient for a test or LAN, though browsers show a warning). For production, replace it with a real certificate, or enable Let's Encrypt issuance on Traefik.
The configuration file .env.local
A file at the repository root on the control workstation, never committed to source control. It carries the SSH access and the host addresses. The infra block is required for both modes:
VM_USER=ops # SSH and sudo user on the hosts
VM_PASS=******** # real sudo password of VM_USER (dev/test convenience)
VM_HOST1=10.0.0.1
VM_HOST2=10.0.0.2 # HA only
VM_HOST3=10.0.0.3 # HA only
SSH_KEY=keys/id_ed25519 # path to the OpenSSH private key
.env.local (which is git-ignored) or in cluster secrets. The SSH key path is itself a variable (SSH_KEY), so there is no hard-coded file name.Copy the OpenSSH private key of the install user into the repository (by convention keys/id_ed25519, which is git-ignored) and set its permissions:
cp .env.example .env.local # then edit it
mkdir -p keys && cp /path/to/my_key keys/id_ed25519 && chmod 600 keys/id_ed25519
.pg-creds (git-ignored) so re-runs reuse the same value (see Single host).Manifest & generator
A guided wizard (setup.sh) asks for the mode, the hosts and their roles, the root domain and the tenants, then writes the manifest (infra/cluster.json, git-ignored) and calls the generator. Run it from the repository root. There are three invocations:
bash setup.sh # interactive wizard, generate only
bash setup.sh --manifest infra/cluster.json # non-interactive, generate only (replays a saved manifest)
bash setup.sh --manifest infra/cluster.json --deploy # generate, then deploy
By default setup.sh only generates. It writes the manifest, renders the out/ artifacts listed below, and stops. Nothing is installed on any host, which leaves the output reviewable before anything runs. The non-interactive form does the same from a manifest that already exists, which is how a known-good configuration is replayed without answering the prompts again.
--deploy adds a single action after generation: it runs bash out/deploy-ssh.sh, the orchestrator that performs the whole bring-up over SSH. In HA that covers the ordered steps detailed in High-availability deployment: the database tier, Swarm initialisation and join, node labels and secrets, the image build, the stack deploy, the Garage initialisation, and tenant provisioning. In single mode it covers the same sequence minus the native database tier — PostgreSQL runs as a container inside the stack (see Single host deployment). Omitting --deploy does not skip the deployment, it only separates it: the same orchestrator is then launched by hand as the explicit next step.
bash out/deploy-ssh.sh # run the orchestrator when ready
out/deploy-ssh.sh drives the bring-up over SSH in single mode too, against one target host instead of several. A fully manual sequence remains documented as a fallback in Single host deployment, for when no control workstation with SSH access is available — or to understand exactly what the orchestrator does.The generator (infra/cluster/generate.mjs) renders the following artifacts under out/:
| Artifact | Role |
|---|---|
docker-stack.gen.yml | The rendered Swarm stack: service definitions, secrets, configs, placement by node label, the controlplane service, the data-plane api pool, and one isolated cell per dedicated tenant. |
traefik-dynamic.gen.yml | The reverse-proxy routers: /api/v1/auth → controlplane (sign-in), Host(<tenant>) → the right cell, a pool fallback, and the web front end. |
labels.sh | The Swarm node labels (vaks.cp / vaks.app / vaks.tenant.*) that pin services to the correct hosts. |
host-<name>.sh | A standalone bootstrap script per host. It installs Docker and, on a database host, the full database tier: the internal-TLS certificate, etcd, at-rest volume encryption (run before PostgreSQL initialises), PostgreSQL with Patroni, and HAProxy. |
deploy-ssh.sh | The orchestrator, both modes. HA: chains DB tier → Swarm init/join → labels + secrets → build images → deploy stack → init Garage → provision tenants over SSH. Single: Docker install → single-node Swarm → secrets → build → deploy → Garage → tenants, on the one host. |
cp hosts), and warns when the etcd quorum is even or smaller than three.Single host deployment 1 machine
In single mode everything runs on one machine in a single-node Swarm (the same secrets mechanism as production, upgradeable to HA later). PostgreSQL runs in a container, so there is no native Patroni/etcd/HAProxy tier. As in HA, the bring-up is orchestrated over SSH from the control workstation by the generated out/deploy-ssh.sh:
bash setup.sh # choose "single", enter the IP, the domain, the database name
bash out/deploy-ssh.sh # orchestrates the ENTIRE bring-up
# or in one go:
bash setup.sh --manifest infra/cluster.json --deploy
Only VM_USER and SSH_KEY (plus VM_PASS when sudo asks for a password) are required in .env.local. Every password and key the stack needs is generated automatically when not provided: the Postgres password is persisted to .pg-creds and the S3 key pair to .s3-creds (both git-ignored), which makes the script idempotent — re-running it reuses the same values and skips what already exists.
The orchestrator runs these ordered steps over SSH, all automatic:
| # | Step | What happens |
|---|---|---|
| 1 | Docker Engine | host-<name>.sh installs the base packages and Docker Engine on the machine (idempotent). |
| 2 | Swarm | docker swarm init on the single node. |
| 3 | Secrets | All 12 Swarm secrets are created: JWT keys, the containerised-Postgres password + database_url, the MFA key, the internal worker token, the SSO fallback, SMTP, the syslog placeholder, and the Garage/S3 material. |
| 4 | Images | The api, web and worker images are built on the machine. |
| 5 | Stack | A self-signed TLS certificate is generated if none was provided, the S3 access key is injected, then docker stack deploy brings the stack up. |
| 6 | Garage init | The single-node storage layout is assigned, the S3 key imported, and the API restarted so it creates its buckets. |
| 7 | Provisioning | One tenant is provisioned per tenant declared in the manifest. |
.pg-creds. It holds the containerised-Postgres password — the one used for direct psql, backups and restore. Losing it does not break the running stack (the secret persists inside the Swarm), but direct database access and idempotent re-runs depend on it.dedicated mode with no tenant declared, the manifest creates no organization: bootstrap the first one once the stack is up (see create the first organization in the manual sequence below).Manual bring-up (fallback)
The same sequence, step by step, run directly on the target machine — for when no control workstation with SSH access is available, or to understand exactly what the orchestrator does. Generate out/ anywhere, transfer it to the host or clone the repo there, then walk the steps on that machine.
Manual step 1: install Docker on the machine
Single mode has no native database tier, but Docker Engine is required for the single-node Swarm. The generator produces a ready-to-run script named after the host. Run it on the target machine:
sudo bash out/host-node1.sh # installs Docker Engine + enables it
docker --version # check
sudo bash infra/bootstrap/00-common.sh. This is the same base script used for the HA hosts, and it installs the base packages and Docker Engine only.docker group, so the docker … commands below fail with "permission denied". Either prefix them with sudo, or (recommended) run sudo usermod -aG docker $USER once and start a new SSH session.Manual step 2: build the images and prepare the deployment
The stack runs on three application images built on the machine, plus a couple of files it expects: the two generated configs and a self-signed TLS certificate.
# 1) Build the 3 application images (from the repository root)
docker build -t vaks-pm/api:latest -f api/Dockerfile .
docker build -t vaks-pm/web:latest -f web/Dockerfile .
docker build -t vaks-pm/worker:latest -f worker/Dockerfile .
# 2) Files the stack expects (the ./infra paths resolve relative to the compose file)
mkdir -p out/infra/certs
cp out/traefik-dynamic.gen.yml out/garage.gen.toml out/infra/
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -subj "/CN=*.local" \
-keyout out/infra/certs/wildcard.key -out out/infra/certs/wildcard.crt
Manual step 3: Swarm + secrets
Initialize the Swarm, then create the secrets. Copy-paste the block as-is, changing only the two cleartext passwords (Postgres and S3):
docker swarm init
# 1) Random secrets: nothing to choose
openssl rand -base64 48 | docker secret create jwt_access_secret -
openssl rand -base64 48 | docker secret create jwt_refresh_secret -
openssl rand -base64 32 | docker secret create mfa_encryption_key -
openssl rand -hex 32 | docker secret create garage_rpc_secret -
openssl rand -hex 32 | docker secret create garage_admin_token -
# 2) Postgres password: choose ONCE, reused in database_url
PGPASS='ChangeMe-strong-Postgres-password'
printf '%s' "$PGPASS" | docker secret create postgres_password -
printf 'postgresql://vakspm:%s@postgres:5432/vakspm?schema=public' "$PGPASS" | docker secret create database_url -
# 3) S3 (Garage) key, formats imposed by Garage: access = "GK"+24 hex, secret = 64 hex
S3_ACCESS=GK$(openssl rand -hex 12)
S3_SECRET=$(openssl rand -hex 32)
echo "S3 access=$S3_ACCESS"; echo "S3 secret=$S3_SECRET" # NOTE these two values
printf '%s' "$S3_SECRET" | docker secret create s3_secret_key -
# 4) SMTP password (leave a dummy value if no email / anonymous relay)
printf 'ChangeMe-or-x' | docker secret create smtp_password -
# 5) Internal secrets — REQUIRED by the stack even when the feature is unused
# (they are declared external: the deploy fails if any is missing)
openssl rand -hex 32 | docker secret create worker_api_token -
openssl rand -hex 16 | docker secret create sso_oidc_secret -
printf 'none' | docker secret create syslog_client_key -
docker secret ls # check: 12 secrets created
psql, backups and restore) and use the same value in postgres_password and inside database_url. Note down the printed S3 access/secret pair: it is reused in manual step 5 to register the key in Garage. Until that step is done the stack runs, but file uploads fail.Manual step 4: deploy the stack
Carry the S3 access key from manual step 3 (same shell) into the stack, then deploy:
sed -i -E "s|S3_ACCESS_KEY:.*|S3_ACCESS_KEY: $S3_ACCESS|g" out/docker-stack.gen.yml
docker stack deploy -c out/docker-stack.gen.yml vaks-pm
docker service ls # garage/postgres/redis/api/controlplane at 1/1
Manual step 5: initialize the Garage storage (S3)
Garage starts blank. Assign it a storage layout, then register the S3 key noted in manual step 3. The API then creates its buckets on restart:
CID=$(docker ps -q -f name=vaks-pm_garage | head -1)
g(){ docker exec "$CID" /garage "$@"; }
# 1) Layout: read the node id, assign a zone + capacity, apply
g status # note the <node_id>
g layout assign -z dc1 -c 100G <node_id>
g layout apply --version 1
# 2) Register the S3 key (same values as manual step 3) + bucket-creation right
g key import --yes -n vakspm-app "$S3_ACCESS" "$S3_SECRET"
g key allow --create-bucket "$S3_ACCESS"
# 3) Restart the API → it creates the buckets on startup
docker service update --force vaks-pm_api
g bucket list # → charter-blobs, document-templates, vaks-pm-attachments, vaks-pm-branding
g bucket list confirms S3 storage is operational. The API auto-creates charter-blobs, document-templates, vaks-pm-attachments and vaks-pm-branding as soon as the key is registered.Manual step 6: create the first organization and admin
In dedicated mode the manifest creates no organization, so bootstrap one after the stack is up:
# 1) Prepare the config from the template, then edit it (slug, name, admin email)
cp infra/org-bootstrap.example.json infra/org-bootstrap.json
# admin.password = null → generated randomly, shown ONCE in the output.
# 2) Inject it into the api container and run the bootstrap
CID=$(docker ps -q -f name=vaks-pm_api | head -1)
docker cp infra/org-bootstrap.json "$CID":/app/api/org-bootstrap.json
docker exec "$CID" sh -c 'export DATABASE_URL=$(cat /run/secrets/database_url) \
&& cd /app/api && node prisma/bootstrap-org.js'
High availability N hosts
The HA topology removes every single point of failure: a replicated PostgreSQL cluster, a redundant control plane, and an S3 store spread across hosts. The whole bring-up is orchestrated over SSH from the control workstation by a single command, with no manual step on the hosts.
Host roles
The wizard asks which roles each host carries. Several roles can be combined on one host.
| Role | What it runs on the host | HA constraint |
|---|---|---|
manager | Docker Swarm manager: decides where containers run and exposes ports 80/443 via Traefik. | ≥ 1; 2 recommended for resilience. |
cp (control plane) | Traefik + the shared controlplane service (sign-in + per-tenant routing). The public HTTPS entry point. | ≥ 2 required; each cp must also be a manager. |
app (data plane) | The application services api, web and worker that serve requests. | ≥ 1; set all hosts to app to spread the load. |
db | PostgreSQL via Patroni (native on the host, not in Docker) with a local HAProxy load balancer. | 3 nodes recommended (1 primary + 2 replicas). |
etcd | A member of the etcd quorum that elects the Patroni leader and stores the DB cluster state. | Odd ≥ 3 (otherwise no quorum). |
worker | A plain Swarm worker node (no Traefik, no control plane). | Mutually exclusive with manager. |
manager,db,etcd,cp,app; host3 carries worker,db,etcd,app. Result: a 3-node database HA cluster, an etcd quorum of 3, and a control plane redundant across two hosts.HA topology
- Control plane: Traefik +
controlplanereplicated on thecphosts. A single entry point is provided by a keepalived VIP on those hosts (thecontrolPlane.vipmanifest field) or by DNS round-robin. - Data plane: the
apipool is spread across theapphosts. The control plane's JWT is required, and its embedded tenant must match the requested host. - PostgreSQL HA: Patroni + etcd (odd quorum ≥ 3); a local HAProxy routes writes to the leader on port 5000 and reads to replicas on 5001. The backend reaches the database through HAProxy, never directly.
What goes into .env.local for HA
In addition to the infra variables, the HA orchestrator reads the database and JWT values. Generate each once in the shell and paste the literal result. Do not write $(openssl …) into the file: it is sourced on every run and would regenerate a different value each time.
# DB / Patroni
POSTGRES_USER=vakspm
POSTGRES_PASSWORD=<value>
POSTGRES_DB=vakspm
PATRONI_REPLICATION_PASSWORD=<value>
PATRONI_SUPERUSER_PASSWORD=<value>
# Auth (JWT signing keys, high entropy required)
JWT_ACCESS_SECRET=<value>
JWT_REFRESH_SECRET=<value>
# Generate the values once, then paste each result above:
openssl rand -hex 24 # DB passwords (hex: no @ : / ? # & % which would break the DSN)
openssl rand -base64 48 # JWT keys (read as Docker secrets, not embedded in a URL)
Run the orchestrator
bash setup.sh # choose "ha", N hosts, roles, tenants
bash out/deploy-ssh.sh # orchestrates the ENTIRE bring-up
# or in one go:
bash setup.sh --manifest infra/cluster.json --deploy
The orchestrator runs these ordered steps over SSH, all automatic:
| # | Step | What happens |
|---|---|---|
| 1 | Database tier | On each db host, in order: 00-common (base packages + Docker), 12-certs (internal TLS cert), 10-etcd (coordination store, started over HTTPS), 15-tang + 18-luks-data (at-rest volume encryption, run here so the volumes are mounted empty before the database initialises), 20-patroni (PostgreSQL 17 + Patroni), then 30-haproxy (local load balancer). After that 25-bootstrap-db is attempted on each db node: the elected leader creates the application role (with CREATEDB) and database, and replicas skip themselves. |
| 2 | Swarm | 40-swarm initializes Swarm on the first manager and joins the other managers (cp hosts) and workers. |
| 3 | Labels + secrets | labels.sh applies node placement labels; then the Swarm secrets are created, the core ones from .env.local and the rest (MFA key, SMTP, Garage RPC/admin tokens, S3 key) generated. |
| 4 | Images | The api, web and worker images are built on the manager, then docker save/load'd to the other nodes. |
| 5 | Stack | A self-signed TLS cert is generated if none was supplied, the S3 access key is injected, then docker stack deploy brings the stack up. |
| 6 | Init Garage | The Garage nodes are federated (node connect), assigned a multi-zone layout, the S3 key is imported, and the API is restarted so it creates its buckets. |
| 7 | Provision | One tenant is provisioned for each tenant declared in the manifest. |
~/vakspm-cluster/ on each relevant host. The native database services write their configs to /etc/patroni.yml, /etc/default/etcd and /etc/haproxy/haproxy.cfg; PostgreSQL data lives under /var/lib/postgresql/17/main/ and holds all tenant databases at once. Swarm secrets are encrypted in the Swarm Raft log on managers and mounted into containers on a tmpfs at /run/secrets/<name>, never written to the node disk.Provisioning tenants
In pooled mode each tenant (customer organization) has its own database <dbName>_<slug> inside the shared PostgreSQL cluster, and all tenants share the api pool, resolved by host name. A slug is a short, url-safe identifier ([a-z0-9-]+: lowercase, digits and hyphens only; no dot, space, uppercase or accent). Tenants can be declared in the manifest or provisioned later without touching it:
# In an api container (on an "app" host):
docker exec <api-cid> sh -c \
'export DATABASE_URL=$(cat /run/secrets/database_url) && \
cd /app/api && node prisma/provision-tenant.js acme admin@acme.com'
Provisioning creates the tenant database, applies the schema and bootstraps its admin. Sign-in is then done at acme.<baseDomain> via the control plane. A dedicated tenant additionally gets its own api-<slug> / worker-<slug> cell with isolated resources, routed by its host name.
Encryption layers
A Swarm install has several independent encryption layers. Internal TLS, at-rest volume encryption (on a high-availability install) and the application key ring's fallback master key are active out of the box. The remaining hardening, a custom certificate authority, strict database verification, the versioned key ring and backup encryption, is turned on by a stack-file change or an admin action. The layers are documented together in this section, but they are not all applied at the same moment; the last column records when each one happens.
| Layer | Protects | Enabled by | When |
|---|---|---|---|
| Public TLS (in transit, external) | All client traffic to the instance. | Traefik at the edge: self-signed cert generated during install, replaceable with a real one. on by default | At deploy; the real certificate can be swapped in later. |
| Internal TLS (in transit, internal) | App↔PostgreSQL, etcd peer/client mTLS, and the Swarm overlay network. | Bootstrap 12-certs.sh (self-signed) + 10-etcd.sh/20-patroni.sh. See Internal TLS. on by default | Self-signed is automatic at install. A custom CA and strict app-side verification can be applied at install or later (rolling). |
| Disk encryption (at rest, volumes) | The PostgreSQL data volume and the Garage object-store volume. | Bootstrap 15-tang.sh + 18-luks-data.sh (LUKS2 + Clevis/Tang). See Disk encryption. automatic (HA) | Built into the database-tier bootstrap, before PostgreSQL initialises. On a high-availability install the generated scripts do this automatically and in order. A single-host install runs PostgreSQL as a container, so this native path does not apply and volume encryption is the host's responsibility. |
| Field encryption (at rest, app secrets) | SMTP password, SSO client secrets, MFA seeds, connector secrets. | Envelope encryption (KEK/DEK): a fallback master key works immediately, and a versioned key ring enables rotation. See Local key ring. on by default | Active from first boot. A versioned key ring and rotation are admin actions, done anytime after deploy. |
| Backup encryption (at rest, artifacts) | Database dumps, base backups, WAL files. | Configured with the backup tooling (age asymmetric encryption). See Operations. opt-in | When the backup schedule is set up, after deploy. |
setup.sh asks whether to encrypt the data volumes (LUKS/Tang, yes by default) and whether to verify the database certificate strictly against an internal certificate authority. The answers are written to an encryption block in the manifest, and the generator acts on them: it includes or skips the per-host LUKS steps, and for strict verification it adds the pg_ca_v1 config and the PG_TLS_* variables to api/controlplane automatically. The procedures further down document what the generator produces, and how to apply the same change to an existing or hand-edited stack.Internal TLS
A fresh install provisions internal TLS automatically with self-signed certificates: the internal traffic is encrypted, but peer identity is not verified. Three flows are covered:
- Database: PostgreSQL TLS for application connections and for replication between nodes (
20-patroni.sh). - Coordination store: etcd peer and client mutual TLS (mTLS), where each node is both a server and a client (
10-etcd.sh). etcd starts over HTTPS from the first boot, and a retrofit of etcd TLS onto a live cluster is risky and is deliberately avoided. - Overlay network: the Swarm overlay uses IPsec symmetric-key encryption managed by Docker (no certificate involved).
The bootstrap places one shared certificate at /etc/vaks/server.{crt,key} (the same bytes on every host, which is what lets the auto-anchored etcd peer mTLS work: each node trusts the certificate its peers present by using that same certificate as its own trust anchor). Per-service copies for postgres and etcd are created once the system users exist.
Supplying a custom Certificate Authority (optional)
To add identity verification (protection against an active man-in-the-middle), replace the self-signed certificates with ones issued by the organization's internal CA (Certificate Authority). The decisive requirement: issue one certificate whose Extended Key Usage carries both serverAuth and clientAuth (etcd mTLS needs both, and a server-only certificate is rejected on the client side), and whose Subject Alternative Name (SAN) lists every host IP plus 127.0.0.1 and localhost. Produce three PEM files: the leaf certificate (server.crt, plus any intermediate chain), the matching unencrypted private key (server.key, mode 600, owned by the service user), and the CA certificate (ca.crt). Install them on each host one at a time (replicas first), point the trust anchors at ca.crt (etcd *_TRUSTED_CA_FILE, Patroni's etcd3.cacert), then restart the services and confirm cluster health before moving to the next node.
By default the application opens a TLS connection to PostgreSQL but does not verify the server certificate. Strict verification (protection against an active man-in-the-middle on the app-to-database hop) is controlled by three environment variables read by the api and controlplane services, the only two that open database connections:
| Variable | Effect |
|---|---|
PG_TLS | 1 opens the connection over TLS. Unset or any other value means a cleartext connection. |
PG_TLS_CA | Filesystem path, inside the container, to the CA certificate (PEM) that signed the database certificate. Unset means encrypted without verification (the default). Setting it switches on strict verification against that CA. |
PG_TLS_MODE | verify-full (the default when PG_TLS_CA is set) checks the certificate chain and that the host name matches the certificate. verify-ca checks the chain only. |
The CA certificate is not secret, so it is supplied as a Docker config, the same mechanism the stack already uses for Traefik's certificate. The procedure edits the stack file: docker-stack.yml on a single host, or out/docker-stack.gen.yml on a high-availability cluster (regenerated by the manifest generator, so re-apply the edit after any regeneration). Run the steps on a Swarm manager.
1. Place the CA on the manager and declare it as a config. Copy the issuing CA certificate to infra/certs/pg-ca.crt next to the stack file, then add an entry under the top-level configs: block:
configs:
pg_ca_v1:
file: ./infra/certs/pg-ca.crt
2. Mount the config and set the variables on both services. Add the same four lines to the api service and to the controlplane service (both connect to the database; the worker does not and is left unchanged):
environment:
# ...existing variables...
PG_TLS: "1"
PG_TLS_CA: /etc/vaks/pg-ca/ca.crt
PG_TLS_MODE: verify-ca
configs:
- source: pg_ca_v1
target: /etc/vaks/pg-ca/ca.crt
verify-full would reject the connection. Use verify-ca unless the certificate's SAN includes the exact name or IP the application dials. The path in PG_TLS_CA must match the target the config is mounted at.3. Redeploy and confirm. Configs are immutable once created, so a later change to the CA needs a new name (pg_ca_v2) referenced in both places.
docker stack deploy --with-registry-auth -c <stack-file> vaks-pm
# the api/controlplane tasks restart; a bad CA path or chain shows up as a
# database-connection error in the logs:
docker service logs --since 3m vaks-pm_api
docker service logs --since 3m vaks-pm_controlplane
PG_TLS_CA left unset the database connection stays encrypted without verification, which is the default and needs no stack change.Disk encryption (LUKS2 + Tang)
The at-rest disk-encryption layer protects the data volumes (the PostgreSQL data and the Garage object store) with LUKS2 (aes-xts-plain64, 512-bit). It encrypts only those volumes, not the host OS, and is unlocked using network-bound key release: a stolen disk on its own cannot be decrypted. Two bootstrap steps set it up. On a high-availability install the generated per-host script runs them automatically, ahead of the database tier, so the volumes are mounted empty before PostgreSQL initialises. The settings below are filled in by the generator and are documented here to explain what runs, not as a manual step. To skip this layer, for example when full-disk encryption is already provided at the infrastructure level, the wizard accepts a no answer, recorded as encryption.diskLuks: false in the manifest.
Tang server (15-tang.sh)
Tang is a small, stateless network key server (it holds no per-client secret). The bootstrap installs it alongside Clevis (the client that binds a LUKS volume to one or more Tang servers) and listens on port 7500. In a multi-host install each host runs a Tang server, and the hosts bind to each other's Tang servers, never to their own.
LUKS volumes & Clevis binding (18-luks-data.sh)
This step creates file-backed LUKS2 containers for the PostgreSQL and Garage data, then binds them to the peer Tang servers with a Clevis SSS (Shamir Secret Sharing) policy of threshold t=1: the volume unlocks automatically as long as at least one peer Tang server is reachable. A boot-time helper retries the unlock and mounts the volume. The generated host script passes the peer Tang addresses (the other database hosts' IPs) automatically:
PEER_TANG_IPS="ip1,ip2" # IPs of the PEER Tang hosts (never the host's own)
SETUP_PG=1 # prepare the pgdata volume (default)
SETUP_GARAGE=1 # prepare the garage volume (default)
Local key ring (KEK/DEK)
The application encrypts certain sensitive fields (SMTP password, SSO (single sign-on) client secrets, MFA (multi-factor authentication) seeds, ticketing-connector secrets) with envelope encryption: a per-tenant DEK (Data Encryption Key, AES-256-GCM) encrypts the fields, and a KEK (Key Encryption Key) wraps the DEK. The KEK never touches the data; it only locks and unlocks DEKs. Rotating the KEK re-wraps a handful of small DEKs and re-encrypts no field data, so it is fast and causes no downtime.
Default state: no key ring
Out of the box no versioned key files are mounted. The application reads a single 32-byte master key from the Docker secret mfa_encryption_key (created during the install) and registers it as the KEK reference env. Encryption and decryption work immediately; there is simply nothing to rotate yet. In production at least one KEK must be loadable, either a kek_v* file or the mfa_encryption_key master key, or the api/controlplane services refuse to start.
Add and activate a versioned KEK
Mounting a versioned key ring is what enables rotation. Each KEK version is one Docker secret named kek_v<N> holding base64 text that decodes to exactly 32 bytes. Run on a Swarm manager:
# 1) Create the secret directly from generated material (never written to disk)
openssl rand -base64 32 | docker secret create kek_v1 -
Declare the secret external: true in the stack file and mount it on both the api and controlplane services (the only services that decrypt, since the worker does not), setting the active version:
environment:
CRYPTO_KEYS_DIR: /run/secrets # directory scanned for kek_v* files
CRYPTO_ACTIVE_KEK: v1 # new wraps use this version
secrets:
- kek_v1
docker stack deploy --with-registry-auth -c <stack-file> vaks-pm
docker service logs --since 3m vaks-pm_api | grep Keyring
# expected: Keyring loaded: v1, env (active=v1)
Rotate
To rotate the KEK: add a new kek_v<N+1> secret keeping the previous one mounted, set CRYPTO_ACTIVE_KEK to the new version, redeploy, then trigger the re-wrap from Admin → Security & Compliance → Encryption keys → Rotate KEK. Each tenant's DEK is unwrapped with its old KEK and re-wrapped under the new one; no field data is re-encrypted. Retire the old KEK only once every DEK references the new one and no retained backup can still reference the old KEK.
Encryption checklist
Confirm each layer is active after the install:
| Layer | Check | Expected |
|---|---|---|
| Public TLS | Load the instance host name over HTTPS in a browser. | Sign-in page served over TLS (a warning is normal with the self-signed cert). |
| Database TLS | sudo -u postgres psql -p 5432 -tAc "show ssl;" on a db host. | on |
| etcd mTLS | etcdctl … endpoint health with the CA/cert flags. | healthy |
| Disk encryption | lsblk / cryptsetup status on the data volume; mountpoint -q /var/lib/postgresql/17/main. | The data volume is a LUKS mapper device and is mounted. |
| Field encryption | docker service logs --since 3m vaks-pm_api | grep Keyring; or GET /admin/crypto/status. | Keyring line lists the loaded keys and the active one; status reports the active KEK and DEK distribution. |
| Decrypt end-to-end | Open an admin page that reads an encrypted value (e.g. the notification/SMTP settings). | The value decrypts and displays. |
Swarm secrets
Sensitive values are never baked into images or committed. They live as Swarm secrets, encrypted in the Raft log on the managers and mounted read-only into containers under /run/secrets/. In both modes the orchestrator creates/generates them all automatically; the manual single-host fallback creates them by hand at manual step 3.
| Secret | Content | Created by |
|---|---|---|
jwt_access_secret / jwt_refresh_secret | JWT signing keys, shared between the control plane and the cells. | orchestrator (from .env.local; generated in single if absent) · manual: step 3 |
database_url | The database connection string (via HAProxy :5000 in HA, postgres:5432 in single). | orchestrator · manual: step 3 |
mfa_encryption_key | The 32-byte AES key used to encrypt MFA/TOTP secrets and as the fallback KEK (reference env). | orchestrator (generated) · manual: step 3 |
smtp_password | The SMTP relay password (or a dummy value if email is disabled / anonymous). | orchestrator · manual: step 3 |
worker_api_token / sso_oidc_secret / syslog_client_key | Internal worker→API token, global SSO fallback, and the audit-syslog mTLS client key (placeholder none until enabled). Required by the stack even when the feature is unused. | orchestrator (generated) · manual: step 3 |
garage_rpc_secret / garage_admin_token / s3_secret_key | On-premise S3 storage (Garage): inter-node RPC secret, admin token, and the S3 key in Garage format. | orchestrator (generated + init Garage) · manual: steps 3 & 5 |
postgres_password single | Password of the containerized PostgreSQL (single only; persisted to .pg-creds by the orchestrator). | orchestrator · manual: step 3 |
kek_v<N> optional | A versioned Key Encryption Key for the key ring (base64, 32 bytes). | Created by hand to enable KEK rotation (see Local key ring). |
Post-install checks
Confirm the instance is healthy before handing it over:
# cluster state
docker stack ps vaks-pm # all tasks Running
patronictl -c /etc/patroni.yml list # 1 leader + N replicas (HA)
# control plane: sign-in on a tenant (401 on bad creds = the chain works)
curl -sk --resolve acme.vaks-pm.com:443:<manager-ip> \
-X POST https://acme.vaks-pm.com/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@acme.com","password":"..."}'
# data plane: api liveness/readiness
curl -sk --resolve acme.vaks-pm.com:443:<manager-ip> https://acme.vaks-pm.com/api/v1/health/live # 200
curl -sk --resolve acme.vaks-pm.com:443:<manager-ip> https://acme.vaks-pm.com/api/v1/health/ready # 200
- Liveness / readiness: the
apiexposes/health/liveand/health/ready(both used by Swarm's own health checks). - Sign-in: the seeded administrator account can sign in and reach the admin area.
/api/v1/auth/*is served by the controlplane; everything else by the api. A JWT issued for another tenant is rejected. - Object store: uploading a task attachment succeeds (this exercises the S3 path end to end).
- Email (if configured): the admin area's Send test email action delivers a message.
Failures & recovery
Business data lives in PostgreSQL (HA via Patroni); the other services carry soft state that can be rebuilt. When a host crashes, Swarm reacts according to each service's placement type.
| Failed service | Impact | Recovery |
|---|---|---|
| PostgreSQL (1 node) | HA Automatic failover: Patroni promotes a replica; a few seconds of write outage. | Nothing to do. Check patronictl -c /etc/patroni.yml list. The former primary rejoins as a replica on restart. |
| Control plane (1 cp host) | HA The other Traefik + controlplane keeps serving (with ≥ 2 cp). The entry point fails over via the VIP / DNS round-robin. | Nothing to do. Confirm with docker service ps vaks-pm_controlplane. |
| Garage (multi-node) | HA With a replication factor ≥ 2, objects stay served by the other nodes. | Automatic resync when the node returns. Permanent replacement: garage layout assign the new node id + apply. |
| Redis | SPOF tolerated No crash, no business loss. During the outage: cross-replica real-time is cut, triggered emails/webhooks for that window are lost, crons skipped. | If pinned and the host returns: nothing to do (volume intact). If the host is dead: move it to a live node (docker service update --constraint-rm … --constraint-add …), restarting empty. |
| Worker | Pending jobs are not processed but remain queued in Redis. | Auto-rescheduled by Swarm (no local state); resumes the queue on startup. |
| Patroni / etcd / HAProxy | native Not managed by Swarm. Patroni performs the database failover; etcd keeps the quorum (odd ≥ 3). | Restart the systemd service on the affected node and verify cluster state. |
age-encrypted backup artifacts, see Operations.Updates & upgrades
An upgrade replaces the container images with a newer version; the customer never builds or edits code. Because api/web/worker are stateless and run several replicas, Swarm performs a rolling update — it swaps replicas one at a time while the surviving ones carry the traffic, so the operation is zero-downtime. The database schema travels with the image (see Database schema below), so there is no separate SQL migration pack to run in the routine case.
api (it aligns the schema at start-up), then controlplane, worker, web; (4) verify health. The detailed steps follow.1 · Get the new images
Three ways to bring the new version to the hosts, depending on how the instance was delivered:
| Delivery mode | Procedure |
|---|---|
| Container registry (images pushed to a private registry) | On a manager, docker pull <registry>/vaks-pm/api:<tag> (likewise web/worker), or let docker service update do it with --with-registry-auth. The other nodes pull from the registry. |
Image archives (delivered as .tar files) | On every node: docker load -i <svc>.tar. Confirm the tag's image ID is identical on all nodes before the rolling update (a docker load can leave a stale layer behind). |
| Updated sources (the customer rebuilds on a host) | Replace the source tree on the manager, then re-run bash out/deploy-ssh.sh: it rebuilds the images, distributes them to the nodes and rolling-updates. Idempotent. |
:latest. Under containerd, one moving tag (:latest) can resolve to different layers on different nodes. Delivering and deploying by unique version tag (e.g. vaks-pm/api:1.4.0) keeps the rollout reproducible and verifiable across nodes.2 · Rolling-update the services
Swarm does not re-pull an unchanged tag without --force. Deploy service by service. The recommended order puts api (and its twin controlplane) first, because that is what aligns the schema at start-up:
TAG=1.4.0 # the version being rolled out
docker service update --force --with-registry-auth --image vaks-pm/api:$TAG vaks-pm_api
docker service update --force --with-registry-auth --image vaks-pm/api:$TAG vaks-pm_controlplane
docker service update --force --with-registry-auth --image vaks-pm/worker:$TAG vaks-pm_worker
docker service update --force --with-registry-auth --image vaks-pm/web:$TAG vaks-pm_web
docker service ls # every service back to its replica count, image = the new tag
Each command waits for the replicas to converge before returning; the rollout stops on its own if the new tasks do not become healthy (the /health/ready health check), leaving the old tasks serving. In pooled mode, the per-tenant dedicated cells (vaks-pm_api-<slug> / vaks-pm_worker-<slug>) update the same way, one at a time.
3 · Database schema
The schema travels in the image: at start-up the api container reads the new image's schema.prisma and aligns the database automatically (prisma db push). For a routine upgrade there is therefore no manual migration step — simply rolling-updating api in step 2 applies the schema.
CREATEDB and owns its databases. If the instance is wired to an externally-managed database with a least-privilege app role (no DDL rights on the public schema), the start-up push fails (permission denied for schema public / must be owner of table …) and api enters a restart loop. In that case, apply the schema change before the rolling update, with a role that has DDL rights:
# from an api image, pointed at the target database with a DDL-capable role
docker run --rm -e DATABASE_URL='postgresql://<owner>:…@<host>:5432/<db>' \
vaks-pm/api:$TAG sh -c 'cd /app/api && npx prisma db push --accept-data-loss'
then run the normal rolling update (the start-up push will have nothing left to apply).prisma db push is forward-only: there is no reverse migration, and some type or constraint changes can drop data. On a populated database, take a backup before any schema-touching upgrade (see Operations). Rolling the code back is done by redeploying the previous image tag; rolling the schema back, however, means restoring from backup — there is no other path.api start-up aligns the database it opens its connection to, not the whole fleet. For a multi-tenant deployment, every tenant database (<dbName>_<slug>) must receive the schema change. If the app role owns those databases, the iteration can be automated with a DDL-capable role by enumerating the tenant databases and running prisma db push against each, before the api rolling update.4 · Configuration or stack changes
- Stack file / Traefik routing. If
docker-stack.gen.ymlortraefik-dynamic.gen.ymlchange, regenerate (bash setup.sh --manifest infra/cluster.json) thendocker stack deploy --with-registry-auth -c out/docker-stack.gen.yml vaks-pm. The dynamic Traefik config is an immutable Swarm config: bump its version suffix (traefik_dynamic_v2→v3) in the stack file before redeploying, otherwise the new config is ignored. - Secrets. Swarm secrets are immutable: to rotate a value, create a new secret (versioned name), reference it in the stack file, then
docker stack deploy. Never remove a KEK that still wraps a live DEK (see Local key ring).
5 · Verify after the upgrade
Confirm the new version is healthy before treating the operation as done:
docker stack ps vaks-pm # all tasks Running on the new image
docker service logs --since 5m vaks-pm_api | grep -Ei 'schema|prisma|error' # schema applied cleanly
# liveness / readiness on a tenant hostname
curl -sk --resolve acme.vaks-pm.com:443:<manager-ip> https://acme.vaks-pm.com/api/v1/health/ready # 200
- All tasks are
Runningon the new tag; no restart loop onapi(the tell-tale sign of a failed schema push). - Sign-in on a tenant works and an admin page opens; an attachment upload succeeds (exercises the S3 path).