Deploy on Docker Swarm

Vaks PM · From-scratch deployment · Docker Swarm · June 2026

Purpose of this guide. This document is a complete, self-contained procedure for standing up a Vaks PM instance on Docker Swarm, from an empty set of servers to a running, encrypted production system. It is written for a system administrator who knows Linux, Docker, SSH and TLS in general but has no prior exposure to this product. It covers a single-host install (one machine) as well as a high-availability install (several hosts), lists every prerequisite and the expected initial state, and walks through the encryption layers (internal TLS, at-rest disk encryption, the application key ring) configured during the install. For an orchestrator-agnostic orientation see Architecture; for the Kubernetes path see Deploy on Kubernetes.

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:

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.

Control workstation SSH client + Node.js 22 TARGET CLUSTER · DEBIAN 13 HOSTS Traefik · controlplane TLS · routes by host name (443 · 80) web · api · worker stateless · scaled horizontally PostgreSQL Patroni HA Redis cache · queue Garage S3 store etcd · HAProxy (HA database tier) native systemd services on the db hosts
The installer runs from a control workstation and drives the cluster over SSH. The application services are stateless; durable state lives in PostgreSQL, Redis and the Garage S3 store. In HA the database tier (PostgreSQL via Patroni, etcd, HAProxy) runs natively on the hosts.

Glossary

Acronyms used throughout this guide, defined once here.

TermMeaning
SSHSecure Shell: encrypted protocol used to administer the hosts and to drive the installer.
HAHigh Availability: a topology that survives the loss of a single host without an outage.
DNSDomain Name System: maps host names to network addresses.
TLSTransport Layer Security: the encryption layer behind HTTPS and used between internal components.
VIPVirtual IP: a single floating network address shared by several hosts for failover (provided here by keepalived).
S3Simple Storage Service: an object-storage interface that became an industry standard. The instance hosts its own S3 store (Garage); no cloud account is involved.
etcdA distributed key-value store used here for cluster coordination. It elects the PostgreSQL leader and holds the database cluster state.
DCSDistributed Configuration Store: the generic term for the coordination store (etcd) that the database HA manager relies on.
QuorumThe majority of coordination nodes that must agree for the cluster to make decisions, which is why etcd needs an odd number ≥ 3.
LUKSLinux Unified Key Setup: the Linux standard for full-volume disk encryption (used here in its LUKS2 form for at-rest data encryption).
KEKKey Encryption Key: a master key that wraps (encrypts) data keys but never touches the data itself.
DEKData Encryption Key: the key that actually encrypts sensitive fields; one per tenant, stored only in wrapped form.
TenantOne isolated customer organization served by the instance. Each tenant has its own database.
PatroniThe high-availability manager for PostgreSQL: it runs one elected leader plus streaming replicas and performs automatic failover.
JWTJSON Web Token: a signed session token issued at sign-in.
SMTPSimple 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.

Target hosts

Network, DNS & TLS

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
Absolute rule. Never place credentials, passwords or IP addresses in source control. All sensitive values belong in .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
Database and JWT secrets. The template also lists Postgres / Patroni passwords and JWT signing keys, read by the orchestrator to configure the database tier and create the Swarm secrets. In HA mode they must be filled in before the orchestrator runs (see High availability). In single mode they are optional: any missing value is generated automatically, and the Postgres password is persisted to .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
Both modes are orchestrated. The generated 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/:

ArtifactRole
docker-stack.gen.ymlThe 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.ymlThe reverse-proxy routers: /api/v1/auth → controlplane (sign-in), Host(<tenant>) → the right cell, a pool fallback, and the web front end.
labels.shThe Swarm node labels (vaks.cp / vaks.app / vaks.tenant.*) that pin services to the correct hosts.
host-<name>.shA 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.shThe 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.
The generator refuses to render an HA target whose control plane is not redundant (fewer than two 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:

#StepWhat happens
1Docker Enginehost-<name>.sh installs the base packages and Docker Engine on the machine (idempotent).
2Swarmdocker swarm init on the single node.
3SecretsAll 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.
4ImagesThe api, web and worker images are built on the machine.
5StackA self-signed TLS certificate is generated if none was provided, the S3 access key is injected, then docker stack deploy brings the stack up.
6Garage initThe single-node storage layout is assigned, the S3 key imported, and the API restarted so it creates its buckets.
7ProvisioningOne tenant is provisioned per tenant declared in the manifest.
Keep .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.
In 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
The equivalent if only the repository is available: 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 permissions. Right after install the current user is not yet in the 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
About the cleartext values. Keep the Postgres password (it is the database password used for direct 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
Seeing the buckets in 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'
The generated admin password is printed only once, so note it (a forced change happens at first login). To load a demonstration dataset instead of an empty organization, see Demo session.

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.

RoleWhat it runs on the hostHA constraint
managerDocker 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.
dbPostgreSQL via Patroni (native on the host, not in Docker) with a local HAProxy load balancer.3 nodes recommended (1 primary + 2 replicas).
etcdA member of the etcd quorum that elects the Patroni leader and stores the DB cluster state.Odd ≥ 3 (otherwise no quorum).
workerA plain Swarm worker node (no Traefik, no control plane).Mutually exclusive with manager.
Recommended 3-host configuration (the wizard defaults): host1 and host2 carry 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

host1 (Manager · CP) host2 (Manager · CP) host3 (Worker) ├── traefik + controlplane ├── traefik + controlplane ├── api / worker ├── api / worker ├── api / worker ├── DB Replica 2 ├── DB Primary ├── DB Replica 1 └── etcd node 3 ├── redis / garage └── etcd node 2 └── etcd node 1

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)
These values are persistent. Changing them after the first deployment breaks the database connection or invalidates every live session, so note them in a safe place. Everything else (S3/Garage keys, MFA key, SMTP password, TLS cert) is generated automatically by the orchestrator.

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:

#StepWhat happens
1Database tierOn 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.
2Swarm40-swarm initializes Swarm on the first manager and joins the other managers (cp hosts) and workers.
3Labels + secretslabels.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.
4ImagesThe api, web and worker images are built on the manager, then docker save/load'd to the other nodes.
5StackA self-signed TLS cert is generated if none was supplied, the S3 access key is injected, then docker stack deploy brings the stack up.
6Init GarageThe 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.
7ProvisionOne tenant is provisioned for each tenant declared in the manifest.
Where things land. The deployment bundle is copied to ~/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.
At-rest volume encryption is built into step 1. On a high-availability install the generated per-host scripts run the LUKS/Tang steps automatically, in the correct order: the encrypted volumes are created and mounted before PostgreSQL initialises, so the database never writes to an unencrypted disk. No separate action is required. The mechanism and its recovery model are described under Disk encryption.

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.

LayerProtectsEnabled byWhen
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 defaultAt 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 defaultSelf-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 defaultActive 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-inWhen the backup schedule is set up, after deploy.
Timing, for reference. Disk encryption is the one layer that must precede the data tier, since LUKS volumes have to exist before PostgreSQL writes its first byte. On a high-availability install the generator already places the LUKS/Tang steps ahead of PostgreSQL in step 1, so this happens automatically (see Disk encryption). The other layers are applied at deploy time or later: a custom certificate authority and strict database verification are stack-file changes followed by a redeploy, and the key ring and backup encryption are configured once the instance is running.
The wizard configures the two adjustable layers. 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.
For the full control mapping against the DORA (Digital Operational Resilience Act, EU Regulation 2022/2554) requirements and the key-management policy, see Security.

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:

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:

On a fresh install, the wizard wires this. Answering yes to strict database verification (with the CA path) makes the generator add everything below to the stack automatically. The manual steps that follow are for adding verification to an already-running deployment, or to a stack edited by hand.
VariableEffect
PG_TLS1 opens the connection over TLS. Unset or any other value means a cleartext connection.
PG_TLS_CAFilesystem 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_MODEverify-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
Pick the mode to match how the application reaches PostgreSQL. The application connects through HAProxy by service name or IP, which usually does not match the host names in the certificate's Subject Alternative Name, so 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
Issuing the database certificate itself (OpenSSL configuration, the certificate signing request, the Subject Alternative Name list and the one-node-at-a-time rollout on the database hosts) is covered in Security. With 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)
Recovery and lock-out. Because key release is network-bound, the data unlocks on its own at boot while peer Tang servers are reachable, with no operator interaction. For the case where every Tang peer is unreachable, escrow a LUKS recovery passphrase off-cluster. Losing that passphrase while Tang is down makes the volume unrecoverable by design. See Security for the key-management policy.

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.

Never delete or unmount a KEK that still wraps a live DEK. If a KEK is removed while any DEK is still wrapped by it, that DEK can no longer be unwrapped and every secret it protects is permanently lost. A database backup contains only wrapped DEKs, so each KEK must be retained at least as long as the longest backup made while it was active. KEK material must also be backed up separately from the database, with equal or longer retention. Conceptual detail and the rotation runbook are in Security.

Encryption checklist

Confirm each layer is active after the install:

LayerCheckExpected
Public TLSLoad 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 TLSsudo -u postgres psql -p 5432 -tAc "show ssl;" on a db host.on
etcd mTLSetcdctl … endpoint health with the CA/cert flags.healthy
Disk encryptionlsblk / 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 encryptiondocker 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-endOpen 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.

SecretContentCreated by
jwt_access_secret / jwt_refresh_secretJWT signing keys, shared between the control plane and the cells.orchestrator (from .env.local; generated in single if absent) · manual: step 3
database_urlThe database connection string (via HAProxy :5000 in HA, postgres:5432 in single).orchestrator · manual: step 3
mfa_encryption_keyThe 32-byte AES key used to encrypt MFA/TOTP secrets and as the fallback KEK (reference env).orchestrator (generated) · manual: step 3
smtp_passwordThe SMTP relay password (or a dummy value if email is disabled / anonymous).orchestrator · manual: step 3
worker_api_token / sso_oidc_secret / syslog_client_keyInternal 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_keyOn-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 singlePassword of the containerized PostgreSQL (single only; persisted to .pg-creds by the orchestrator).orchestrator · manual: step 3
kek_v<N> optionalA 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

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 serviceImpactRecovery
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.
RedisSPOF 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.
WorkerPending jobs are not processed but remain queued in Redis.Auto-rescheduled by Swarm (no local state); resumes the queue on startup.
Patroni / etcd / HAProxynative 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.
For backup tiers (logical dump, point-in-time recovery), restore procedures, and the 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.

At a glance. (1) fetch the new images; (2) on a populated database, back up first; (3) rolling-update 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 modeProcedure
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.
Pin a versioned tag rather than :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.

Precondition: the app role must own the schema. The automatic alignment assumes the application's PostgreSQL role is allowed to modify the schema it uses — which is the case on a fresh install, where the app role is created with 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).
Destructive changes and rollback. 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.
Pooled mode — one database per tenant. The 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

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
Adding an organization (pooled mode) is not an upgrade: provision a new tenant with its own database and host name (see Provisioning tenants); existing tenants are not disturbed.

Related: Product overview · Architecture · Deploy on Kubernetes · Security · Operations · Demo session