Deployment
How to run Countersign in production: every environment variable, Docker and docker compose, a reverse proxy in front, a Kubernetes sketch, a hardening checklist, sizing and upgrades. For the database see Storage, for sign-in Authentication, for running it day to day Operations.
Contents
- What a production installation consists of
- Environment variables
- Start-up checks
- Docker
- docker compose
- Behind a reverse proxy
- Kubernetes sketch
- Hardening checklist
- Sizing
- Upgrade procedure
What a production installation consists of
flowchart LR
accTitle: A production installation: reverse proxy, Countersign, PostgreSQL and the identity provider
people(["People"]) -- "HTTPS" --> proxy["Reverse proxy<br/>TLS, headers, rate limits"]
proxy -- "HTTP" --> app["Countersign<br/>1..n Node.js processes"]
app --> db[("PostgreSQL<br/>one table, append-only")]
app -- "OIDC, outbound HTTPS" --> idp["Identity provider"]
ops["Operations host<br/>command-line tool"] --> db
| Part | Notes |
|---|---|
| Countersign | A Next.js application on Node.js 22.18 or newer. Stateless apart from an in-memory copy of the state; run one or several. |
| PostgreSQL | The system of record. One database, one table. See Storage. |
| Identity provider | Microsoft Entra ID or another OpenID Connect provider; the server must reach its discovery, token and key endpoints over HTTPS. |
| Reverse proxy | Terminates TLS and adds security headers. Countersign itself speaks plain HTTP. |
| An operations host | Somewhere to run the command-line tool with database access: the same image works. |
Environment variables
All settings come from the environment. Values are trimmed. Nothing is read from files except the configuration document and the PostgreSQL schema.
Mode and storage
| Variable | Default | Meaning | Required when |
|---|---|---|---|
COUNTERSIGN_STORAGE |
unset | postgres, sqlite, file or memory. Unset means the demo: no server state, no sign-in. Setting it turns the server mode on. |
Always, in production |
COUNTERSIGN_DATABASE_URL |
value of DATABASE_URL, else empty |
PostgreSQL connection URI. | COUNTERSIGN_STORAGE=postgres |
COUNTERSIGN_DATA_FILE |
./data/countersign.sqlite (sqlite), ./data/countersign.jsonl (file) |
Path of the SQLite or JSONL file. | never (has a default) |
COUNTERSIGN_DB_POOL |
5 |
Maximum PostgreSQL connections per process. | never |
COUNTERSIGN_SKIP_MIGRATE |
unset | 1: do not run db/postgres.sql at start (the schema was created by hand and the application role has no DDL rights). |
The application role cannot create objects |
COUNTERSIGN_SCHEMA_FILE |
unset | Path of db/postgres.sql if it is not in its usual place. |
The schema file is not found |
COUNTERSIGN_CONFIG |
unset | A configuration file path, or preset:fleet, preset:property, preset:clinic. Written as the first entry only if the store is empty; ignored afterwards. |
Unless you run init yourself |
COUNTERSIGN_DEMO_DATA |
off | 1, true or yes: fill an empty store with the preset's demo history. Only with COUNTERSIGN_CONFIG=preset:<id>; with anything else the server reports a configuration problem and does not serve. Evaluation only. |
never |
Sign-in
| Variable | Default | Meaning | Required when |
|---|---|---|---|
COUNTERSIGN_AUTH |
entra |
entra, oidc, proxy or demo. |
never (has a default) |
COUNTERSIGN_ENTRA_TENANT_ID |
unset | Directory (tenant) id. The issuer becomes https://login.microsoftonline.com/<id>/v2.0. |
entra |
COUNTERSIGN_ENTRA_CLIENT_ID |
unset | Application (client) id. | entra |
COUNTERSIGN_ENTRA_CLIENT_SECRET |
unset | Client secret value. | entra |
COUNTERSIGN_OIDC_ISSUER |
unset | Issuer URL, exactly as the provider's ID tokens state it in iss (with its trailing slash, if it has one). |
oidc |
COUNTERSIGN_OIDC_CLIENT_ID |
unset | Client id. | oidc |
COUNTERSIGN_OIDC_CLIENT_SECRET |
unset | Client secret, sent in the token request body. Leave unset for a public client. | never |
COUNTERSIGN_OIDC_SCOPES |
openid profile email |
Scopes requested. Applies to entra too. |
never |
COUNTERSIGN_ID_CLAIM |
oid (entra), sub (oidc) |
Claim with the person's stable id. | never |
COUNTERSIGN_NAME_CLAIM |
name |
Claim with the display name. | never |
COUNTERSIGN_ROLE_CLAIM |
roles (entra), groups (oidc) |
Top-level claim with roles or groups. | never |
COUNTERSIGN_ROLE_MAP |
{} |
JSON object: identity-provider value → role id. Unmapped values are used as they are. Applies to proxy too. |
Provider values are not the role ids |
COUNTERSIGN_PROXY_SECRET |
unset | Shared secret the proxy sends in X-Countersign-Proxy-Secret; at least 16 characters. |
proxy |
COUNTERSIGN_PROXY_USER_HEADER |
x-forwarded-user |
Header with the person's id. | never |
COUNTERSIGN_PROXY_NAME_HEADER |
x-forwarded-preferred-username |
Header with the display name. | never |
COUNTERSIGN_PROXY_ROLES_HEADER |
x-forwarded-groups |
Header with roles, comma-separated. | never |
COUNTERSIGN_DEMO_USERS |
[] |
With demo sign-in: the people who can be picked, as JSON [{"id":"ann","name":"Ann Lee","roles":["qa"]}]. Replaces the preset's list; needed to use demo sign-in with a configuration of your own. Evaluation only. |
never |
Sessions, signatures and address
| Variable | Default | Meaning | Required when |
|---|---|---|---|
COUNTERSIGN_SESSION_SECRET |
unset | Key that signs session cookies; at least 32 characters. The same on every server. Changing it signs everyone out. | entra, oidc, demo |
COUNTERSIGN_SESSION_HOURS |
8 |
Lifetime of a session. | never |
COUNTERSIGN_SIGN_MAX_AGE_MINUTES |
15 (entra, oidc), 0 (proxy, demo) |
An approval needs a sign-in no older than this. 0 turns the check off. Only enforced with entra and oidc. |
never |
COUNTERSIGN_PUBLIC_URL |
unset | The address people use, such as https://records.example.com, without a trailing slash. Used for the OIDC redirect URI, the origin check and the cookie's Secure flag. |
Behind a proxy, in practice always |
Development and tests
| Variable | Meaning |
|---|---|
COUNTERSIGN_TEST_DATABASE_URL |
A PostgreSQL database the tests may use; they drop and create the table there. Never a production database. |
PORT |
The port Next.js listens on (next start), default 3000. Not a Countersign variable. |
Invalid values stop the server with a message, for example
COUNTERSIGN_STORAGE must be one of memory, file, sqlite, postgres (or unset for the demo).,
COUNTERSIGN_AUTH must be one of entra, oidc, proxy, demo.,
COUNTERSIGN_ROLE_MAP must be JSON such as {"SG-Fleet-QA":"qa"}.,
COUNTERSIGN_DEMO_USERS must be JSON such as [{"id":"ann","name":"Ann Lee","roles":["qa"]}]. or
COUNTERSIGN_SESSION_HOURS must be a number.
A minimal production environment
COUNTERSIGN_STORAGE=postgres
COUNTERSIGN_DATABASE_URL=postgres://countersign_app:<password>@db.internal:5432/countersign?sslmode=verify-full&sslrootcert=/etc/ssl/db-ca.pem
COUNTERSIGN_SKIP_MIGRATE=1
COUNTERSIGN_AUTH=entra
COUNTERSIGN_ENTRA_TENANT_ID=<tenant id>
COUNTERSIGN_ENTRA_CLIENT_ID=<client id>
COUNTERSIGN_ENTRA_CLIENT_SECRET=<client secret>
COUNTERSIGN_SESSION_SECRET=<openssl rand -base64 48>
COUNTERSIGN_PUBLIC_URL=https://records.example.com
The configuration is written once, before the first start, with init (see
Operations), so COUNTERSIGN_CONFIG is not needed here.
Start-up checks
In server mode the application refuses to serve until its settings are complete. Every endpoint then answers HTTP 503
and lists what is missing; /api/health is the place to look:
{
"status": "misconfigured",
"mode": "server",
"version": "1.0.0",
"problems": [
"COUNTERSIGN_DATABASE_URL is required for PostgreSQL.",
"COUNTERSIGN_SESSION_SECRET must be at least 32 characters (for example: openssl rand -base64 48).",
"COUNTERSIGN_ENTRA_TENANT_ID is required.",
"COUNTERSIGN_ENTRA_CLIENT_ID is required.",
"COUNTERSIGN_ENTRA_CLIENT_SECRET is required."
]
}
The store is opened on the first request, not when the process starts: a wrong database address shows as
"status": "store-unreachable" on /api/health, not as a crash. Check /api/health after every deployment; see
Operations.
Docker
The repository's Dockerfile builds the application (Next.js standalone output on node:22-alpine) and also
contains the command-line tool, db/postgres.sql, the examples and the documents. The container runs as the
unprivileged user countersign, listens on port 3000, has a writable /app/data for the SQLite and file stores, and
declares a health check on /api/health. Build and run:
docker build -t countersign:1.0.0 .
docker run -d --name countersign -p 3000:3000 --env-file ./production.env countersign:1.0.0
curl -s http://localhost:3000/api/health
The command-line tool in the same image, against the same store:
docker run --rm --env-file ./production.env -v "$PWD/config:/config:ro" countersign:1.0.0 \
node scripts/countersign.ts init --config /config/lab-equipment.json
docker run --rm --env-file ./production.env countersign:1.0.0 node scripts/countersign.ts status
Notes:
- Pin the image by version or digest and record it in your installation qualification (validation pack).
- With
COUNTERSIGN_STORAGE=sqliteorfile, mount a volume at the folder ofCOUNTERSIGN_DATA_FILE(by default/app/data), writable for the container's user; without one the trail is lost with the container. - Pass secrets with your platform's secret mechanism, not baked into the image.
- If the PostgreSQL schema file is not found inside the image, set
COUNTERSIGN_SCHEMA_FILEto its path or create the schema by hand and setCOUNTERSIGN_SKIP_MIGRATE=1.
(Docker was not available on the machine this page was written on; the commands above are the standard ones for the image and were not executed there.)
docker compose
docker compose up is meant for evaluation: it starts PostgreSQL 16 and the application with
COUNTERSIGN_STORAGE=postgres
COUNTERSIGN_AUTH=demo
COUNTERSIGN_CONFIG=preset:fleet
COUNTERSIGN_DEMO_DATA=1
that is, a shared database filled with the fleet demo history, and a sign-in page where you pick one of the demo
people. It also sets a fixed, published COUNTERSIGN_SESSION_SECRET and a database password of countersign, which
is one more reason not to use it as it is. Open http://localhost:3000. Everything about the server path can be
tried this way: several browsers as
different people, approvals, the audit trail in PostgreSQL (countersign_audit_trail), the CLI.
To turn it into a real installation:
- Take the variables from
.env.example, where the Entra and OIDC variables are listed as comments, fill them in, and generate your own session secret. - Set
COUNTERSIGN_AUTH=entra(or remove the line), removeCOUNTERSIGN_DEMO_DATA, and replaceCOUNTERSIGN_CONFIG=preset:fleetwith your own configuration file or runinityourself. - Start from an empty database. A store that was filled with demo data keeps it for ever; there is no way to delete entries.
- Give PostgreSQL a real password, a volume that is backed up, and the two roles of Storage.
- Put a TLS-terminating proxy in front.
Behind a reverse proxy
Countersign should not face the internet directly. The proxy terminates TLS; the application listens on HTTP in a private network.
What Countersign needs from the proxy
| Need | How |
|---|---|
| Its public address | Set COUNTERSIGN_PUBLIC_URL=https://records.example.com. With it set, forwarded headers are not consulted. |
Or, without COUNTERSIGN_PUBLIC_URL |
The proxy sends X-Forwarded-Proto and X-Forwarded-Host (the first value of each is used), else the Host header and the request's scheme. |
The public address is used for three things: the OIDC redirect URI (<public URL>/auth/callback), the origin check
on writes (a request whose Origin header differs is refused with The request comes from another site.), and the
Secure flag of the cookies (set when the address starts with https://).
Prefer COUNTERSIGN_PUBLIC_URL. If you rely on forwarded headers instead, the proxy must overwrite
X-Forwarded-Proto and X-Forwarded-Host on every request, because a client that could set them could influence the
origin check and the redirect URI.
nginx example
limit_req_zone $binary_remote_addr zone=cs_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=cs_write:10m rate=120r/m;
server {
listen 80;
server_name records.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name records.example.com;
ssl_certificate /etc/ssl/records.example.com.fullchain.pem;
ssl_certificate_key /etc/ssl/records.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "same-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
client_max_body_size 300k;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
location /auth/ {
limit_req zone=cs_auth burst=10 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location = /api/act {
limit_req zone=cs_write burst=30 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location = /api/health {
allow 10.0.0.0/8; # monitoring network
deny all;
proxy_pass http://127.0.0.1:3000;
}
location / {
proxy_pass http://127.0.0.1:3000;
}
}
Security headers
Countersign sets Cache-Control: no-store on its API and sign-in responses and the cookie attributes described in
Authentication. The Next.js application (next.config.ts) also sends
X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: same-origin on every response; the
plain API server (scripts/api-server.ts) does not. Setting them at the proxy as well does no harm and covers both.
The rest is the proxy's job:
| Header | Why | Set by the application |
|---|---|---|
Strict-Transport-Security |
Browsers never fall back to HTTP. | No |
X-Frame-Options: DENY and Content-Security-Policy: frame-ancestors 'none' |
The application cannot be framed by another site (clickjacking an approval). | X-Frame-Options only |
X-Content-Type-Options: nosniff |
No content-type guessing. | Yes |
Referrer-Policy: same-origin |
Addresses of records are not sent to other sites. | Yes |
A fuller Content-Security-Policy |
Worth adding; test it against the application first (Next.js uses inline scripts unless nonces are configured). Start in report-only mode. | No |
Rate limiting
Countersign has no rate limiting of its own. Limit at the proxy:
| Path | Why | Suggested starting point |
|---|---|---|
/auth/* |
Each sign-in start makes the server call the identity provider. | 10 per minute per address |
/api/act |
Writes. A person rarely exceeds one action in a few seconds. | 120 per minute per address |
/api/entries |
Clients poll every 15 seconds; the first load returns the whole trail. | Generous; watch response sizes |
Also limit the request body (the application refuses bodies over 256 KiB to /api/act itself) and restrict
/api/health and, if you wish, /api/bootstrap to where they are needed: both answer without sign-in.
/api/health reveals the entry count, the head hash and the configuration's name; /api/bootstrap the storage kind
and sign-in method.
Kubernetes sketch
A starting point, not a tested manifest. Two replicas work because sessions are signed cookies and concurrent writes are settled by the database (see Architecture).
apiVersion: v1
kind: Secret
metadata:
name: countersign
type: Opaque
stringData:
COUNTERSIGN_DATABASE_URL: postgres://countersign_app:<password>@postgres.db.svc:5432/countersign?sslmode=verify-full
COUNTERSIGN_ENTRA_CLIENT_SECRET: <client secret>
COUNTERSIGN_SESSION_SECRET: <at least 32 random characters, the same for all replicas>
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: countersign
spec:
replicas: 2
selector:
matchLabels: { app: countersign }
strategy:
type: RollingUpdate
rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
template:
metadata:
labels: { app: countersign }
spec:
securityContext:
runAsNonRoot: true
containers:
- name: countersign
image: registry.example.com/countersign:1.0.0
ports:
- containerPort: 3000
envFrom:
- secretRef: { name: countersign }
env:
- { name: COUNTERSIGN_STORAGE, value: postgres }
- { name: COUNTERSIGN_SKIP_MIGRATE, value: "1" }
- { name: COUNTERSIGN_AUTH, value: entra }
- { name: COUNTERSIGN_ENTRA_TENANT_ID, value: "<tenant id>" }
- { name: COUNTERSIGN_ENTRA_CLIENT_ID, value: "<client id>" }
- { name: COUNTERSIGN_PUBLIC_URL, value: "https://records.example.com" }
startupProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 5
failureThreshold: 60
readinessProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
httpGet: { path: /api/health, port: 3000 }
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 5
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 1Gi }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
---
apiVersion: v1
kind: Service
metadata:
name: countersign
spec:
selector: { app: countersign }
ports:
- port: 80
targetPort: 3000
Things to decide:
- Probes.
/api/healthanswers 200 when the store is reachable and the trail verifies, and 503 otherwise (misconfigured, store unreachable, integrity fault). As a readiness probe that is right: a replica that cannot reach the database gets no traffic. As a liveness probe it restarts replicas during a database outage and on an integrity fault, where a restart does not help; a restart loop then hides the real problem. Either give liveness a high failure threshold, as above, or use a TCP probe for liveness and keep the HTTP check for readiness and alerting. - Start-up time. The first health check triggers the reading and replay of the whole trail. That takes seconds
even for a long trail (see Sizing); the
startupProbeabove allows five minutes, which also covers a slow database. - Read-only root file system. Next.js may want a writable cache directory; mount an
emptyDirwhere your image needs one. - The CLI runs as a Job or with
kubectl execin a pod of the same image and Secret:kubectl exec deploy/countersign -- node scripts/countersign.ts status. - Ingress must provide what the reverse-proxy section lists.
- The schema is created once by a DBA or a Job that connects as the owner role; the Deployment uses the application
role with
COUNTERSIGN_SKIP_MIGRATE=1.
Hardening checklist
| # | Item | How to check |
|---|---|---|
| 1 | COUNTERSIGN_AUTH is entra, oidc or proxy. Demo sign-in is off. |
/api/health shows "auth"; POST /auth/demo answers 404 Demo sign-in is off. |
| 2 | COUNTERSIGN_DEMO_DATA and COUNTERSIGN_DEMO_USERS are unset and the store was initialised from your own configuration. |
status shows your configuration; entry 1 is your configure entry. |
| 3 | HTTPS only: the proxy redirects HTTP, sends HSTS, and COUNTERSIGN_PUBLIC_URL starts with https://. |
The session cookie carries Secure. |
| 4 | The application port is reachable from the proxy only. | Network policy or firewall; essential with proxy sign-in. |
| 5 | COUNTERSIGN_SESSION_SECRET is random, at least 32 characters, stored in a secret manager, identical on all servers and not reused between environments. |
|
| 6 | The identity provider's client secret is in the secret manager and its expiry is in the calendar. | |
| 7 | The database role of the application has INSERT and SELECT on countersign_entries only: no UPDATE, DELETE, TRUNCATE, no ownership. COUNTERSIGN_SKIP_MIGRATE=1. |
\dp countersign_entries shows countersign_app=ar/…; see Storage. |
| 8 | The owner role's password is not in the application's environment; superuser access to the database is restricted and logged. | |
| 9 | The database connection uses TLS with certificate verification. | Connection URI has sslmode=verify-full. |
| 10 | Sign-in requires assignment at the identity provider, so that only intended people can read. | An unassigned test account is turned away. |
| 11 | auth_time is delivered and re-authentication really prompts. |
See Authentication. |
| 12 | COUNTERSIGN_SIGN_MAX_AGE_MINUTES and COUNTERSIGN_SESSION_HOURS match your procedures. |
|
| 13 | Time synchronisation (NTP or the platform's equivalent) on every application server and the database server, monitored. | Entry times come from the application server's clock. |
| 14 | Security headers and rate limits at the proxy. | See above. |
| 15 | /api/health is monitored and alerts on anything but ok. |
Operations |
| 16 | The head hash is anchored outside the database on a schedule, and verify --expect-head runs regularly. |
Operations |
| 17 | Backups run, and a restore has been rehearsed and verified. | Operations |
| 18 | Shell access to hosts that can run the CLI with database credentials is limited to named operators. | The CLI takes the actor's name from the command line. |
| 19 | The container runs as a non-root user, from a pinned image; dependencies are scanned. | |
| 20 | Logs of the proxy and the platform are kept; the application's own log is not the audit trail. | Operations |
Sizing
What costs time and memory:
- Replay on start. Each server process reads the whole trail on its first request and replays it through the engine. Afterwards it reads only new entries.
- State in memory. Every process keeps the complete state: all entries, all records with all versions.
- The browser receives the whole trail on first load and replays it too.
- Each single command on a running server copies part of the state, so its cost grows slowly with the size of the store.
Replay works in place on an indexed copy of the state, and its time grows in proportion to the number of entries. Measurements on a development machine (Node 22, one core), with a synthetic fleet trail (create, confirm and approve of vehicles with unique plates):
| Entries | Records | Replay at start | Trail as JSON | Heap after replay |
|---|---|---|---|---|
| 290 (the fleet demo) | 104 | well under 0.1 s | 0.3 MB | – |
| 3,010 | about 1,000 | 0.23 s | 1.5 MB | 16 MB |
| 9,010 | about 3,000 | 0.72 s | 4.6 MB | 54 MB |
| 30,010 | about 10,000 | 2.0 s | 15.4 MB | 87 MB |
The maintainers measured 7.3 s for 100,000 entries (33,000 records) with the same kind of trail. While that 30,010-entry trail was being built, one command at a time, a command took 5 ms on average.
In practice:
- Server start is not a concern up to tens of thousands of entries: a few seconds at most, plus the time to read the entries from the store.
- Memory is what to plan for: every server process holds the whole state, roughly 3 MB of heap per thousand entries in the measurement above, and so does every open browser tab.
- The browser's first load downloads the whole trail and replays it. At 30,000 entries that is about 15 MB of JSON before compression; a slow connection or an old device will feel it long before the server does. Compress responses at the proxy.
- Beyond that range, measure with a copy of your own data, on the server and in the oldest browser you support.
The quickest server-side measurement is
time npm run cli -- verify, which does the same replay.
There are no snapshots and no archiving in version 1.0.0, so a trail only grows. Estimate entries as records × about three (create, confirm, approve) plus three per later change, plus option values.
Other sizing notes:
| Resource | Guidance |
|---|---|
| CPU | One core per process is enough; commands are handled one at a time per process. |
| Memory | 256 MB is plenty for small trails; allow about 3 MB of heap per thousand entries plus Next.js itself. |
| Database | Tiny: the fleet demo's 290 entries occupy 376 kB including indexes. Connections: processes × COUNTERSIGN_DB_POOL. |
| Replicas | Two for availability. More replicas do not make writes faster: concurrent writes are serialised by the database and retried. |
| Network | First load transfers the whole trail (compress at the proxy); afterwards clients poll /api/entries?after=N every 15 seconds. |
Upgrade procedure
Countersign's data format is the audit entry and the rule "replaying old entries gives the same result". An upgrade
must preserve both, so the procedure is built around verify.
Read the changelog (CHANGELOG.md) for the versions you skip: changed variables, a changed
db/postgres.sql, anything about replay compatibility.Assess the change under your change control and decide what to re-test (see the validation pack).
Back up the database and note
status(entry count, head hash).Rehearse on a copy. Restore the backup into a test database and run the new version's CLI against it:
npm run cli -- verify --expect-head <head hash from step 3>replay all N entries reproducemeans the new engine gives the same result for every entry of your history. If replay fails, stop: do not deploy that version on your data.Run the new version's tests (
npm test, ornpm run oq -- --out report.mdfor the record) in the test environment, and click through your own configuration there.Schema. If
db/postgres.sqlchanged and you create the schema by hand, run the new file as the owner role. Its statements are idempotent.Deploy the new version. A rolling update is possible when the release notes do not say otherwise; all versions that write must agree on the engine's rules, so do not leave mixed versions running longer than the roll-out.
Check:
/api/healthisokon every replica, with the entry count and head hash you expect;statusandverifyonce more from the operations host; sign in and open a record.Record the version, image digest, the
verifyoutput and the checks in your installation record.
Rolling back means deploying the previous version again. Entries written by the new version remain in the trail; if the old version cannot replay them it will report an integrity fault and refuse writes, so test the rollback in step 4 as well when the changelog announces engine changes.