On this page

Set it up · docs/deployment.md

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

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=sqlite or file, mount a volume at the folder of COUNTERSIGN_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_FILE to its path or create the schema by hand and set COUNTERSIGN_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:

  1. 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.
  2. Set COUNTERSIGN_AUTH=entra (or remove the line), remove COUNTERSIGN_DEMO_DATA, and replace COUNTERSIGN_CONFIG=preset:fleet with your own configuration file or run init yourself.
  3. Start from an empty database. A store that was filled with demo data keeps it for ever; there is no way to delete entries.
  4. Give PostgreSQL a real password, a volume that is backed up, and the two roles of Storage.
  5. 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/health answers 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 startupProbe above allows five minutes, which also covers a slow database.
  • Read-only root file system. Next.js may want a writable cache directory; mount an emptyDir where your image needs one.
  • The CLI runs as a Job or with kubectl exec in 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.

  1. Read the changelog (CHANGELOG.md) for the versions you skip: changed variables, a changed db/postgres.sql, anything about replay compatibility.

  2. Assess the change under your change control and decide what to re-test (see the validation pack).

  3. Back up the database and note status (entry count, head hash).

  4. 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 reproduce means 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.

  5. Run the new version's tests (npm test, or npm run oq -- --out report.md for the record) in the test environment, and click through your own configuration there.

  6. Schema. If db/postgres.sql changed and you create the schema by hand, run the new file as the owner role. Its statements are idempotent.

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

  8. Check: /api/health is ok on every replica, with the entry count and head hash you expect; status and verify once more from the operations host; sign in and open a record.

  9. Record the version, image digest, the verify output 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.