← 11. Personal data · Contents · Next → 13. Developing
12. Operating¶
Everything needed to keep wardn running: deploying it, watching it, backing it up, restoring it, and the complete list of knobs.
Deploying¶
The local stack is Docker Compose. Production replaces four things with managed equivalents and changes nothing in the code:
| Component | Local | Production |
|---|---|---|
| Broker | EMQX container | AWS IoT Core, or any MQTT broker with shared subscriptions |
| Object storage | MinIO | S3 |
| Database | PostgreSQL container | Managed PostgreSQL |
| Identity | Keycloak container | Keycloak, or any OIDC provider |
A worked example of the right-hand column, on AWS with a Helm chart and Terraform, is 14. Deploying to production.
The database socle¶
db-init is the single source of truth for the schema, the roles, the grants, the partitions and the seed. It is idempotent and runs on every up.
make up # migrations, partitions, seed
make verify # assert the schema, the grants and the seed
make seed # re-run migrations and seed
make reseed # re-run the seed even if already applied
Set SEED_ENABLED=false for an empty database. PARTITION_MONTHS_BACK and PARTITION_MONTHS_AHEAD control how many monthly partitions are created up front; the retention sweep keeps two months ahead from then on.
Running more than one backend¶
The backend is stateless and leaderless.
make scale # a second instance on port 3001, and the proof that it works
Two rules:
- Each instance needs its own
INSTANCE_ID, stable across restarts. Two MQTT clients sharing an id kick each other off the broker. In Kubernetes this is the StatefulSet ordinal. - Stable matters as much as unique. With
INSTANCE_IDset, the broker keeps a persistent session and queues events for an instance that is restarting. Without it, the backend takes a clean session, says so in its log, and accepts that a restart misses whatever arrives while it is down.
Everything else coordinates by itself: MQTT shared subscriptions spread events, the rights queue is drained concurrently with leases, and the scheduled sweeps take a PostgreSQL advisory lock.
Certificates¶
tls-init issues a development CA and one identity per party on first start. In production the authority is the manufacturing one, and a cabinet's key never leaves its secure element. See 10. Security.
make tls-check # how long every certificate in the stack has left
| Exit code | Meaning |
|---|---|
0 | Nothing lapses within TLS_EXPIRY_WARNING_DAYS |
1 | Something lapses inside the window, or already has |
2 | The check could not be made (no openssl) |
The 2 exists precisely so that an inability to check does not masquerade as a certificate alarm.
Watching day to day¶
| Signal | Where to read it |
|---|---|
| A cabinet is offline | Fleet screen, or the controller_silent alert |
| A queue is rising | pendingEvents in telemetry, queue_saturated alert |
| Passages were lost | droppedEvents non-zero, events_dropped alert |
| A clock is wrong | clockTrusted: false, clock_untrusted alert |
| A reader stopped answering | readersDown, readerBusStopped, reader_error alert |
| Rights are not landing | sync_failed, rights_drift alerts; appliedRevision against rightsRevision |
| A certificate is dying | certificate_expiring alert, and make tls-check |
| Application errors | Sentry, when SENTRY_DSN is set |
| A specific request | The X-Trace-Id on its response |
| A cabinet, from the site's own monitoring | http://<cabinet>:9090/metrics |
| The backend is up | GET /health, and which instance answered |
| The database answers | GET /health/ready, or 503 when it does not |
Alerts are read through the API:
curl -H "Authorization: Bearer $TOKEN" 'http://localhost:3000/api/v1/alerts?limit=50'
The eight kinds and what raises them are in 7. The fleet.
Backups¶
make backup # database, artefacts, erasure ledger
make backup-verify BACKUP=./backups/<stamp> # restore it elsewhere to prove it
make restore BACKUP=./backups/<stamp> CONFIRM=yes
flowchart TD
B["make backup"] --> D["pg_dump, custom format"]
B --> L["Erasure ledger<br/>written BESIDE the dump"]
B --> O["Mirror the object storage<br/>(firmware, documents)"]
D --> M["MANIFEST: what this can<br/>and cannot restore"]
L --> M
O --> M
M --> ENC{"Off-site<br/>configured?"}
ENC -->|"yes, with a passphrase"| E["Encrypt, upload,<br/>prune the remote"]
ENC -->|"yes, no passphrase"| R["REFUSE: nothing<br/>leaves in the clear"]
ENC -->|"no"| K["Stays on this machine"] What is backed up¶
| Why | |
|---|---|
| PostgreSQL | Rights, passages, the audit trail. The only copy |
| Object storage | Firmware artefacts and documents. Their digests are in the database, so a database restored without them points at files that do not exist |
| The erasure ledger | The list of people erased, written beside the dump. It is what makes this more than a pg_dump |
What is not backed up¶
- The cabinets' local databases: derived from the cloud, no history. They rebuild themselves on the next connect.
- EMQX state: sessions and retained messages rebuild themselves.
- The development certificate authority: production uses the manufacturing PKI.
- ⚠️ Keycloak accounts created at runtime. The realm is imported from this repository, so what a backup would add is whatever was created afterwards. That is a real gap, stated rather than hidden.
The recovery point¶
One pg_dump a day. Up to 24 hours of passages can disappear.
That is not theoretical. The cabinet is built never to lose a passage locally. But once an event is acknowledged and cleared from its queue, last night's dump holds the only copy.
It is the accepted cost of not operating a write-ahead log archive. If that cost becomes unacceptable, point-in-time recovery is the way out, and it is an operations project rather than a script change.
A backup that has never been restored is not a backup¶
make backup-verify BACKUP=./backups/<stamp>
This is the step everyone skips, and the reason backups are found to be worthless on the one day they are needed.
It restores into a throwaway database beside the real one, never over it, then asserts three things a corrupt or truncated dump cannot satisfy: the schema comes back whole, checked by the same verify.sql the socle uses; the tables carrying the business are not empty; and the audit trail came back with them, since a restore that loses the trail restores the data without the accountability.
Off site¶
A backup on the same machine as the database is not a backup: the fire, the theft and the failed disk take both.
BACKUP_REMOTE_ENDPOINT=https://s3.eu-west-3.amazonaws.com
BACKUP_REMOTE_BUCKET=wardn-backups
BACKUP_REMOTE_ACCESS_KEY=…
BACKUP_REMOTE_SECRET_KEY=…
BACKUP_ENCRYPTION_PASSPHRASE=…
Encrypted before it leaves, never after. With a destination configured and no passphrase, make backup refuses, rather than sending names, credentials and the history of every passage through a site to somebody else's disk in the clear because a variable was forgotten.
⚠️ A lost passphrase makes the backup as useless as its absence. It belongs neither in this repository nor in the remote store.
The SHA-256 of the content before encryption stays on site, where the remote store never saw it. Comparing a copy that comes back against that value proves what returns is what left. The cipher alone does not prove that.
The same run prunes the remote store beyond BACKUP_KEEP_DAYS. Retention off site is not the local find: without this, a 30-day commitment would hold everywhere except the one place the data had been copied to.
Backups compose with retention rather than coinciding with it. The newest backup already holds logs 30 days old, so by the time it is deleted at 30 days it has been holding data up to 60 days old. That belongs in a register of processing activities.
Restoring¶
make restore BACKUP=./backups/<stamp> CONFIRM=yes
Destructive, and deliberately hard to run by accident: without CONFIRM=yes it refuses and prints the command it wanted.
It stops the backend, restores the dump, re-runs db-init (which is the source of truth for the schema, the roles and the append-only grants, and is idempotent), then starts the backend again.
The two steps that are not automatic¶
The script prints them instead of doing them, because neither is safe to automate.
flowchart TD
R["Restore finished"] --> S1["1. Replay the erasures<br/>recorded after this backup"]
R --> S2["2. Run the retention sweep"]
S1 --> A["Collect the ids from every<br/>LATER backup's ledger"]
A --> B["Re-issue each erasure"]
B --> C{"Response?"}
C -->|"404"| D["Already erased,<br/>nothing to do"]
C -->|"2xx"| E["That person HAD come back.<br/>Count them: this number<br/>cannot be recovered later"]
S2 --> F["Data purged after the backup<br/>is back, and out of policy<br/>until the next sweep"] 1. Replay the erasures. Restoring rolls back later erasures and the record that they happened. The ledgers written beside the following backups are the only things that know who must stay erased. The script prints the list of ledger files with the real paths filled in:
cut -d, -f2 backups/<later>/erasures.csv … | sort -u
Then, for each id, re-issue the erasure and read the answer:
404: already erased, nothing to do;2xx: that person had come back, and has just been erased again.
That count is what goes into the incident record and to the data protection officer. After this step it is no longer recoverable from anywhere.
If no later ledger is reachable, stop: nothing in the world knows who had to be erased.
2. Run the retention sweep. A backup taken before a purge restores the data the purge removed, putting the system back outside its own announced window until the next sweep.
Recovering a backup that went off site¶
The scenario off-site backup exists for is the one where nothing local is available, so the command cannot live only in the script that disappeared with the machine. It is copied into the manifest inside the archive:
mc alias set offsite "$BACKUP_REMOTE_ENDPOINT" "$BACKUP_REMOTE_ACCESS_KEY" "$BACKUP_REMOTE_SECRET_KEY"
mc cp "offsite/$BACKUP_REMOTE_BUCKET/<stamp>.tar.enc" .
openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \
-in <stamp>.tar.enc -out <stamp>.tar \
-pass env:BACKUP_ENCRYPTION_PASSPHRASE
mkdir -p backups/<stamp> && tar -C backups/<stamp> -xf <stamp>.tar
make backup-verify BACKUP=backups/<stamp>
A wrong passphrase fails loudly (bad decrypt); it never returns a half-correct file.
Commissioning a cabinet¶
- Create the zone, then the
edge_controllers,doorsandidentification_devicesrows. Today this is SQL or the seed. There is no write API for hardware. - Issue the cabinet an identity whose common name is its
device_identifier. The broker turns that name into the topic subtree it may reach. - Set
DEVICE_IDto the same value, pointMQTT_HOST/MQTT_PORTat the broker, and give it the three TLS paths. - Give it a
BOOTSTRAP_RIGHTS_PATHdescribing its wiring (which zone it serves, its doors, its readers) plus a fallback set of rights. The file is read only when the local store is empty; the cloud replaces the rights on the first full sync. - Start it. On its first connect it asks for its whole rights set.
Check it landed:
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/devices/fleet
status: online, an appliedRevision matching rightsRevision, and an accessRightsHash the cloud agrees with.
Common situations¶
| Symptom | What to look at |
|---|---|
| A cabinet is grey on the fleet screen | It has missed three heartbeats. Power, uplink or a crash all look the same from here. controller_silent was raised |
appliedRevision lags rightsRevision | Deltas queued or in flight. sync_failed says if delivery gave up; POST /devices/{id}/actions/full-resync forces the remedy |
rights_drift keeps being raised | The cabinet has been sent its whole set three times and still disagrees. That is far more likely a fault in the fingerprint comparison than a cabinet losing the same rights every time. The nightly pass still covers it meanwhile |
pendingEvents climbing | The uplink is down, or the broker is refusing. Passages are safe until the queue reaches PENDING_EVENTS_MAX |
droppedEvents non-zero | The record for that cabinet has a hole. The number is cumulative since commissioning |
clockTrusted: false | The cabinet's wall clock is behind an instant it already lived through. Its decisions and its timestamps cannot be trusted until real time catches up |
| A door behaves oddly | Check its mode. A lane left in unlocked after roadworks opens for everybody, and still logs every read |
reboot_os is refused | The controller runs in a container. It refuses rather than degrading into a service restart |
| The dashboard cannot reach the API | CORS_ORIGINS and WARDN_API_URL must both name the address the browser dials |
| Retention never runs | MAINTENANCE_DATABASE_URL is unset. The backend says so once at startup and keeps serving |
Configuration reference¶
Everything is an environment variable with a documented default. make env creates your .env from .env.example.
Backend¶
| Variable | Default | Effect |
|---|---|---|
PORT | 3000 | HTTP port |
DATABASE_URL | required | The application role's connection |
DATABASE_POOL_MAX | 10 | Connections per instance |
DATABASE_CONNECTION_TIMEOUT_MS | 5000 | How long a request waits for a free connection |
DATABASE_IDLE_TIMEOUT_MS | 10000 | How long an unused connection is held |
DATABASE_STATEMENT_TIMEOUT_MS | 15000 | Enforced by PostgreSQL, so it releases the connection even when the client stopped waiting |
MAINTENANCE_DATABASE_URL | (empty) | Retention and erasure only. Empty means those two are unavailable |
MQTT_URL | required | mqtt:// or mqtts:// |
MQTT_SHARED_GROUP | wardn-backend | Every instance joins the same group |
MQTT_USERNAME | wardn-backend | Must match the certificate's common name on the mTLS listener |
MQTT_TLS_CA · _CERT · _KEY | (empty) | Read only when MQTT_URL is mqtts://. Missing one is fatal |
INSTANCE_ID | wardn-backend-<hostname> | Stable and unique per instance. Setting it deliberately enables a persistent session |
KEYCLOAK_ISSUER_URL | required | Exactly as it appears in tokens |
KEYCLOAK_JWKS_URL | derived from the issuer | Where this process can reach the realm, which may differ |
CORS_ORIGINS | http://localhost:4200 | Listed, never reflected |
LOG_LEVEL | info | |
API_DEFAULT_PAGE_SIZE | 50 | |
API_MAX_PAGE_SIZE | 500 | Ceiling on limit |
DEFAULT_ZONE_TIMEZONE | Europe/Brussels | The timezone a zone created without one gets |
RATE_LIMIT_WINDOW_MS | 60000 | |
RATE_LIMIT_MAX | 600 | Requests per window, applied before authentication |
WS_HANDSHAKE_LIMIT | 60 | Live-socket handshakes per address per window |
REPLAY_THRESHOLD_MS | 30000 | Lateness past which a passage is recorded as held |
DEVICE_HEARTBEAT_INTERVAL_S | 10 | The cadence the cloud measures silence against |
DEVICE_OFFLINE_AFTER_MISSED | 3 | Missed heartbeats before a cabinet is called offline |
DEVICE_OFFLINE_SWEEP_MS | 5000 | How often the sweep looks |
DEVICE_DEBUG_MAX_DURATION_S | 900 | Ceiling on a debug stream, enforced here and again on the cabinet |
ALERT_REPEAT_MINUTES | 60 | Silence after a condition has been raised once |
TLS_EXPIRY_WARNING_DAYS | 30 | Notice before a cabinet's certificate lapses |
SYNC_TICK_MS | 1000 | How often the rights queue is drained |
SYNC_TASK_BATCH | 20 | Deltas claimed per tick |
SYNC_TASK_LEASE_S | 30 | How long a claim is held before another instance may take it |
SYNC_TASK_MAX_ATTEMPTS | 10 | Attempts before giving up and alerting |
RIGHTS_FULL_SYNC_LOCAL_HOUR | 3 | Nightly full sync, in each zone's own timezone |
RIGHTS_FULL_SYNC_JITTER_MINUTES | 60 | Window the fleet is spread over |
RIGHTS_FULL_SYNC_TICK_MS | 60000 | How often the nightly schedule is examined |
RIGHTS_VERIFY_INTERVAL_S | 7200 | How often fingerprints are compared. 0 disables the check |
RIGHTS_MAX_FINGERPRINT_MISMATCHES | 3 | Disagreements before alerting instead of resynchronising |
RETENTION_SWEEP_MS | 21600000 | Six hours |
ACTIVITY_LOG_RETENTION_DAYS | 30 | |
SYSTEM_LOG_RETENTION_DAYS | 30 | |
AUDIT_LOG_RETENTION_DAYS | 365 | |
ACCESS_RIGHT_RETENTION_DAYS | 30 | How long a revoked right is kept |
SYNC_TASK_RETENTION_DAYS | 7 | How long an acknowledged sync task is kept |
S3_ENDPOINT | http://minio:9000 | Signed into the links handed to cabinets, so it must be an address a cabinet can dial |
S3_REGION | us-east-1 | |
S3_ACCESS_KEY · S3_SECRET_KEY | (empty) | Scoped credentials; never the root account |
S3_OTA_BUCKET | wardn-ota | |
OTA_LINK_VALIDITY_S | 900 | How long a presigned firmware link lives |
SENTRY_DSN | (empty) | Empty means no error reporting |
SENTRY_ENVIRONMENT | development | |
OTEL_EXPORTER_OTLP_ENDPOINT | (empty) | Empty means spans are created but not exported |
OTEL_TRACES_CONSOLE | false | Print spans where the logs are |
Cabinet¶
| Variable | Default | Effect |
|---|---|---|
DEVICE_ID | required | The name it publishes under, and the common name of its certificate |
SQLITE_PATH | /data/edge.db | |
SQLITE_ENCRYPTION_KEY_SOURCE | secure_element | env · none · secure_element (⚠️ not implemented: the process refuses to start) |
SQLITE_ENCRYPTION_KEY | (none) | Required when the source is env. No default, ever |
BOOTSTRAP_RIGHTS_PATH | (none) | Wiring and fallback rights, read only when the store is empty |
READER_BUS | simulated | simulated (text over TCP) or osdp (RS-485) |
READER_LISTEN | 0.0.0.0:9000 | Where the simulated bus listens |
OSDP_SERIAL_PORT | /dev/ttyV0 | |
OSDP_BAUD_RATE | 9600 | |
OSDP_ADDRESSES | 1 | Which peripheral addresses to poll |
OSDP_POLL_INTERVAL_MS | 100 | |
METRICS_LISTEN | 0.0.0.0:9090 | Prometheus endpoint, on the local network |
LOG_LEVEL | info | Changeable at runtime from the cloud |
DECISION_TIMEOUT_MS | 300 | The budget a decision is measured against |
RELAY_PULSE_MS | 2000 | Default pulse; a door may carry its own |
PENDING_EVENTS_MAX | 50000 | Store & Forward capacity. A real maximum |
PENDING_EVENTS_WARN_RATIO | 0.8 | Depth at which the cabinet reports itself saturated |
CLOCK_TOLERANCE_S | 60 | How far wall time may run backwards before the clock is called wrong |
EXPIRED_RIGHT_GRACE_DAYS | 7 | How long a lapsed right stays distinguishable from an unknown one |
MQTT_HOST · MQTT_PORT | emqx · 1883 | |
MQTT_TLS_CA · _CERT · _KEY | (empty) | All three or none; a half-configured identity fails at startup |
MQTT_KEEP_ALIVE_S | 30 | |
EVENT_BATCH_SIZE | 100 | Passages published per drain |
EVENT_PUBLISH_INTERVAL_MS | 1000 | How often the queue is drained |
TELEMETRY_INTERVAL_S | 10 | Heartbeat and state report |
DEVICE_DEBUG_MAX_DURATION_S | 900 | The cabinet's own ceiling on a debug stream |
HARDWARE_MODEL | read from the device tree | What the box is |
REBOOT_COMMAND | /sbin/reboot | Refused outright inside a container |
WATCHDOG_PATH | /dev/watchdog | Absent in a container; the controller says so |
WATCHDOG_INTERVAL_S | 10 | |
FIRMWARE_PATH | /opt/wardn/bin/wardn-edge | What the supervisor starts, and what an update replaces |
SENTRY_DSN · SENTRY_ENVIRONMENT | (empty) · development |
Dashboard¶
Read at container start and written into config.js. These are the addresses a browser dials, never the service names the containers use.
| Variable | Default |
|---|---|
WARDN_API_URL | http://localhost:3000 |
WARDN_KEYCLOAK_URL | http://localhost:8080 |
WARDN_KEYCLOAK_REALM | wardn |
WARDN_KEYCLOAK_CLIENT_ID | wardn-spa |
WARDN_SENTRY_DSN | (empty) |
WARDN_ENVIRONMENT | development |
Backups¶
| Variable | Default | Effect |
|---|---|---|
BACKUP_DIR | ./backups | |
BACKUP_KEEP_DAYS | 30 | Applied locally and in the remote store |
BACKUP_REMOTE_ENDPOINT | (empty) | Empty keeps backups on this machine |
BACKUP_REMOTE_BUCKET · _ACCESS_KEY · _SECRET_KEY | (empty) | |
BACKUP_ENCRYPTION_PASSPHRASE | (empty) | Mandatory once an endpoint is set; make backup refuses without it |
Local infrastructure¶
Used by Compose and by db-init, not by the applications.
| Variable | Default |
|---|---|
POSTGRES_DB · POSTGRES_USER · POSTGRES_PASSWORD | wardn · wardn_owner · wardn_owner_dev |
APP_DB_USER · APP_DB_PASSWORD | wardn_app · wardn_app_dev |
MAINTENANCE_DB_USER · MAINTENANCE_DB_PASSWORD | wardn_maintenance · wardn_maintenance_dev |
PARTITION_MONTHS_BACK · PARTITION_MONTHS_AHEAD | 3 · 3 |
SEED_ENABLED · SEED_FORCE | true · false |
SEED_ACTIVITY_DAYS · SEED_ACTIVITY_EVENTS_PER_DAY | 30 · 12 |
MQTT_PORT · MQTT_TLS_PORT | 1883 · 8883 |
EMQX_DASHBOARD_PORT · EMQX_DASHBOARD_PASSWORD | 18083 · wardn_dev_dashboard |
KEYCLOAK_PORT · KEYCLOAK_ADMIN · KEYCLOAK_ADMIN_PASSWORD · KEYCLOAK_REALM | 8080 · admin · admin · wardn |
MINIO_PORT · MINIO_CONSOLE_PORT · MINIO_ROOT_USER · MINIO_ROOT_PASSWORD | 9000 · 9001 · wardn · wardn_dev_secret |
MINIO_DOCS_BUCKET · MINIO_OTA_BUCKET | wardn-docs · wardn-ota |
MINIO_APP_ACCESS_KEY · MINIO_APP_SECRET_KEY | wardn-backend · wardn_backend_dev_secret |
TLS_DAYS · TLS_CA_DAYS | 825 · 3650 |
BACKEND_PORT · BACKEND_2_PORT · FRONTEND_PORT | 3000 · 3001 · 4200 |
EDGE_READER_PORT · EDGE_METRICS_PORT | 9100 · 9101 |
wardn is operable. Last chapter: contributing to it.
Next → 13. Developing