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.
Deploying a demo instance? Follow this same guide, with one deliberate addition: to have the instance come up pre-filled with a believable sample company (teams, staff, clients, a portfolio of projects with tasks, schedules and timesheets) instead of an empty organization, provision a dedicated demo tenant loaded with the demo dataset. The special values apply only to that demo tenant — a tenant marked "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:

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.

Control workstation SSH client + Node.js 22 TARGET CLUSTER · DEBIAN 13 HOSTS Traefik · controlplane TLS · routes by host name (443 · 80) web · api · worker · mcp 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.

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 routePowerShell twin
Wizard + artifact generationbash 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
Two Windows prerequisites, no bash. Node.js for generation (the generator is cross-platform) and an OpenSSH client (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

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

PromptWhat it asksDefault
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 emailContact 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 hostsHow many machines take part (HA).3
Base domainThe parent domain only (e.g. example.com) — not a full host name. Each organization is then reached at <slug>.<baseDomain> (so slug acme + example.comacme.example.com).
Database nameThe 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 redisWhich host runs the single Redis instance.host1
Hosts for garageWhich hosts run the S3 store (≥2 for S3 HA).all
keepalived VIPA floating IP shared by the cp hosts; blank falls back to DNS round-robin.blank
slugsTenant 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 strictlyStrict 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
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.
4ImagesBy 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.
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, vaks-pm-audit-archive, vaks-pm-exports
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. 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.

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.
4ImagesBy 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.
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:

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

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.

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.

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)

Requirement. Let's Encrypt only works if every tenant hostname resolves publicly to this cluster and ports 80/443 are reachable from the internet (the TLS-ALPN-01 challenge dials the hostname back). An internal or LAN-only hostname cannot be validated — use Option B instead.

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
The private key file under 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)
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, 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.

At a glance. (1) fetch the new images from the public registry; (2) on a populated database, back up first, then rolling-update 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
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. :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.

On a populated database, back up first. The schema reconciles automatically, but 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
Immutable Traefik config. If traefik-dynamic.gen.yml changed, bump its version suffix in the stack file (e.g. traefik_dynamic_v2v3) 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
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