Clinique HOREB · Private healthcare, Benin
A clinic application suite designed for a full air gap
Independent engagement, sole engineer: infrastructure, architecture, deployment
The requirement was an application suite that could run entirely inside the clinic, without depending on its internet connection. I began from GCP, my default application platform, which gave the project a working cloud baseline. The architecture task was then to preserve those capabilities while removing every provider dependency. Zitadel now provides local identity, with Google authentication retained only as a fallback when the connected path is available.
The system
Reception, laboratory, maternity, the operating block, staff planning, and an administrative dashboard sitting over all of them. Six departments, one shared patient record.
Everything lives in the building. Twelve Windows 11 Pro workstations: one acts as the server, the other eleven are terminals with a browser and nothing else on them. The server carries PostgreSQL for the data, one API that is the only thing allowed to touch it, Cerbos to decide what each member of staff may do, and Zitadel as the local identity provider. Their network segment has no route out of the building, so there is no connection to lose.
Constraints that determined the design
Five facts about this client decided almost everything below. None of them were mine to choose.
- Medical data: the clinic remains the controller of records stored outside the EU.
- Connectivity: one Starlink terminal cannot sit on the critical path of clinical work.
- Operations: nobody on site is an engineer, so the system must be operable by clinical staff.
- Scale: around one hundred staff do not justify a high-availability cluster.
- Hardware: the local deployment has to fit on one 8 GB host.
A sixth came from the client rather than from the site, and it shaped the architecture more than any of the others: the suite has to last, so no shortcuts. That is what ruled out the easy version of offline support, a conditional inside the existing backend that skipped the cloud calls when the link was down.
If cloud were allowed, GCP already solved the problem
The initial implementation lived entirely on Google Cloud. Each application ran as a container on Cloud Run behind its own subdomain and talked to Firestore directly, with no backend in between. Google Sign-In handled identity, restricted to the clinic's Workspace domain. Attachments went to Cloud Storage, background jobs to Cloud Tasks, secrets to Secret Manager. Terraform described all of it, split into a shared platform root and one root per service tier, and GitHub Actions deployed it by trading an OIDC token for short-lived credentials, so no service account key ever existed.
If cloud deployment were allowed, this would remain a strong answer. Firestore's security rules are an authorization engine that ships inside the database. Granting a signed-in user with a verified clinic address access to a collection takes a few lines, the provider evaluates them, and there is no service to deploy or operate. The repository's own written convention says as much: keep logic out of the backend wherever rules can enforce it safely, and reserve the backend for work that genuinely needs credentials. For one engineer building eight applications, that reduced the amount of backend code and operational infrastructure. Cloud Run scaling to zero also kept idle infrastructure cost low.
Its limit is structural, not a matter of degree. The database enforces authorization, so the authorization layer cannot run without the provider. The applications speak to the database directly, so they cannot run without it either. No amount of money buys a degraded mode. And a reception desk that stops admitting patients because a satellite link dropped is not an inconvenience, it is a clinical problem.
Architecture and flow
One API is the sole gatekeeper. Every frontend reaches it over HTTP, and it is the only thing that reaches PostgreSQL. It works out who is asking, asks Cerbos whether that principal may perform that action on that resource, and only then touches the database. Cerbos keeps no data of its own and answers from policy files held in memory. Zitadel is the local identity provider, storing its own database inside the same PostgreSQL instance, reached through a Caddy proxy because browsers cannot speak the identity API's raw HTTP/2. Each application is published on its own port for now; putting them behind one hostname belongs to the site deployment.
Row-level security is switched on across the tables with no policies attached to it at all. Anything arriving at the database directly is therefore denied by default rather than trusted, and the API connects with a role that bypasses the whole mechanism. That division is deliberate. One service is the gatekeeper; the database's own rules exist to catch a misconfigured client, not to be the primary control.
Uploads write to a local disk volume behind a path-traversal guard, then get served by an authenticated route on the API like every other resource. Nothing in the system issues a signed link. A signed link exists to delegate trust to a storage provider, and here there is no provider to delegate it to.
Patient identity is not the suite's to invent. The clinic's cash desk is a separate system that already existed, and it remains the sole authority for patient identifiers, with clinical acts gated on a payment recorded there. Integration is a handful of authenticated webhooks. Those identifiers are typed as integers throughout, and the schema change that established that refuses to run on a value it cannot recognise rather than coercing it, because failing loudly beats silently repointing a clinical record at the wrong person.
Technology choices
PostgreSQL directly, not Supabase or PostgREST with row-level security
- Closest translation: Supabase or PostgREST with row-level security would preserve direct client-to-database access.
- Reason rejected: the backend already owns the application logic, so the database does not need to remain the primary gatekeeper.
- Risk: a row-level security error can become a silent data leak in the language the maintainer knows least well.
- Operational cost: another service adds deployment, backup, and recovery work for a clinic with no full-time operator.
- Decision record: the prototype remains in the repository marked as superseded, so the rejected path stays reviewable.
Cerbos for authorization, not OpenFGA or SpiceDB
- Decisive constraint: Cerbos evaluates policy files in memory and needs no database.
- Alternatives: OpenFGA and SpiceDB provide richer relationship models but require a tuple datastore.
- Air-gap consequence: another datastore means another stateful service to operate, back up, and restore on one host.
- Authorization model: four roles, per-application access through derived roles, and explicit permissions for sensitive actions.
- Example: editing patient identity is separate from general access to patient records.
Zitadel for local identity, with Keycloak as a valid alternative
- Okta: its self-hosted agent still depends on Okta's cloud directory, so it fails the offline requirement.
- Keycloak: a fully valid local option that can use its own database inside the existing PostgreSQL service.
- Zitadel: also meets the offline requirement and stores its state inside the existing PostgreSQL service.
- Reason selected: I chose Zitadel deliberately to explore and operate a different identity technology.
- Decision boundary: this was not a capability, database, or resource-efficiency win over Keycloak.
Site configuration: hardware
The stack above is built, and real production data has been migrated into it. The physical installation in this section and the next is the deployment design for the site.
- Machines: twelve Windows 11 Pro workstations, with one acting as the server.
- No specialist hardware: no rack, hypervisor cluster, or appliance that the clinic cannot maintain.
- Container runtime: Docker Engine runs inside Windows Subsystem for Linux rather than the desktop application.
- Boot behaviour: a scheduled task starts the containers at system boot instead of waiting for an interactive login.
- Baseline test: memory was measured with an empty database and no traffic, so it is not a capacity or concurrency result.
Growth affects the tiers differently:
- Authorization: more staff and grants do not create relationship tuples because Cerbos evaluates shared policy rules.
- Data: PostgreSQL absorbs growth through index design, disk capacity, and query cost.
- Upgrade order: indexing first, then storage, then a dedicated PostgreSQL machine if required.
- Terminals: the eleven clients hold no application or data and can be replaced with another browser-equipped workstation.
Site configuration: network and the air gap
- Clinical segment: twelve statically addressed machines on their own switch.
- Structural isolation: no default gateway exists, so isolation does not depend on a firewall rule.
- Connected segment: Starlink serves general use, administration, and remote components on a separate network.
- No route between them: patient-record terminals cannot reach Starlink, and connected machines cannot reach clinical records.
- Software delivery: pull images on a connected machine, export them, carry them across, and load them on the server.
- Outstanding correction: replace the remaining floating
latesttag with a pinned digest. - Backups: scheduled database dumps to attached storage, with media rotated off site and recovery proven through restoration.
- Transport security: an internal certificate authority is required because the isolated segment cannot reach a public authority.
- Current state: the stack still uses plain HTTP on the isolated segment.
Identity moved local without deleting the cloud fallback
- Cloud baseline: Google authenticated staff and the Firebase Admin SDK verified its tokens.
- Local primary: Zitadel runs on clinic hardware and stores its state in PostgreSQL.
- Offline behaviour: normal authentication requires no external issuer or signing-key endpoint.
- Connected fallback: Google authentication remains available without becoming a local dependency.
What runs remotely, and why
- Mailbox sync and classification: a separate remote service owns the external credentials and classification model.
- Data direction: the clinic pulls results once a day, so nothing exposes an inbound route to the clinical segment.
- Offline failure: a failed pull is recorded while the local applications continue running.
- Messaging bot: remains hosted because a third party must reach its public webhook.
- Satisfaction form: remains on its managed database because patients submit responses from outside the clinic.
- Dashboard import: remote form data enters the internal dashboard through the same read-only daily pull.
Accepted trade-offs
- No realtime push: screens poll and refetch after writes.
- Connected classification: document classification is unavailable without a connection.
- Local operations: backup and transport-security responsibilities move from the provider to this deployment.
- Image integrity: digest pinning remains outstanding before the air-gap delivery is complete.
Against all that: the records are in the clinic, the suite works when the satellite link does not, and the deployment is small enough to hand to a clinic with no engineer and stand up again at the next one without renting anything.
Verification
The main claims have direct checks behind them:
- Authorization: Cerbos returns different decisions for the same request under different principals, tested directly and through API middleware.
- Offline degradation: stopping the remote service records the failed daily pull without taking down the local applications.
- Cash desk integration: one webhook creates a patient and linked episode, verified by querying the database.
- Migration: production data was migrated with row counts reconciled against the source.
Stack
- PostgreSQL
- Docker
- Cerbos
- Zitadel
- Caddy
- Terraform
- Google Cloud Run
- Firestore
- TypeScript
- React
- Windows 11 Pro