Operations
Running a clinical data repository in production means more than starting the binary: the database must be backed up and least-privileged, traffic must be encrypted, upgrades must be safe while the service stays up, and you need to see what the system is doing. This chapter is a production checklist — database roles, TLS, backup and point-in-time recovery, upgrades and migrations, observability, health probes, and the management surface — drawn from how the container image and Helm chart are built to run.
- Database roles and least privilege
- Applying migrations
- TLS and database security
- Backup and point-in-time recovery
- The container image and pod hardening
- Upgrades
- Observability
- Health probes and the management surface
Database roles and least privilege
EHRbase-rs connects to an external PostgreSQL 18 — a managed service or an operator-run cluster, never a chart-side sidecar, because a database holding PHI must be independently backed up and recoverable. The server carries only a connection string, ideally sourced from a secret.
The database uses a four-role model — never a superuser at runtime:
| Role | Purpose | Used by |
|---|---|---|
| owner | owns the database | provisioning only |
ehrbase_migrator | runs the schema migrations; owns the helper functions | the migration step |
ehrbase_app | reads and writes clinical data | the running server |
ehrbase_reader | read-only | replicas and reporting |
The migrations create these roles idempotently, apply the per-schema grants,
and revoke the ability to create objects in the public schema. The running
server connects as ehrbase_app — its DSN should authenticate as that role,
not the migrator or the owner.
Applying migrations
The binary applies its embedded migrations on boot, so you choose how migrations run:
- Grant the runtime DSN the migrator role — simplest, for single-tenant or small deployments; the server migrates itself at startup. Least isolation.
- Run migrations out of band with a migrator DSN, then start the server
with the lower-privileged
ehrbase_appDSN — recommended for least-privilege production. Run the migration as a CI/CD step or a one-shot job with the migrator credential before rolling the deployment, and gate the rollout so two versions never race the schema.
TLS and database security
These are database-side settings that belong to whoever provisions PostgreSQL; the deployment references them but cannot enforce them:
- TLS in transit. Require
hostsslon the server and put?sslmode=verify-fullin the DSN so the client verifies the server certificate. - pgaudit. Run pgaudit as the database-layer complement to the openEHR
audit and the ATNA trail — for example
pgaudit.log = 'ddl, role, connection'globally plus object-level audit on the PHI tables — and ship the audit log to an immutable store with long (roughly six-year) retention. - Encryption at rest. Encrypt at the volume or disk layer. Do not encrypt the stored clinical JSON with pgcrypto — it would break AQL’s ability to query inside the data.
Backup and point-in-time recovery
Enable WAL archiving and point-in-time recovery from day one (pgBackRest or a
managed PITR), because a CDR’s data is not reconstructible. Clinical and audit
tables are never UNLOGGED. Test your restore, not just your backup.
The container image and pod hardening
The published image is distroless and non-root — shell-less, with no package manager — and is multi-architecture (amd64 and arm64) on GHCR. When run under Kubernetes with the provided Helm chart, the pod is hardened and the chart’s validation asserts these on every render:
| Setting | Value |
|---|---|
| run as non-root | uid/gid 65532 |
| read-only root filesystem | yes (a writable emptyDir at /tmp) |
| privilege escalation | disallowed |
| Linux capabilities | all dropped |
| seccomp | RuntimeDefault |
| service-account token | not mounted (the workload never calls the Kubernetes API) |
| NetworkPolicy | default-deny ingress; only the API/management port admitted |
Egress restriction is opt-in because egress targets (database, broker, terminology server) are deployment-specific. See Installation → Kubernetes & Helm for the chart itself.
Upgrades
- Backward-compatible migrations. Migrations are append-only and never edited once applied. A rolling upgrade must be compatible with the previous schema for the window where both versions run: apply additive changes first, and defer destructive changes to a later release once every pod is on the new version.
- Lock-safe DDL. The migration runner bounds DDL with a
lock_timeoutandstatement_timeoutso a migration cannot block live traffic indefinitely; on a busy table useCREATE INDEX CONCURRENTLYand add constraintsNOT VALIDthenVALIDATElater. - Pin the image. Deploy an immutable tag or, better, a
@sha256digest, neverlatest; roll back by re-pinning the prior digest — the schema’s backward compatibility makes that safe. - Stay available. Keep at least two replicas (or autoscaling) and a pod disruption budget so node drains and upgrades never fully interrupt the API. The default 30-second termination grace period covers the server’s short shutdown drain of the audit and event outboxes.
Observability
tracing is the single instrumentation API. From it, three signal families
fan out, and identified data never enters any of them — telemetry uses
only closed-set labels and opaque request/trace ids, so correlation to a
patient is possible only through the
ATNA audit trail.
- Logs go to stdout — JSON when not attached to a terminal, pretty on a
TTY — each line stamped with the trace and span id. Shipping and rotation
are the platform’s job.
EHRBASE_LOG_FORMAT(auto/json/pretty) andEHRBASE_LOG_FILTER(orRUST_LOG, defaultinfo,ehrbase=info) control them, and the level can be changed at runtime through theloggersendpoint below. - Traces export to any OpenTelemetry collector (Tempo, Jaeger, and so on) over OTLP — but only when you configure an endpoint; with none set, the tracing layer is not installed at all (zero overhead). Root spans are named by route template, never by a path containing ids.
- Metrics are exposed for Prometheus to scrape at
/management/prometheus(OTLP metrics push is an option). The catalogue includes HTTP request duration and active requests, authentication failures, database pool state, AQL query counts and latency, compositions committed, validation failures, and the audit pipeline’s health.
The telemetry environment variables:
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_OTEL_OTLP_ENDPOINT | unset (layer not installed) | OTLP collector endpoint |
EHRBASE_OTEL_SERVICE_NAME | ehrbase | reported service name |
EHRBASE_OTEL_ENVIRONMENT | dev | reported deployment environment |
EHRBASE_OTEL_TRACES_SAMPLE_RATIO | 1.0 | head sampling ratio (start at 0.1 in production) |
EHRBASE_OTEL_METRICS_PUSH | false | also push metrics over OTLP |
Tip
A single-container dev stack (
grafana/otel-lgtm, bundling an OTLP collector, Prometheus, Tempo, Grafana, and Loki) ships as a Compose overlay, together with a provisioned Grafana dashboard (request rate/errors/duration, database pool, AQL latency, validation failures, audit health) and a starter alert pack — point the server at it with the two OTLP variables above.
Health probes and the management surface
The management surface — health, info, metrics, and runtime log control — is
off by default on the bare binary, and each endpoint is independently
opt-in with an access level (admin_only, private, or public). It can be
bound to its own internal port so it never appears on the public API listener.
The Helm chart turns the health probes on by default because they carry no
PHI.
| Environment variable | Default | Meaning |
|---|---|---|
EHRBASE_MANAGEMENT_ENABLED | false | enable the management surface |
EHRBASE_MANAGEMENT_BASE_PATH | /management | base path for the surface |
EHRBASE_MANAGEMENT_PORT | unset (main listener) | serve management on its own port |
EHRBASE_MANAGEMENT_ACCESS_DEFAULT | admin_only | default access level |
EHRBASE_MANAGEMENT_PROBES_ENABLED | false | expose the liveness/readiness probes as public |
The probe and ops endpoints:
| Endpoint | Purpose | Default access |
|---|---|---|
GET {base}/health/liveness | process is up (200), no I/O | public when probes enabled |
GET {base}/health/readiness | 200 (up/degraded) or 503 (down): database ping, migrations applied, audit sender, events — each bounded to one second | public when probes enabled |
GET {base}/health | aggregate component health | admin_only |
GET {base}/info | build, version, and pinned spec versions | admin_only |
GET {base}/prometheus | Prometheus text exposition | admin_only (re-expose to the scraper via network policy) |
GET {base}/metrics | JSON registry view | admin_only |
GET {base}/env | effective configuration, with secrets redacted | admin_only |
GET/POST/DELETE {base}/loggers | read and change the log level at runtime | admin_only |
Note
Under Kubernetes, wire liveness and startup to the liveness route and readiness to the readiness route; readiness reports
503when the database is unreachable or migrations are not applied, so a pod is only sent traffic once it can serve. If you keep the management surface off, the container’sehrbase healthchecksubcommand can back an exec probe instead. The public/rest/statusproduct endpoint remains available regardless.
For the full list of configuration keys across every subsystem, see
Installation → Configuration reference; to
explore the API itself, open the API reference at /ehrbase-rs/api/ (also
linked from the toolbar on every page).