Skip to content

ARCHITECTURE.md - Linumed Base

Overview

Linumed Base is not a custom operating system and not a bootable image. It is an Infrastructure-as-Code kit built on Ansible that turns a standard Debian 13 (Trixie) installation into a hardened, GDPR-aware healthcare infrastructure platform.

"GDPR-aware", deliberately not "GDPR-compliant". Compliance is a property of a processing activity, not of a software package - it depends on the legal basis, the processing register, the data processing agreements, the retention periods, the application running on top and who is given access, none of which this kit knows or controls. What it does contribute is concrete and testable: host hardening, TLS on every exposed path, encrypted backups with a restore test that actually runs, every management interface bound to loopback, and no telemetry leaving the host. That makes a compliant deployment possible; it does not make one.

The target audience is IT departments and system administrators at clinics and care facilities who want to run open-source software but don't have the time or expertise to build a healthcare-compliant stack from scratch.


Design principles

On-premise by design Linumed Base is built to run in the institution's own infrastructure. There is no cloud dependency, no telemetry call-home, no SaaS component. All data stays in-house.

GDPR as a constraint, not a feature Data protection requirements are built into every design decision: local data storage, encrypted backups, a minimal logging surface, no transfer of personal data to third parties.

Idempotency All Ansible playbooks are idempotent. A second run produces no changes. That is a hard requirement, not a recommendation.

FOSS-only in the core Every component of Linumed Base is free open-source software. Linumed Shifts (the commercial product) is not part of this repository and is licensed separately.

EU infrastructure No dependencies on US-only services. Pulling images from Docker Hub is accepted, but images run on EU infrastructure. EU-based alternatives are preferred for CI/CD.


Target architecture (v0.1)

What runs where on a Linumed Base host: a Docker Engine group holding the Caddy,
BridgeLink and monitoring stacks, and a second group of services that run natively -
ufw/fail2ban/SSH hardening, Node Exporter and restic.

Deliberately two figures rather than one. This one answers what runs where and carries no arrows at all; the flows are the next one. The previous single diagram mixed containment with three different kinds of arrow - a security property, a data flow and a backup operation - and drew two of them at the Docker group's boundary, which is the one thing the renderer reliably gets wrong (issue #65).

What reports to whom: restic feeds the Node Exporter's textfile collector; Node
Exporter, cAdvisor and the BridgeLink exporter all scrape into Prometheus; Alloy ships
logs to Loki; Prometheus and Loki both feed Grafana, and Prometheus also feeds
Alertmanager.


Components

common (Ansible role)

Base hardening of the Debian system. Always runs first.

Covers: - SSH hardening (PasswordAuthentication off, configurable port, AllowUsers) - ufw firewall (default deny incoming, only explicitly allowed ports) - fail2ban (SSH brute-force protection) - unattended-upgrades (automatic security updates) - timezone and NTP configuration - basic system packages

caddy (Ansible role)

Caddy as a reverse proxy with automatic TLS via ACME (Let's Encrypt or a private CA), run as a Docker Compose stack.

Configuration via a Caddyfile, generated from Ansible templates.

Design decision - Caddy as a container, not native on the host: the original plan was to run Caddy natively so TLS termination would survive a Docker restart. Checked (as of 2026-08-10): Debian 13 ships caddy at version 2.6.2 with 11 open security issues in the Debian security tracker, while the container runs current upstream 2.11.x. For the most exposed component in the whole stack, "native" would therefore not be safer but measurably less safe - the patch-cadence advantage that would otherwise justify running native works against it here. The original availability argument barely holds either: if every backend is itself a container, a surviving Caddy with no reachable upstream just returns 502 instead of connection refused - no practical gain. A real kill switch (e.g. during a security incident) belongs in its own documented network-level procedure (ufw deny, interface down) in the operations runbook, not as a side effect of where the proxy runs.

Docker publishes container ports past ufw (see the security model below) - for Caddy that's intentional on 80/443, since the proxy has to be reachable from outside. Any further ports: entry in this Compose stack has to account for that trap deliberately.

HL7 v2 integration engine, run as a Docker Compose stack. The engine used is BridgeLink.

This is not a replacement for Mirth Connect, but the same codebase under a different name: same channel XML, same administrator, same transformers and connectors - the Java packages are still called com.mirth.connect. NextGen Healthcare moved Mirth Connect to a purely commercial, proprietary license in March 2025 (source closed from 4.6 onward); the open-source line has continued under new names since, and Linumed Base follows that open line. Mirth Connect remains the industry's de-facto standard - channels are portable between all variants, so an institution keeps its integration work if it ever wants to switch.

Full reasoning, including evaluated alternatives (Open Integration Engine, licensed Mirth 4.6+, frozen 4.5.2), accepted downsides and revision triggers: ADR 0001.

Protocols supported out of the box, measured against the pinned image's own extension list: HL7 v2.x, HL7 v3, DICOM, EDI/X12, NCPDP, delimited text (CSV), XML, JSON, raw - with MLLP, TCP, HTTP, file, JMS, SMTP and JDBC connectors.

FHIR is not among them. The engine ships no FHIR data type and no FHIR connector: FHIR was never part of Mirth Connect's open-source core, only of NextGen's commercial extensions, so the open fork does not inherit it. A FHIR endpoint can be built here - an HTTP listener with the JSON data type - but that gives transport and parsing, not a resource model, validation, search semantics or a CapabilityStatement. The FOSS route to those would be the HAPI FHIR libraries loaded into the engine; how a library reaches the classpath in this image is not established, and the directory names in it are misleading - see docs/roles/bridgelink.md and issue #98. Anyone whose requirement is a FHIR server wants a real one next to this kit, which by this repo's own boundary is application software (ADR 0011).

Docker Compose stack: - BridgeLink (hardened image: Debian 13, no shell, non-root) - PostgreSQL (configuration and message database)

Only the admin/API port is published, and only on 127.0.0.1. Channel ports (HL7 MLLP or similar) are deliberately not published by the role - that's a per-site decision.

monitoring (Ansible role)

Observability stack. Most components run as a Docker Compose stack; Node Exporter runs natively.

Components: - Prometheus - metrics scraping and storage (container) - Grafana - dashboards, three vendored default dashboards included (host overview, container overview, log explorer) - deliberately generic infrastructure dashboards, not clinical or patient-related: the monitoring stack sees metrics and logs, not the integration engine's message contents; bound to 127.0.0.1 only by default, access via SSH tunnel (container) - Loki - log aggregation (container) - Grafana Alloy - ships host and container logs to Loki (container). Replaces Promtail, which reached end-of-life on 2026-03-02 and gets no more security fixes - not an option for a GDPR-focused kit. - Alertmanager - alert routing (container) - Node Exporter - host metrics (CPU, RAM, disk, network). Native Debian package instead of a container: gets security updates automatically through the existing unattended-upgrades role, needs no --pid=host/rootfs mounts, and ufw can actually protect the port - with a published container port that protection would be ineffective (see the security model below). - cAdvisor - container metrics (container)

cAdvisor covers every container generically, which is why the gap it leaves is easy to miss: for BridgeLink it reports CPU, RAM and network of the process, and nothing about whether messages are actually moving. A stopped channel or a queue that stops draining looks identical to an idle, healthy engine from the outside. BridgeLink exposes no /metrics of its own, so the bridgelink role ships an opt-in exporter sidecar that translates its REST API into channel-level metrics.

Prometheus reaches that sidecar by container name over a shared Docker network (linumed-base-metrics), not over a host port - the two stacks are separate Compose projects, and neither a loopback-published port (unreachable from another container) nor an all-interfaces one (bypasses ufw) is an option. The Node Exporter job's host.docker.internal works only because Node Exporter is a native service, not a container. Both switches (bridgelink_exporter_enabled, monitoring_scrape_bridgelink) are off by default - see docs/roles/bridgelink.md.

Retention is split by data kind, not a single global value: metrics (monitoring_metrics_retention_days, default 90) and logs (monitoring_logs_retention_days, default 30, shorter) - logs can contain personal data (IP addresses, usernames), so the shorter retention here is data minimization, not an arbitrary choice.

backup (Ansible role)

Encrypted backups with restic. Supported backends: - local (another directory / external drive) - SFTP (e.g. another server on the network) - S3-compatible (optional, e.g. Hetzner Object Storage)

Backup schedule via a systemd timer (not cron). Monitoring integration: restic results are pushed to Prometheus as metrics.


Network design

Decided, not open: no Linumed Base management interface is reachable from outside. Every component binds to 127.0.0.1 or publishes no host port at all; access goes through an SSH tunnel. Each Compose stack has its own Docker network by default.

Two shared networks exist, and neither weakens that: linumed-base-external lets the operator's own stacks be reached by Caddy (issue #39), and linumed-base-metrics lets Prometheus scrape an exporter that lives in another stack (issue #64). Both are internal Docker networks with no host port and no route from outside; they exist so that container A can reach container B without publishing anything, which is the opposite of an exposure. This is explicitly not a return of the linumed-net idea below - that one was about routing this kit's own management UIs through a reverse proxy, which remains dropped.

A shared network (linumed-net) through which Caddy would route the kit's own services was originally planned as the target picture and has been deliberately dropped - along with the SSO integration that would have been built on top of it. Reasoning, evaluated alternatives (reverse proxy with an identity provider, mesh VPN) and the accepted downsides: ADR 0003.

Access paths: the internet reaches Caddy on :80 and :443 only, which redirects to
HTTPS and proxies the operator's own applications. Grafana, Prometheus and the BridgeLink
admin interface are reachable exclusively through an SSH tunnel on
127.0.0.1.

Caddy is the reverse proxy for the institution's own applications, not for this kit's management interfaces. It joins a second Docker network (linumed-base-external, created unconditionally by the caddy role) for exactly that purpose, independent of the access decision above: an operator's separate Compose stack joins the same network as external: true and is then reachable by service name, without publishing a port or touching ufw. See docs/roles/caddy.md.

Because everything stays bound to loopback, Linumed Base works over whatever network the operator already runs - a corporate VPN, a mesh, a jump host, or the clinic LAN - without needing to know about any of it. That composability is the reason for the decision, not a side effect of it.


Storage strategy

All persistent data lives in named Docker volumes, no bind mounts to host paths except explicitly documented exceptions.

Service Volume Contents
bridgelink bridgelink_appdata keystore, server.id, runtime data
postgresql (BridgeLink) bridgelink_db_data channel configuration and messages
prometheus prometheus-data metrics (retention: 90 days default)
grafana grafana-data dashboards, user settings
loki loki-data log data (retention: 30 days default, shorter than metrics - see the monitoring role)

Node Exporter has no volume of its own - it runs natively, and host metrics aren't persisted there (Prometheus takes care of that).

restic backs up /var/lib/docker/volumes/ via direct file access, plus /opt/linumed-base/ (role configuration, secrets). Backups run daily via a systemd timer. The result isn't pushed to Prometheus; it's written as a Prometheus textfile metric (the same mechanism the native Node Exporter uses) - Prometheus picks it up on its regular scrape, no pushgateway needed.

Recovery tests are mandated as a documented process (a GDPR requirement). The backup role runs one automatically, weekly, on its own systemd timer independent of the daily backup - restore into a throwaway target, diff against the live source, write the result as its own textfile metrics so a restore test that silently stops running is exactly as visible as one that starts failing. See docs/roles/backup.md.


Inventory structure

ansible/inventory/
└── example/
    ├── hosts.yml            # Example inventory (no real hosts)
    └── group_vars/
        ├── all.yml          # Global variables (timezone, NTP, etc.)
        └── linumed/
            ├── vars.yml            # Linumed-specific, non-secret defaults
            └── vault.yml.example   # Template for the required secrets (Ansible Vault)

For a real deployment, the administrator creates their own inventory outside the repository and references the roles.

Optional roles (everything but common and docker, which are always applied) are picked via scripts/select-roles.sh, a whiptail checklist that writes linumed_base_roles into vars.yml - see README.md's Quick start section and scripts/README.md for usage:

Role-selection checklist: caddy and backup checked, monitoring and bridgelink unchecked, run from a terminal.


Security model

  • every connection TLS-encrypted (Caddy + ACME)
  • SSH key-only, no password login
  • firewall default-deny, minimal opening
  • Docker bypasses ufw: a container port published via ports: is reachable despite active ufw rules (Docker's own iptables/nftables rules sit ahead of ufw's rules in the chain). Hence this kit's default: publish nothing that doesn't need to be publicly reachable (the exception is Caddy on 80/443, which is intentional) - everything else gets either no host port at all or an explicit 127.0.0.1 binding.
  • Docker containers run unprivileged, no --privileged
  • secrets via Ansible Vault or an external .env file (never in the repo). Passwords a container needs at runtime go in as a Docker secret from a file, not as an environment variable - env vars are readable by anyone allowed to run docker inspect and end up in the container config on disk.
  • From the bridgelink role onward, the stack processes real patient data. Up to that point it only holds operational data (metrics, logs); an integration engine, by contrast, moves HL7 messages carrying names, dates of birth and diagnoses, and its database stores them depending on channel settings. That shifts the requirements for backup (the backup role), retention and access control from "securing infrastructure" to "processing health data" - see docs/roles/bridgelink.md, the GDPR section.
  • backup data is encrypted (restic + password via Vault)
  • automatic security updates for the host system (unattended-upgrades also covers natively installed packages like Node Exporter, not just container images)

Scope: Linumed Base vs. Linumed Shifts

Linumed Base Linumed Shifts
Type open-source IaC kit commercial SaaS application
License MIT proprietary
Content infra stack, integration engine, monitoring shift scheduling for care wards
Repo Linumed/Base Linumed/Shifts (private)
Audience IT admins, system integrators care management, ward leads, nursing staff
Dependency independent can run on top of Linumed Base

Linumed Shifts is not in this repository and is not documented here.


Versioning strategy

  • v0.1: common + docker + caddy + monitoring + bridgelink + backup
  • v0.2: operational readiness - a working onboarding path, the operations handbook, access hardening (shell-less tunnel users, real Grafana users), an automated restore test. See docs/ROADMAP.md.
  • v0.3: the product's own name (Linumed Base, ADR 0006), BridgeLink application metrics, and the diagram/documentation work that came with them. No new role. See docs/ROADMAP.md.
  • v0.4: the DICOM stack (Orthanc), plus the maintenance the kit did not do before - scanning the pinned images for known vulnerabilities, answering the lifecycle questions (Debian major upgrade, teardown, the single-host assumption), and a playbook for the runtime-agnostic subset. See docs/ROADMAP.md, Stage 6. Orthanc was earmarked as v0.3 until 2026-08-20; that number went to the rename and the observability work instead.
  • v1.0: the point from which breaking changes require a major version bump. What that promise covers is written down rather than left to interpretation - variable names, deploy paths, container and network names, this kit's own metric and alert names, dashboard and datasource UIDs, systemd unit names, and the set of variables with no default. See ADR 0008, which also lists what is deliberately not covered (pinned image versions have to keep moving) and what has to be cleaned up before the tag, because 1.0 freezes whatever exists at that moment. An earlier version of this line also named "certification prep"; that term appeared exactly once in the entire repository and was defined nowhere, so it was removed rather than left as an expectation nobody had committed to. If a specific certification ever becomes a goal, it gets named, scoped and given its own issue.

No bundled identity provider. The Authentik role originally planned for v0.2 has been dropped (ADR 0003): it would have required an outward-facing opening that this kit deliberately avoids. Instead of shipping an identity provider, Grafana gets an optional connection to an existing one (OIDC).

Application software (HIS, DMS, document management) is deliberately not part of Linumed Base. The clinic runs its own applications. Linumed Base provides the hardened foundation they run on - see the note on "GDPR-aware" under Overview for where that foundation stops.

Every release is tagged as a git tag, and versions follow Semantic Versioning. For a 0.x project that means the opposite of what an earlier version of this sentence claimed ("breaking changes only from v1.0 onward"): before 1.0 there is no stability guarantee at all. Breaking changes can and do land in minor releases, and each one is called out under ### Breaking in CHANGELOG.md - v0.2.0 moved the Caddyfile, v0.3.0 renamed every identifier and moved Prometheus's configuration into its own directory.

v1.0.0 was tagged 2026-08-22; v2.0.0 followed 2026-08-27, five days later, when Orthanc turned out to be application software by this project's own definition and was removed entirely (ADR 0011) - a breaking change against the ADR 0008 surface, so a major bump, not a patch on top of a release five days old. From here a breaking change again requires a major version bump. That promise was not a quality label to reach and did not arrive by itself - it is a deliberate promise about future releases, made only once the surface it covers had been measured and, where it should not have been frozen, narrowed (ADR 0010).

v2.0.1 followed on 2026-09-04 and is what the other side of that promise looks like: a documentation release that touches no role, variable or default, so nothing in the ADR 0008 surface moves. It exists because the quick start shipped in v2.0.0 could not be followed at all.

The scope of that promise is defined in ADR 0008. The surface was measured, not estimated: re-measured against all seven roles on 2026-08-21, then again against six roles on 2026-08-23 after Orthanc's removal (#92/ADR 0011). Current: 120 interface role variables (plus four recorded as internal in ansible/internal-variables.txt, see ADR 0010), 11 container names, 13 alert rule names, four systemd units, seven variables a plain run aborts without, and the Grafana dashboard and datasource UIDs that every operator-built panel references.