Deploy on Docker Swarm
"seed": "demo" in the manifest, plus its known demo login — and never to your real organizations. The exact values to enter for each tenancy mode (single, dedicated, pooled, HA) are in the demo guide. Do not seed a demo dataset into a production organization.
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: by default the container images are pulled from the public registry at deploy time (or, in build mode, built on a target host — never locally). The wizard asks Images (ghcr|build); ghcr is the default.
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, the mcp AI connector, 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. By default the container images are pulled from the public registry (
ghcr.io/vaks-fr/vakspm-*) by each host at deploy time; in build mode they are built on a target host instead. Either way the control workstation needs no Docker. - A copy of the project repository —
git clone https://github.com/vaks-fr/Vaks-Pm.git(or the forge's "Download ZIP" button). 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).
From a Windows control workstation
The install scripts come in two equivalent forms: the .sh ones (POSIX shell) and their native PowerShell twins. On a Windows workstation — including a bare Windows Server with no Git Bash and no WSL — the PowerShell route depends on no bash at all. The scripts that run on the hosts stay in bash, but they run on the Debian hosts, never on the control workstation.
| Step | .sh route | PowerShell twin |
|---|---|---|
| Wizard + artifact generation | bash setup.sh | .\setup.ps1 |
| Deploy orchestrator (all over SSH) | bash out/deploy-ssh.sh | .\out\deploy.ps1 |
The generator (infra/cluster/generate.mjs, plain Node.js) emits both orchestrators side by side in out/ — the .sh route is left strictly unchanged, the PowerShell route is additive. Full sequence from PowerShell:
.\setup.ps1 # wizard: same questions -> writes the manifest -> generates out\ (incl. out\deploy.ps1)
.\out\deploy.ps1 # native orchestrator: drives the whole bring-up over SSH
# or in one command:
.\setup.ps1 -Deploy
ssh.exe / scp.exe) for deployment — either the built-in Windows "OpenSSH Client" feature or a Win32-OpenSSH MSI/exe installer, your choice. The same .env.local (SSH access + DB/JWT secrets in HA) feeds both routes. Secret and self-signed-certificate generation is done natively (.NET crypto, or openssl offloaded to a host) — nothing else to install.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, either drop your own certificate at
infra/certs/wildcard.{crt,key}before deploying, or enable Let's Encrypt: settls.acme.emailin the manifest (the wizard also asks for a Let's Encrypt email), and Traefik issues a real, browser-trusted certificate for the tenant domain(s) via the TLS-ALPN-01 challenge. Let's Encrypt requires the tenant host name to resolve publicly to the cluster and ports 80/443 reachable from the internet. Traefik is automatically pinned to a single node in this mode (theacme.jsonstore is single-writer).
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 image source (ghcr pull or build), the hosts and their roles, the root domain, the tenants, the at-rest encryption, and optionally a Let's Encrypt email for a trusted certificate, 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
Before it prompts, setup.sh runs a preflight check on .env.local and lists exactly what is missing (the SSH access, and in HA the database/JWT secrets) rather than failing cryptically later. It then asks, in order:
| Prompt | What it asks | Default |
|---|---|---|
Mode (single|ha) | One machine, or a multi-host high-availability cluster. | ha |
Images (ghcr|build) | Pull the pre-built public images, or build them from source on the hosts (air-gapped). | ghcr |
| Let's Encrypt email | Contact address for the ACME account — Let's Encrypt uses it only for certificate expiry/renewal notices (not verified). Blank keeps the self-signed certificate. | blank |
| Number of hosts | How many machines take part (HA). | 3 |
| Base domain | The parent domain only (e.g. example.com) — not a full host name. Each organization is then reached at <slug>.<baseDomain> (so slug acme + example.com → acme.example.com). | — |
| Database name | The PostgreSQL database/cluster name. | vakspm |
| name (per host) | The server's real hostname (uname -n) — used for Swarm labels, Patroni and etcd. | host1…N |
| ip (per host) | The host's IP address (validated). | — |
| roles (per host) | Comma-separated: manager,db,etcd,cp,app (see the role table below). | per host |
| Host for redis | Which host runs the single Redis instance. | host1 |
| Hosts for garage | Which hosts run the S3 store (≥2 for S3 HA). | all |
| keepalived VIP | A floating IP shared by the cp hosts; blank falls back to DNS round-robin. | blank |
| slugs | Tenant identifiers to provision now (comma-separated), or blank for none. | blank |
| Encrypt data volumes (LUKS/Tang) | At-rest disk encryption of the database and object-store volumes. | yes |
| Verify DB TLS strictly | Strict app↔PostgreSQL certificate verification against an internal CA. | no |
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 | By default the images are pulled from the public registry (ghcr.io/vaks-fr/vakspm-*). In build mode (wizard Images (ghcr|build) prompt) they are built on the machine instead. |
| 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, vaks-pm-audit-archive, vaks-pm-exports
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. The simplest path is to declare a tenant slug at the wizard (the slugs prompt) — step 7 then provisions it automatically and prints the admin password, and you can skip this section entirely.
To do it by hand, mind where each command runs: the template org-bootstrap.example.json lives in the repository (the infra/ folder, on your control workstation), while the docker commands need the api container and therefore run on the target host. So you edit the file on the workstation, copy it to the host, then run the bootstrap there:
# 1) On the control workstation (repository root): copy the template and edit it
cp infra/org-bootstrap.example.json infra/org-bootstrap.json
# edit inside the file — REQUIRED:
# organization.id stable key for the org (slug-like, e.g. acme; usually = slug)
# organization.slug subdomain → <slug>.<baseDomain> ([a-z0-9-])
# organization.name display name
# admin.email first admin's login email
# optional:
# organization.primaryDomain custom domain (e.g. pm.acme.com), or null
# organization.timezone default Europe/Paris
# admin.name default "Org Admin"
# leave admin.password = null → generated randomly, shown ONCE below.
# 2) Copy it to the target host, then bootstrap inside the api container
scp -i <ssh-key> infra/org-bootstrap.json <user>@<host>:~/
ssh -i <ssh-key> <user>@<host>
API=$(sudo docker ps -q -f name=vaks-pm_api | head -1)
sudo docker cp ~/org-bootstrap.json "$API":/app/api/org-bootstrap.json
sudo docker exec "$API" sh -c 'export DATABASE_URL=$(cat /run/secrets/database_url) \
&& cd /app/api && node prisma/bootstrap-org.js'
If you ran the fully-manual bring-up directly on the target host, the repository is already there — skip the scp/ssh and run the docker commands locally with infra/org-bootstrap.json.
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 | By default the api, web, worker images are pulled from the public registry (ghcr.io/vaks-fr/vakspm-*) — each node pulls at deploy time. In build mode (wizard Images (ghcr|build) prompt, for air-gapped installs) they 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:
# Run on the target host (single) or an "app" host (HA), where Docker runs:
API=$(sudo docker ps -q -f name=vaks-pm_api | head -1) # id of the running api container
sudo docker exec "$API" sh -c \
'export DATABASE_URL=$(cat /run/secrets/database_url) && \
cd /app/api && node prisma/provision-tenant.js <SLUG> <ADMIN-EMAIL>'
Replace the two arguments before running:
<SLUG>— the organization's short url-safe id ([a-z0-9-]+, e.g.acme). It becomes the subdomain: sign-in is at<slug>.<baseDomain>.<ADMIN-EMAIL>— the first administrator's login email (e.g.admin@acme.com). Its password is generated and printed once in the output; a change is forced at first login.
Provisioning creates the tenant database (<dbName>_<slug>), applies the schema and bootstraps that admin. 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.Rotating the public TLS certificate
A fresh install serves the browser-facing site with a self-signed certificate, which is fine for a test or LAN but makes browsers show a warning. This is the certificate Traefik presents at the edge; the install stores it as two immutable Swarm configs, tls_cert_v1 and tls_key_v1. There are two ways to move to a trusted certificate after the instance is already running, and which one applies depends on whether the hostname is reachable from the public internet.
Option A — switch to Let's Encrypt (automatic, renewing)
Enable the ACME resolver by adding the contact email to the manifest, then regenerate and redeploy. The generator wires the resolver, the acme.json store and, on a multi-node cluster, pins Traefik to a single node (the store is single-writer):
// infra/cluster.json
"tls": { "acme": { "email": "ops@example.com" } },
bash setup.sh --manifest infra/cluster.json # regenerate the stack with ACME
cd <deploy-dir> && docker stack deploy --with-registry-auth -c docker-stack.gen.yml vaks-pm
Traefik then obtains a browser-trusted certificate for the tenant domain(s) and renews it automatically. On a single host there is no node-pinning to worry about.
Option B — supply your own certificate (internal CA or purchased)
Use this when the hostname is not public, or when the organisation issues certificates from its own CA. Because the certificate lives in immutable Swarm configs, you cannot overwrite tls_cert_v1 in place — a redeploy that reuses the same config name with different bytes is silently ignored. Bump the version suffix instead, exactly as for traefik_dynamic and pg_ca.
1. Put the real certificate next to the stack file. Replace infra/certs/wildcard.crt and infra/certs/wildcard.key with your leaf certificate (plus any intermediate chain) and its unencrypted private key. On an orchestrated deploy these go into your local infra/certs/ (the deploy script copies that folder to the manager and wipes the manager-side copy each run); on a hand-edited stack, place them in infra/certs/ next to the stack file on the manager.
2. Bump the config version in the stack file (docker-stack.yml on a single host, docker-stack.gen.yml on HA). Rename in the top-level configs: block and in the Traefik service's config mounts:
configs:
tls_cert_v2: # was tls_cert_v1
file: ./infra/certs/wildcard.crt
tls_key_v2: # was tls_key_v1
file: ./infra/certs/wildcard.key
# ...in the traefik service:
configs:
- source: tls_cert_v2 # was tls_cert_v1
target: /certs/wildcard.crt
- source: tls_key_v2 # was tls_key_v1
target: /certs/wildcard.key
mode: 0400
3. Redeploy and clean up. Traefik rolls onto the new certificate; then remove the old, now-unreferenced configs:
cd <deploy-dir> && docker stack deploy --with-registry-auth -c <stack-file> vaks-pm
docker config rm vaks-pm_tls_cert_v1 vaks-pm_tls_key_v1 # once nothing references them
infra/certs/ is only read at stack deploy time — afterwards Traefik serves the copy Swarm holds in its Raft store. Keep the key at mode 600 and the folder at 700; the on-disk staging copy is otherwise world-readable.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. Swarm itself does not currently probe them forapi/web(noHEALTHCHECKis declared indocker-stack.yml— only themcpservice has one); use them for external monitoring and the manual curl checks above, not as an automatic rollout gate. - 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, and has no migration command to run — the database schema reconciles itself at api start-up. 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.
api, controlplane, worker, web; (3) regenerate & redeploy the stack — only if the release notes explicitly call for it; (4) verify health.1 · Get the new images
Images are published publicly on GitHub Container Registry, no credentials required:
ghcr.io/vaks-fr/vakspm-api
ghcr.io/vaks-fr/vakspm-web
ghcr.io/vaks-fr/vakspm-worker
ghcr.io/vaks-fr/vakspm-mcp
Servers with outbound Internet access — on every node of the cluster:
TAG=1.4.0 # the version being rolled out
docker pull ghcr.io/vaks-fr/vakspm-api:$TAG
docker pull ghcr.io/vaks-fr/vakspm-web:$TAG
docker pull ghcr.io/vaks-fr/vakspm-worker:$TAG
docker pull ghcr.io/vaks-fr/vakspm-mcp:$TAG
Air-gapped environment (the servers have no outbound Internet) — on a third-party machine that has Internet access (not necessarily a cluster node):
TAG=1.4.0
docker pull ghcr.io/vaks-fr/vakspm-api:$TAG
docker pull ghcr.io/vaks-fr/vakspm-web:$TAG
docker pull ghcr.io/vaks-fr/vakspm-worker:$TAG
docker pull ghcr.io/vaks-fr/vakspm-mcp:$TAG
docker save ghcr.io/vaks-fr/vakspm-api:$TAG \
ghcr.io/vaks-fr/vakspm-web:$TAG \
ghcr.io/vaks-fr/vakspm-worker:$TAG \
ghcr.io/vaks-fr/vakspm-mcp:$TAG \
-o vaks-pm-$TAG.tar
Carry the vaks-pm-$TAG.tar file (USB key or whatever transfer method is available) to every node of the cluster, then load the images there:
docker load -i vaks-pm-$TAG.tar
:latest. Under containerd, one moving tag (:latest) can resolve to different layers on different nodes. Delivering and deploying by unique version tag (e.g. :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 — its start-up is what reconciles the database schema, no separate migration command is needed:
TAG=1.4.0 # the version being rolled out
docker service update --force --image ghcr.io/vaks-fr/vakspm-api:$TAG vaks-pm_api
docker service update --force --image ghcr.io/vaks-fr/vakspm-api:$TAG vaks-pm_controlplane
docker service update --force --image ghcr.io/vaks-fr/vakspm-worker:$TAG vaks-pm_worker
docker service update --force --image ghcr.io/vaks-fr/vakspm-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. Because api/web/worker have no Swarm-level HEALTHCHECK today (unlike mcp), the rollout does not automatically pause on a failing /health/ready — run the verification curls from step 4 after each service update before moving to the next. In pooled mode, the per-tenant dedicated cells (vaks-pm_api-<slug> / vaks-pm_worker-<slug>) update the same way, one at a time.
prisma db push is forward-only: there is no reverse migration, and some type or constraint changes can drop data. 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. See Operations for the backup tiers.3 · Configuration or stack changes exception
The large majority of upgrades stop at step 2 — nothing more to do. This step applies only if the release notes explicitly call out a change to docker-stack.gen.yml or traefik-dynamic.gen.yml (for example a new service, a newly required secret, or a new routing rule). In that case, regenerate then redeploy:
bash setup.sh --manifest infra/cluster.json
docker stack deploy --with-registry-auth -c out/docker-stack.gen.yml vaks-pm
traefik-dynamic.gen.yml changed, bump its version suffix in the stack file (e.g. traefik_dynamic_v2 → v3) before redeploying — Swarm configs are immutable, a new version under the same name is ignored.If a new secret is required (rare, explicitly called out in the release notes):
openssl rand -hex 32 | docker secret create <secret-name> -
# then reference this secret in docker-stack.gen.yml before the docker stack deploy above
4 · 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).