ADR-0008's sensitive tier — the household's password manager, photos, documents
and home automation — on trinity (10.0.99.40, Winterfell / VLAN 99), the
ProDesk 600 G4 that ADR-0034 made the tier's host after the firewall restore
was rehearsed on it. Deployed there since 2026-09-28, built by
build-the-sensitive-tier-host.md
under #404. It was authored ahead of the hardware, the way stacks/lab was, and
the first start found five things that the file could not show; the sections
below carry each of them. It holds real data since 2026-09-28 — Immich's
first 615 photographs, which arrived before the off-estate copy that #404 step
10 gates them on; What backs Immich up
says what that leaves exposed.
make up STACK=sensitive| Service | Image | Port | Purpose |
|---|---|---|---|
| caddy | caddy |
443 (https) | The tier's published HTTPS port. Terminates TLS, routes by name to every service behind it (#129) |
| step-ca | smallstep/step-ca |
internal (9000) | The tier's certificate authority — a root of its own with an intermediate beneath it, issuing to Caddy over ACME (#130, ADR-0037) |
| home-assistant | ghcr.io/home-assistant/home-assistant |
internal (8123) | Home automation, and what the 99 → 20 rule exists for — one pass, to the Hue bridge, scoped by ADR-0035 (#134) |
| adguard | adguard/adguardhome |
53 (dns), on 10.0.99.40 only |
The house's DNS filter, and since 2026-09-28 the only forwarder behind Unbound on morpheus (ADR-0055, which replaces the fallback half of ADR-0010). It is never a client-facing resolver, and if it stops, outside names stop for the whole house. The one port besides Caddy's, published to the firewall's forwarder and the blackbox prober and answered for nothing else; the UI is behind Caddy at adguard.matrix.elysium (#135) |
| immich-server | ghcr.io/immich-app/immich-server |
internal (2283) | The photo library — API and job workers in one container, reached as https://immich.matrix.elysium through Caddy (#132) |
| immich-machine-learning | ghcr.io/immich-app/immich-machine-learning |
internal (3003) | Smart search, face detection and OCR for the server above. Behind the ml profile, on by default (#132) |
| immich-db | ghcr.io/immich-app/postgres |
internal (5432) | Immich's Postgres, with VectorChord preloaded, on the internal SSD (#132) |
| immich-valkey | valkey/valkey |
internal (6379) | Immich's job queue. Nothing durable — a tmpfs, rebuilt from the database on restart (#132) |
| paperless | ghcr.io/paperless-ngx/paperless-ngx |
internal (8000) | The document archive: scan, OCR, index. On this tier by content — tax returns, passports, medical records — and the service ADR-0023 classes as durable (#133) |
| paperless-db | postgres |
internal (5432) | Paperless-ngx's own database. Metadata about documents; the documents themselves are files under paperless-media |
| paperless-broker | valkey/valkey |
internal (6379) | Paperless-ngx's task queue and cache — the one volume in this stack whose loss costs nothing |
| vaultwarden | vaultwarden/server |
internal (8080) | The household's password manager, at https://vaultwarden.matrix.elysium — Bitwarden's own clients and extensions, pointed at that URL (#131) |
| memos | neosmemo/memos |
internal (5230) | The household's quick notes, at https://memos.matrix.elysium. Notes, not documentation — the second service beyond ADR-0008's nine, by ADR-0059 (#145) |
| homepage | ghcr.io/gethomepage/homepage |
internal (3000) | The household's front page at https://home.matrix.elysium: what exists on the estate and where it lives, grouped by VLAN. A directory, not a status page — seven tiles read live numbers, with read-only tokens or from Prometheus; the rest are links (#137) |
| ntfy | binwiederhier/ntfy |
internal (8080) | Where the estate's alerts arrive: Alertmanager on prometheus publishes to https://ntfy.matrix.elysium and the phones subscribe there. Deny-all, two declared users (#136) |
| miniflux | miniflux/miniflux |
internal (8080) | The household's feed reader at https://miniflux.matrix.elysium, and the tier's first service beyond ADR-0008's nine (ADR-0057, #147). It polls every subscription on a timer, so it is a steady source of outbound traffic from VLAN 99 |
| miniflux-db | postgres |
internal (5432) | Miniflux's own database: subscriptions, read state, stars and entries |
| mealie | ghcr.io/mealie-recipes/mealie |
internal (9000) | The household's recipes, meal plans and shopping list at https://recipes.matrix.elysium. Beyond ADR-0008's nine, decided by its own ADR, and deployed 2026-09-29 (#146, ADR-0060) |
| linkding | sissbruecker/linkding |
internal (9090) | The household's bookmarks, at https://links.matrix.elysium. One SQLite file, one account, no second factor. The fourth service beyond ADR-0008's nine (ADR-0061, #144) |
| actual | actualbudget/actual-server |
internal (5006) | The household's budget at https://actual.matrix.elysium: the sync server for Actual's local-first clients, password login only, no bank sync. The fifth service beyond ADR-0008's nine, after Miniflux, Memos, Mealie and linkding, by ADR-0062 (#142) |
| stirling-pdf | stirlingtools/stirling-pdf |
internal (8080) | The household's PDF editor at https://pdf.matrix.elysium: merge, split, sign, OCR, convert, so that none of it goes through a website. Keeps nothing, and its documents live only in memory (#143, ADR-0063) |
Twenty-one services. Two are plumbing; Home Assistant and Vaultwarden are the first household services and the shape every later one takes; AdGuard is the one the household uses without ever knowing it; four are Immich, the service ADR-0008 names as the price of putting the tier on Winterfell at all; three are Paperless-ngx, the archive of what the household cannot get back; Homepage is the page that tells the household the rest exist; ntfy is the one the estate uses, to tell the operator what is wrong with the rest; two are Miniflux, the first of the tier extras, decided by an ADR of its own before it was written here; Memos is the second; Mealie is the third, the one the household is meant to open for its own sake; linkding is the fourth; Actual is the household's budget, the fifth; and Stirling-PDF, the sixth, is the one that keeps nothing. What is absent is as deliberate as what is here:
- No Prometheus, Loki or Grafana. The lab has its own because its
telemetry must never reach VLAN 99 (ADR-0007); this host is on VLAN 99,
so it is watched the way
oracleis — an Alloy agent shipped byscripts/deploy-agent.sh, pushing to10.0.99.20, needing no new rule and no new port. The agent is not in this compose file for the same reason it is not in the lab's: it is the estate's, deployed identically everywhere. - No other services planned. The next one, whenever it comes, arrives as
Home Assistant, Immich, Paperless-ngx, Vaultwarden, Homepage, ntfy,
Miniflux, Memos, Mealie, linkding, Actual and Stirling-PDF did — and, beyond
ADR-0008's nine, after an ADR of its own, as ADR-0057, ADR-0059,
ADR-0060, ADR-0061, ADR-0062 and ADR-0063 were for the last six: a
service with
expose:, a block in theCaddyfile, a name on the leaf and in the resolver, its credential in SOPS where it takes one from outside, and a sentinel for its volume inscripts/backup-volumes.sh. A service in this file withports:of its own is the one thing a review of it should refuse — AdGuard is the single argued exception, andcompose.yamlmakes the argument at DIFFERENCE 7 so that the next one has to be made too. - No shared database. Immich, Paperless-ngx and Miniflux each run a
Postgres of their own — Immich's needs the vector extension and therefore a different
image — and one database per service is what lets
backup-volumes.shattribute every volume to the one service that owns it and stop only that. - No Supervisor, no add-ons, no MQTT broker, no Zigbee coordinator. Home
Assistant Container has no add-on store, which is why it was chosen: an
add-on is a second package manager outside
compose.yamland outside digest pinning. What an add-on would have supplied becomes a pinned service here on the day a device needs it, and today none does — Ring is cloud, the Hue bridge is its own radio, the speakers are Wi-Fi. No USB radio means wheretrinitysits is not this service's concern.
compose.yaml twenty-one services, one network, health-gated ordering;
Immich's machine learning sits behind the `ml` profile
Caddyfile every route the tier serves; validated in CI
home-assistant/ configuration.yaml and packages/, mounted read-only
over the volume Home Assistant writes its state to
.env.example non-sensitive tunables — edit this, not .env:
the library's mount point, and the ML switch
adguard/AdGuardHome.yaml AdGuard Home's whole configuration, blocklists included
homepage/ settings, services, widgets and bookmarks YAML and
custom.css — its whole configuration, mounted read-only
ntfy/server.yml ntfy's settings; its users and access list come from SOPS
ntfy/templates/ message templates, mounted read-only
stirling-pdf/smoke.sh proves the PDF engine works, not just its status endpoint;
run by check_hardened_boot.sh (#143)
household.recipients the age public keys that can open the household's copies
household-age.sha256 checksums of the age release the household copy is opened with
consume/ untracked: drop a scan here and Paperless-ngx imports
and deletes it. Created by render-config.sh
export/ untracked: where document_exporter writes. Likewise
Secrets are secrets/sensitive.sops.yaml, encrypted to this stack's own rule
in .sops.yaml — trinity's key opens this file and nothing else of the
estate's (secrets/sensitive.example.yaml says why, and lists every key). The one file under
certificates/ this stack reads is tier-ca.pem, the tier's root
certificate, written by make tier-ca ARGS="--install …" from the bundle
minted on the monitoring host; every leaf is obtained from step-ca at run
time and lives in Caddy's /data volume, never on disk here.
- Caddy runs as root with one capability, and every other container in
the estate does not.
compose.yamlmeasures why — the image's/dataand/configare root-owned and a named volume inherits that — and names the command that re-checks the premise when the image moves.cap_drop: [ALL]plusno-new-privilegesis what makes it acceptable; do not add capabilities to make something else work. step-ca keeps the same one capability for a different, also measured, reason: its binary ships with theNET_BIND_SERVICEfile capability, and the kernel refuses to exec such a file against a bounding set without it — the first boot failed onoperation not permittedbefore the process existed. It still runs as1000:1000. - No admin API.
admin offin theCaddyfilemeans a routing change ismake up STACK=sensitive, which recreates the container, notcaddy reload. The socket would have been unauthenticated and on the same network as every service it fronts. - step-ca is a CA of the tier's own, and its tree is not made here. Not
an intermediate beneath the estate's CA, which this file once claimed: that
root carries
pathlen:0, and a leaf beneath any intermediate of it failspath length constraint exceeded— measured, and decided in ADR-0037.make tier-ca ARGS=--minton the monitoring host mints the root and intermediate in this image and writes a bundle without the root key;make tier-ca ARGS="--install …"here populates thestep-ca-datavolume from it and writescertificates/tier-ca.pem. The image's entrypoint will not start withoutconfig/ca.json, andDOCKER_STEPCA_INIT_*is never set, because either would mint a root of its own. The key password isSTEPCA_PASSWORDin SOPS, written to a private tmpfs at start and nowhere on disk.build-the-tier-ca.mdis the procedure. - Every certificate is obtained over ACME, and every name is also an
alias. The
Caddyfile'scert_issuer acmeblock points at step-ca's directory and trusts the tier's root; leaves are seven days and Caddy renews them. step-ca validates thetls-alpn-01challenge by dialling the requested name on 443 from inside the compose network, so a name served in theCaddyfilemust also appear under thecaddyservice's networkaliasesincompose.yaml— one line each, added together. A name with a block and no alias fails its first issuance with a DNS error at the CA. - The phones have to trust the tier's root. The mobile app is the whole
reason Immich was chosen over PhotoPrism (#132), and it talks to
https://immich.matrix.elysiumon a certificate a phone has never heard of.certificates/tier-ca.pem— the tier's root, not the estate'sca.pem, which vouches for nothing here (ADR-0037) — goes onto each phone as a user-installed root before the app is pointed at the server; Android's Immich app honours a user root, iOS needs the profile installed and then enabled under Certificate Trust Settings, which is the step people miss (build-the-tier-ca.md§6). Nothing about this changes when a leaf renews — the root is the same one. - Immich runs as the deploying user, and the library disk has to be owned
by that user.
immich-servercarries${RENDER_UID}:${RENDER_GID}the way Alertmanager does in the estate's stack, and writes only underIMMICH_UPLOAD_LOCATION; the format-and-chown of the USB disk is #404's step, done once when the disk is prepared. The machine-learning container is the exception and runs as root with every capability dropped, for the measured reasoncompose.yamlgives: its image has no/cache, so the volume mounted there is created root-owned and nothing else could fetch a model into it. Both run read-only; the three places the two images write outside their volumes were found by running them, not by reading, and each is atmpfsor an environment variable incompose.yamlwith the finding beside it. - Machine learning is on, bounded, and one line from off.
.env.examplesetsCOMPOSE_PROFILES=ml; blank it andmake upstarts five services instead of six. #132 asked for the option because the ML is the most memory-hungry thing that will run in the estate, and ADR-0034 chose a 32 GB host over an N100 partly so that it need not be taken. A 4 GiB ceiling is what makes running it before there is data acceptable. If it is switched off, disable machine learning under Administration › Settings too, or every upload queues jobs against a container that is not there. - The database password is set once. Postgres reads
IMMICH_DB_PASSWORDwhen it initialises its volume and never again; the server reads it on every connection. Rotating it insecrets/sensitive.sops.yamltherefore changes what the server sends and not what the database expects —ALTER USERinside the container is the other half, andsecrets/sensitive.example.yamlsays so beside the key. - 80 is not published. ADR-0012: a port is published when something
off-host consumes it, and nothing consumes 80 — browsers on Hicks type
https, and ACME'stls-alpn-01challenge runs over 443; the provisioner accepts no other challenge, and theCaddyfiledisables the redirect listener Caddy would otherwise open on 80. A redirect is a later choice, made in.env.example,compose.yamland theCaddyfiletogether. - Every routed name needs a host override.
homeassistant.matrix.elysium— and each name after it,vaultwarden.matrix.elysiumincluded — has to be inmorpheus's resolver, pointed at10.0.99.40(add-a-host-override.md) — a name the resolver does not know never arrives. The certificate side is the alias bullet above, not a SAN: there is no leaf to put one on. - Home Assistant is an ordinary member of the network, not
network_mode: host. Upstream's example uses host networking andprivilegedfor discovery and USB. Discovery is mDNS and SSDP, which are link-local, and every device it controls is on Skids, a VLAN away — nothing on 20 would be found from 99 however the container were attached. Devices are added by address, through the one pass ADR-0035 writes down. It runs with every capability dropped, a read-only root and two tmpfs mounts, as root because the image has no other mode;compose.yamlnumbers the differences. - Home Assistant's credentials are not in SOPS, and cannot be. The Hue
application key, the Ring token and everything else a config flow produces
are written by Home Assistant into
/config/.storage, inside thehome-assistant-configvolume. Nothing this repository renders can hand them in, so the volume is where the tier's most numerous credentials live, protected by #404's disk-encryption decision and by the encrypted volume archive rather than by SOPS. ADR-0035 records the deviation. A long-lived access token minted for another service goes in that service's SOPS file — none exists yet — and TOTP is enrolled at first login, as ADR-0022's floor requires. It is enrolled on the owner account since 2026-09-28. - Automations are YAML in
home-assistant/packages/, not the UI editor.configuration.yamlis mounted read-only from this directory and loads the packages directory beside it; there is noautomations.yaml, because the UI editor's include fails hard on a file that does not exist and the file is one Home Assistant writes rather than one this repository ships. The packages README says the rest. Integrations and devices are still added through the UI: a config flow has no YAML form. - Home Assistant's HTTP settings are seeded into its volume, not written in
configuration.yaml. Since 2026.9 they live in.storage/http. A YAMLhttp:block is imported once, as a pending config that reverts to defaults unless an admin promotes it within five minutes, and from 2027.2 it is not read at all. The defaults trust no proxy. So the store is written before Home Assistant's first start byscripts/seed-ha-http.sh, whichmake up STACK=sensitiveruns first. It writes the stable slot withpendingempty, which Home Assistant runs as-is: no trial, nothing to promote. It builds the file in the pinned image, from Home Assistant's own schema and store version.- A fresh volume is seeded. Proved 2026-09-29 against the real
compose.yamlunder a throwaway project: a request withX-Forwarded-Forfrom172.28.99.2got302, from.3got400, and an unseeded control got400from both. - An existing store is never overwritten. A restored volume brings its
own
.storage/httpback, and a live one may have been changed in the UI.make uponly checks it, and warns if it does not trust Caddy. scripts/seed-ha-http.sh --checkreads it without changing anything.--force, with Home Assistant stopped, rewrites it. That is the fix for the one symptom this prevents:400: Bad Requestonhomeassistant.matrix.elysium, with "your HTTP integration is not set-up for reverse proxies" in its log, as ontrinity's first start on 2026-09-28.
- A fresh volume is seeded. Proved 2026-09-29 against the real
- Caddy has a fixed address,
172.28.99.2, for one reader. Home Assistant'strusted_proxiesnames the proxy it will believeX-Forwarded-Forfrom, and a Docker-assigned address is not a name.seed-ha-http.shreads the address fromcompose.yaml's oneipv4_address:, so the two cannot drift. The network's subnet is fixed for that one line and nothing else. Itsip_rangekeeps Docker's own assignments in.128and up, because Caddy starts last and on 2026-09-28 found.2already taken by Vaultwarden. - AdGuard's configuration is the tracked file, every time.
compose.yamlcopiesadguard/AdGuardHome.yamlinto a tmpfs on each start, with the admin hash substituted fromADGUARD_ADMIN_PASSWORD_HASH. A blocklist enabled in the UI is enabled until the next restart; the one that lasts is a commit — the same rule the estate's dashboards live by (ADR-0004). Two settings in that file are the ones to know before touching it:ratelimit: 0, because every query arrives from one address and the default 20 qps would throttle the whole house; andallowed_clients, which ismorpheusand the blackbox prober onprometheusand drops everything else without a reply. - The admin password is a bcrypt hash, made once.
make hash-passwordprompts for it — never an argument, never in history — and the hash is what goes intosecrets/sensitive.sops.yaml. AdGuard cannot carry a second factor (ADR-0022), whichsecurity.mdalready records. - Port 53 is published on
10.0.99.40, not0.0.0.0..env.examplesays why: the host's own stub resolver holds127.0.0.53:53, and a wildcard bind fails on it. The forwarder edit onmorpheusthat makes any of this matter isforward-dns-to-adguard.md, and it is the whole client-side change. trinityconverges this stack on a timer, asprometheusdoes its own (#533, ADR-0021).homelab-converge-sensitivefetchesmainhourly, verifies the merge's signature, fast-forwards the checkout ontrinityand runsmake up STACK=sensitive, so a merged Dependabot bump lands without anyone at a shell. It was installed report-only (HOMELAB_CONVERGE_APPLY=0) and is let act by the step inconverge-the-host.md;DeployApplyDisabledfires fortrinityuntil then. By hand, it ismake converge STACK=sensitive, notgit pull. The timer is installed with the nightly backups bymake install-timers PROFILE=sensitive, andmake validateontrinityfails until both are.- Memory limits are set from day one, and now a CPU ceiling too. #129's
ask, and the one place this file departs from the lab's reasoning — a proxy
and a CA have working sets a limit can be stated for without a machine to
measure. Immich's four are ceilings rather than derivations, and
compose.yamlsays what was measured underneath them and when to re-derive. Paperless-ngx's were set before the box existed —cpus: 4of the ProDesk's six because OCR takes every core it is given for minutes, and3072mbecause upstream's floor is 2 GB for the whole install — and were first measured on the monitoring host on 2026-09-09 from the pinned images: 747 MiB working set idle, 825 MiB consuming a one-page 200 dpi scan, 22 processes. Ontrinityon 2026-09-28 they held under a synthetic backlog — five one-page scans and one of 50 pages, all 300 dpi and image-only: a peak of 1716 MiB, no OOM kill, 3.0 cores at the busiest minute and 0.2 s throttled in total, 3 min 54 s for the 50 pages, and Vaultwarden through Caddy never slower than 19 ms meanwhile. Both limits stand. Re-derive fromcontainer_memory_rssoncetrinityhas run a month. - Caddy joins the operator's group.
gen-certs.shwrites the leaf's key0640, owned by whoever ran it, and root inside a container that has droppedCAP_DAC_OVERRIDEis bound by that mode like any other uid — measured: the pinned image died onkey.pem: permission denieduntilgroup_addcarriedRENDER_GID, the way the estate's Grafana already does (#131).
- It runs as the operator, with nothing left to escalate to. Upstream's
rootless form —
user:set, noUSERMAP_*— as${RENDER_UID}, the uid that ranmake up, withcap_drop: [ALL]. Measured on the boot above:CapEffandCapBndboth zero,NoNewPrivsset, the migrations and the index rebuild ran, a scan dropped intoconsume/was OCR'd to PDF/A and deleted afterwards as that uid. That last step is why the uid is the operator's:consume/is a bind mount owned by whoever runs the stack, and a file put there over SSH by that user is removable only by them. init: false, twice, in a stack whose default isinit: true. The estate's reason for a real init as PID 1 is an image whose PID 1 never callswait(); Paperless-ngx's PID 1 is s6-overlay, which is an init, and refuses to be anything else — withdocker-initin the slot the container exits at once withs6-overlay-suexec: fatal: can only run as pid 1. Valkey's entrypoint istinifor the same reason. One init each is enough.- Not
read_only, on purpose. OCR renders every page to an image under/tmp/paperlessand the working set scales with the document; a tmpfs there is charged to the container's memory limit, so a long scan would arrive as an OOM kill instead of a slow consume. What the container writes outside its volumes, fromdocker diff: s6's state under/run/s6and that scratch directory, and nothing else. - The ingest path is the existing one. The web UI and the mobile apps
upload over 443, on the pass Hicks already has. A backlog goes in over SSH,
which the same named list carries —
rsyncintostacks/sensitive/consume/ontrinityand the consumer picks it up on inotify. No share is exported and no port is published for it, and a scanner's scan-to-folder would need both: #133 flags that as a device with hard-coded credentials on this segment, worth its own decision before it is aports:line here. - The superuser is created from the environment, once.
PAPERLESS_ADMIN_USERin.env.exampleand its password in SOPS, theGRAFANA_ADMIN_*shape. The variable never changes an existing account, so a rotation is done in the UI first and recorded in SOPS after. Enrol TOTP on that account at first login — Paperless-ngx carries its own, under the user's profile in the web UI — before a single real document arrives; that is ADR-0022's floor for this service, and it is the floor the SSO deferral rests on. - Storage is not the constraint; the photo library is. A scanned page is a few hundred kilobytes, stored twice — the original and a PDF/A copy — plus a thumbnail, so ten thousand pages is on the order of 5–10 GB. The ProDesk's 512 GB holds that many times over; ADR-0034's second drive is for Immich.
An OCR index can be rebuilt; the originals cannot. backup-volumes.sh covers
both halves #133 asks for, and it needed two things to do so: an entry per
volume in its sentinel table — the string that proves an archive holds that
volume, read off the volumes after the boot above — and one for every other
volume in this stack, none of which had one, so a stack backup here refused
on the first of them — #428's first half, landed the same day as its second,
the age recipient the script picks, which #131 moved to the stack's own
secrets file. On trinity:
STACK=sensitive make backupThat stops the three Paperless containers, archives paperless-media (the
originals, the PDF/A copies, the thumbnails — the part that cannot be
rebuilt), paperless-db-data (the database: tags, correspondents, every
document's metadata), paperless-data (the index and the classifier, both
rebuildable) and paperless-broker-data (the queue, disposable), verifies
each by its sentinel, and starts them again. A restore is
restore-the-sensitive-tier.md;
the Postgres sentinel carries the major version in its path, so a bump from 18
has to move it, loudly.
The version-portable form is the exporter — docker compose exec paperless document_exporter ../export, into export/ — which writes every document with
a manifest.json that a fresh install of the same version re-imports.
Upstream is explicit that an export does not cross versions, so it is the
form to send off-estate rather than the form to rely on across an upgrade.
One thing this does not do, stated rather than implied. It is
scheduled: homelab-backup-sensitive runs it nightly on trinity and copies
each set to oracle (#404 step 9). But nothing here is the off-estate copy
ADR-0023 requires before the first real document — encrypted, keyed to a
second holder, with visible freshness. That copy is now built: make household-copy runs the exporter above, archives export/ into a standard
set encrypted to household.recipients as well, and
carries it to the household drive (ADR-0073,
carry-the-household-copy.md).
It refuses a copy of record until there is a household holder, so the
precondition on the data arriving is still open.
The vault, and the service #131 was mostly not about deploying: "a
password vault
is the one service here where 'it is running' and 'it is recoverable' are
entirely different claims, and only the second one counts on the day it
matters." What the service does is in compose.yaml; what has to be true
around it is here.
-
Every capability dropped, and root. The same measurement Caddy records —
/datais root-owned in the image and a named volume inherits it — with one difference:ROCKET_PORTis8080, so there is no privileged bind and nothing to keep. Started under exactly the compose file's options before this was written: healthy,/aliveand/adminanswering, nothing written outside/data, under 10 MiB resident. -
Sign-up is off from the first start. #131 said "after the two accounts exist"; there is no such window. Accounts are created by invitation from
/admin, and with no SMTP configured the invited address registers itself at the vault — so no SMTP credential exists to protect, and nothing on Hicks can ever open an account of its own. -
The admin token is a hash.
VAULTWARDEN_ADMIN_TOKENin SOPS is an Argon2id PHC string, sodocker inspectshows a hash where on Grafana it shows the password. Generate it from the pinned image, on any machine with docker:docker run --rm -it "$(COMPOSE_FILE=stacks/sensitive/compose.yaml ./scripts/image-for.sh vaultwarden)" /vaultwarden hash --preset owasp
The string is five
$-delimited fields, and compose reads a$in.envas a variable reference — written raw, the value reaches the container as=19=65540,t=3,p=4behind five warningsmake upscrolls past.render-config.shdoubles every$on the way in; that was latent for every secret it writes, a Grafana password with a$in it included, and is fixed for all of them. The token itself is held nowhere in the estate. Keep it with the operator's other credentials: it is precisely the thing this vault cannot hold for you. -
The name has its own certificate, and needs a host override. The browser checks the certificate's SANs before Caddy sees a Host header, and
vaultwarden.matrix.elysiumgets a leaf of its own from step-ca because it is a site block in theCaddyfileand an alias on Caddy incompose.yaml(the alias bullet above). What it still needs from outside this stack is the host override (add-a-host-override.md). -
TOTP on both accounts at first login. ADR-0022's floor — the thing ADR-0008 offered in place of SSO, and the one service in the tier where that substitute exists and matters most. Settings → Security → Two-step login in the web vault, and the recovery code goes where the admin token goes.
-
Before it holds anything real, three things fall due at once, and they are the same moment observed three times:
- A verified restore —
restore-the-sensitive-tier.md, which also records what has already been rehearsed and what has not. - ADR-0022's decision recorded: an identity provider, or the deferral re-accepted with reasons.
- ADR-0023's Independent class met: the household's own credentials recoverable without this vault, and opened once from the other person's device without the operator present. The recommendation there is that the family's vault is hosted Bitwarden and this one keeps the operator's.
- A verified restore —
The household's notes, and the second Tier extras service —
ADR-0059 decided it before it was written. What the service does is in
compose.yaml; what has to be true around it is here.
-
Notes, not documentation. Nothing about rebuilding or recovering the estate lives in Memos as its home;
docs/is that, and #124 is adocs/problem. A note ontrinityis exactly as down astrinityis. -
Not root, and no capabilities. The image's data directory is
10001:10001, so it starts as that uid and skips the entrypoint'schownandsu-exec. Measured on the pinned image with exactly the compose file's options: healthy on/healthz, a user created, an upload stored, 15 MiB resident. -
Close registration at first login. The first account registered becomes the admin, and sign-up is a setting in the database rather than the environment, so it cannot be closed from this file. Register the admin at
https://memos.matrix.elysium, then as that admin set disallow user registration in the instance's general settings before anything else. The household's accounts are created from the admin settings after that. Check it held, ontrinity— the setting is readable without logging in, and the line should contain"disallowUserRegistration":true:docker exec sensitive-memos wget -qO- http://127.0.0.1:5230/api/v1/instance/settings/GENERAL -
Password only. Memos has no TOTP. ADR-0022 records the tier's other services without one, and ADR-0059 puts Memos beside them.
-
Not one file. The database is WAL-mode — while it runs, a fresh
memos_prod.dbis a 4 KB header and everything else is in the-wal, which a clean stop checkpoints back — and attachments are files under./assets. The backup names all three. -
Durable, under ADR-0023. No real notes before the off-estate copy the class requires, for the same reason as Paperless-ngx.
The household's page, at https://home.matrix.elysium — the one address on
this tier that people who are not the operator are given. #137 asked that it
stay honest about what it is, and the files under homepage/ are where that
is kept:
- A directory, not a status page. No status dots, no
siteMonitor, noping(settings.yamlsays why). Uptime is Grafana's question; a green dot that means "a socket opened" is worse than no dot. Grafana is linked from the page so the two are not strangers. - Household first, then the estate by VLAN. Household — photos,
documents, passwords, the house, Jellyfin and ntfy — comes first. Every
other tile sits under its host's segment, in the order and with the rack
colour of
docs/network.md's table: 🔴 Winterfell · VLAN 99 (Grafana, Prometheus, AdGuard, the firewall, the UPS, the wiki) is open; 🟢 ImaginationLAN · VLAN 30 (Proxmox, iLO, the lab's Grafana, Wazuh, Velociraptor), 🟡 CasaBonita · VLAN 40 (the NAS) and ⚪ Switch LAN start collapsed. Every console link is a login page Hicks already reaches through a named pass; listing it grants nothing. Below the tiles,bookmarks.yamlholds the house wiki and where to get each app. It is tracked because without it Homepage serves its own sample — GitHub, Reddit and YouTube, which the first deploy did. - Seven tiles read live numbers, and only with read-only credentials or
none. Immich (a key with the single permission
server.statistics) and Paperless-ngx (the token of a view-onlyhomepageuser) are read from their own APIs. Prometheus, the UPS (charge, runtime, load), the firewall (pf states), the iLO (watts) and the NAS (pool free) are read from Prometheus, which shares a /24 withtrinityand wants the estate's read-only reader token (#182), a token that can query and nothing else. That is how tiles on VLANstrinitycannot reach show numbers with no new rule: Prometheus already scrapes them, and each query is one a Grafana dashboard already runs. Numbers only, never up/down. Home Assistant, AdGuard, Vaultwarden and Grafana are links, because none of them can issue a token that reads without also being able to change something.services.yamlhas the per-service reasoning. - Weather is the one third-party call, and a coarse one.
widgets.yamlhas Open-Meteo (no key), fetched server-side fromtrinityover Winterfell's existing egress, for Bellevue rounded to one decimal place — about 10 km — because this repository is public. - Tokyo Night, frosted.
custom.cssredefines theslatepalette thatsettings.yamlnames and draws a gradient behind translucent cards (cardBlur: md); no image or font is fetched. It is tracked and mounted for the bookmarks reason: without it Homepage serves its own (empty) sample. Its selectors come from the pinned bundle, not a documented contract, so a Dependabot bump is where to look if the cards go flat. - No Docker socket, although upstream's example mounts one — the full
Docker API behind a page with no login (ADR-0022).
compose.yamlsays so at the service. - Deploy order. The two tokens can be minted only once Immich and
Paperless are up, and the compose guards refuse
make upuntil both are in SOPS — so: mint them (secrets/sensitive.example.yamlhas the clicks),make secrets-edit STACK=sensitive, add the host override forhome.matrix.elysiumonmorpheus(add-a-host-override.md), thenmake up STACK=sensitive. - Measured before it was written, on
trinity, from the pinned digest with exactly the compose options: healthy, CapEff 0, nothing written outside its tmpfs mounts, 101 MiB and 12 tasks; with placeholder tokens the Immich and Paperless widgets got a 401 from the real services through Caddy and the tier CA, which is the whole path short of a valid token.
Where the estate's alerts are delivered since #136, replacing ntfy.sh for
every channel but two. compose.yaml has the service, its measurements, and
DIFFERENCE 12. verify-the-alert-path.md
has the routing it serves and the cutover. What has to be true around it is
here.
-
Deny-all, and two users who can each do one thing.
alertmanagerpublishes to the three topics by bearer token and cannot read them.phonereads them by password and cannot publish. Nobody else can do either, anonymous or not, and nobody can sign up. The list lives in SOPS and is applied on every start, so a rotation ismake secrets-edit STACK=sensitiveandmake up. Rotating the token means the monitoring host's copy too,ALERTMANAGER_NTFY_TOKEN. -
Urgent and security go to ntfy.sh as well. A phone off the home network cannot reach this service: nothing on the tier is exposed, and the WireGuard path goes to the lab. So the two channels that page carry a second webhook to their old ntfy.sh topics.
defaultdoes not, and a warning raised while you are out waits in the twelve-hour cache until the phone is back on Wi-Fi. -
The iPhone is woken through ntfy.sh, and learns nothing else from it. iOS delivers only through APNs, which only ntfy.sh can reach. So
upstream-base-urlmakes this server post a content-free poll request there: the SHA-256 of the topic's URL, and nothing else, as the pinned image's own log showed. The phone then fetches the message from here. ntfy.sh therefore learns that a message exists, and when. It never learns what the message says, and without this the iPhone sees an alert only when the app is opened. -
The phones need the tier's root, the same as for Immich. Each phone needs the root installed and, on iOS, enabled (
build-the-tier-ca.md§6). Then, in the ntfy app on each phone:- Add the three in-house topics, with server
https://ntfy.matrix.elysiumand userphone. The password is in the password manager; the topic names are insecrets/sensitive.sops.yaml. - Keep the two ntfy.sh subscriptions. They are the off-network pager.
- On iOS, leave the app's default server at
ntfy.sh. The upstream wake-ups arrive through it. - Give
urgenta sound that wakes you anddefaultnone. That per-topic difference is why #66 split the channels.
Android's app has to honour a user-installed root for the first step to work, and it does: on 2026-09-28 the Pixel (Android 13) subscribed with the tier's root installed under Encryption & credentials › CA certificate and no setting in the app, and both phones received a test message published with Alertmanager's token.
- Add the three in-house topics, with server
-
A dead ntfy is reported through ntfy.sh. A blackbox probe of
/v1/health, verified against the tier's root, raisesEndpointUnreachable, and the failed deliveries raiseAlertmanagerNotificationsFailing. Both are critical, so both route tourgentand its ntfy.sh copy. The probe is the only HTTPS check any of this tier's sites has from outside. -
Nothing to back up.
ntfy-dataholdsuser.db, rebuilt from SOPS on every start, andcache.db, at most twelve hours of notifications that were already delivered. ADR-0023 classes ntfy as unclassed for the same reason.backup-volumes.shskips the volume by name, which also keeps ntfy up through a backup, the moment a failed backup would want to page. -
Alerts arrive as a title and a line.
ntfy/templates/homelab.ymlrenders each Alertmanager payload with a severity marker and the summary as the title, the description's first sentence and the host as the message, and a priority that follows severity. Critical is 5, which on Android is the loud channel and on iOS is time-sensitive. The full text stays in Alertmanager and Grafana. The ntfy.sh copies look the same. ntfy.sh cannot load a template file, sorender-config.shon the monitoring host passes this one inline, as URL parameters built from the file at every render. Change the file andmake renderthere as well asmake uphere. -
No second factor. ntfy has passwords and tokens. The only human account is
phone, which can read three topics of alert text and nothing else. ADR-0022 leaves ntfy out of its table for that reason: it authenticates no household identity.
The household's feed reader since #147, and the first service on this tier
that ADR-0008 did not name. ADR-0057 decided it before it was written:
the placement, the outbound traffic, and why the account has no second
factor. compose.yaml has what was measured on the pinned image.
-
Adding it to the running tier — done on 2026-09-29, and kept as the procedure for the next service.
trinitywas built before Miniflux was written, so it arrived as a later service does rather than bybuild-the-sensitive-tier-host.md, which carries it for a rebuild. Ontrinity:make secrets-edit STACK=sensitive, and addMINIFLUX_DBPASSandMINIFLUX_ADMIN_PASSWORD, each frommake gen-secret. The admin password goes in the password manager too. Commit the encrypted file.- On
morpheus, addminifluxtotrinity's Additional Names for this Host (add-a-host-override.md). make up STACK=sensitive. Caddy is recreated for its new alias and site block, and step-ca issues the name's leaf on the first request.make backup STACK=sensitive ARGS=--listafter the next nightly run:miniflux-db-datais in the set.
-
First login. Sign in at
https://miniflux.matrix.elysiumasMINIFLUX_ADMIN_USER(adminby default), withMINIFLUX_ADMIN_PASSWORDfrom SOPS. The account is created on the first start and never touched by the variable again. Rotate the password under Settings, or from the host if the UI is lost:docker exec -it sensitive-miniflux /usr/bin/miniflux -reset-passwordThen record the new value in
secrets/sensitive.sops.yaml, the way Paperless's admin password is kept. -
No second factor, and none to enrol. Miniflux has no TOTP. Its passkeys (
WEBAUTHN) are a second way to log in, not a second step — the password still logs in on its own — so they are left off. The one route to a factor is its OpenID Connect login, which waits on the identity provider ADR-0022 keeps deferring.docs/security.mdnames Miniflux beside Immich and AdGuard for that reason. -
The REST API is off. It accepts the admin's own password over basic auth, which would be a second door to the account that nothing here uses. With it off, Homepage's tile is a link rather than an unread count.
-
The sync APIs stay off until a phone wants them. Fever and Google Reader are what third-party mobile clients speak. Each is turned on per user under Settings › Integrations, with a username and password of its own that bypasses the login page. Turning one on is ADR-0057's decision 5: use a generated password, keep it in the password manager, and record here which client and when.
-
It reaches outward all the time, and never inward. Every subscription is fetched on a timer (
POLLING_FREQUENCY, sixty minutes by default), so this container is a steady source of outbound HTTPS from VLAN 99 on the firewall's graphs. The fetcher refuses every private, loopback and link-local address after DNS, so a feed URL cannot be pointed at the gateway, step-ca or anything else on Winterfell. -
Export the subscriptions now and then.
miniflux-db-datais in the nightly set, and an OPML export is the portable half that survives a version the database cannot come back to:docker exec sensitive-miniflux /usr/bin/miniflux -export-user-feeds admin > miniflux-feeds.opml
It is a reading list, not a secret. Keep it with the household's documents rather than in git.
The household's recipes, at https://recipes.matrix.elysium. It is on this
tier because the phones that use it are on Hicks, not because recipes are
sensitive (ADR-0060). Reaching it needs no rule beyond the 443 Hicks
already has.
-
Root with every capability dropped, as Vaultwarden runs.
/app/datais root-owned in the image.PUID=0andPGID=0make the entrypoint's user-switch a no-op, so it neitherchowns norgosus. Measured before this was written, under exactly the compose file's options: healthy, a login answered, three recipes imported by URL, and nothing written outside/app/data. It used 224 MiB idle and 396 MiB at the peak. -
SQLite, no database container, no SOPS secret. The signing secrets are generated into the volume on the first start and backed up with it. The admin password is a hash in the database.
-
Sign-up is off from the first start. The registration endpoint answers
403. Accounts are made by the admin, and are for the two people ADR-0008 assumes. A third person's account is ADR-0022's trigger 3. -
No second factor exists, so there is none to enrol. ADR-0060 records it beside Immich and AdGuard. OIDC is the route, the day an identity provider exists.
-
The first login is the default admin,
changeme@example.com/MyPassword, and it must be renamed and re-passworded before anyone else is told the address (build-the-sensitive-tier-host.md). Forgotten afterwards, the image's own script resets it on the running container:docker exec -it sensitive-mealie python3 \ /opt/mealie/lib/python3.14/site-packages/mealie/scripts/change_password.pyThe path names the image's Python version. If a bump moves it,
docker exec sensitive-mealie find /opt/mealie -name change_password.pyfinds it. -
URL import fetches the page a user pastes, from
trinity, over Winterfell's existing egress, and never inward. Itssafehttptransport refuses private, loopback and link-local addresses after DNS. That was measured against a container on this network,10.0.99.1and10.0.99.20, and no request arrived.HTTP_ALLOW_LISTis the one setting that opens it, andcompose.yamlwrites it out empty. Adding a host to it is a hole into Winterfell.
The household's bookmarks, at https://links.matrix.elysium (#144). It is
the fourth service here that ADR-0008 does not name, and ADR-0061 is the
decision that put it on this tier. compose.yaml has the service and what was
measured on the pinned image. What has to be true around it is here.
- Root for the bootstrap, uid 33 for everything that serves. The image's
bootstrap.shruns as root, migrates, creates the superuser, and chowns the volume towww-data. Then uwsgi drops to 33. It keeps four capabilities out ofALLfor that half and holds none afterwards.compose.yamlsays what each one is for, including the one that only matters on the second start: withoutDAC_OVERRIDE, a migration fails and the container still reports healthy. - One account, and nobody signs up. linkding has no self-registration. The
superuser is created on first start from
LINKDING_SUPERUSER_NAMEin.envandLINKDING_SUPERUSER_PASSWORDin SOPS. After that, the variables do nothing, so a rotation is done in linkding's settings and recorded in SOPS afterwards. A second person's account is made in/admin, and it counts toward ADR-0022's third trigger like any other account on the tier. - No second factor. linkding has none of its own. It offers OIDC, or trust
in a proxy header, and neither exists here yet. It sits with Immich,
AdGuard Home, Miniflux, Memos and Mealie in
security.md's list of services that cannot carry one. ADR-0061 records why that is accepted for a list of links, and that OIDC is how it would get one if ADR-0022's decision brings an identity provider. - No favicons, on purpose.
LD_DISABLE_BACKGROUND_TASKSkeeps linkding from asking a third party for an icon for every site in the list. Adding a bookmark still fetches that page's own title and description. - Archiving pages is not what this is. The
-plusimage, with Chromium, would save snapshots. ADR-0061 leaves that want to a different service and a new decision. - Adding it to the running tier, as Miniflux is added. On
trinity:make secrets-edit STACK=sensitive, and addLINKDING_SUPERUSER_PASSWORDfrommake gen-secret. It goes in the password manager too. Commit the encrypted file.- Done 2026-09-29.
linksis one oftrinity's Additional Names for this Host onmorpheus(add-a-host-override.md). It answered10.0.99.40frommorpheusthat day, with the reverse entry stilltrinity. Until step 3, HTTPS to it fails at the handshake: the running Caddy has no site for the name yet. make up STACK=sensitive. Caddy is recreated for its new alias and site block, and step-ca issues the name's leaf on the first request.make backup STACK=sensitive ARGS=--listafter the next nightly run:linkding-datais in the set.
- Backed up with the tier.
linkding-datais archived nightly with the other volumes. The sentinel issecretkey.txt, becausedb.sqlite3is already Vaultwarden's. The database and its-walare reported beside it. A restore without the key logs everyone out, and loses nothing else. A Netscape HTML export from linkding's settings also imports into any browser, which makes it a copy nothing here is needed to read.
The household's budget (#142), and the fifth service here beyond ADR-0008's nine, after Miniflux,
Memos, Mealie and linkding.
ADR-0062 is the decision and why it is Actual rather than Firefly III.
compose.yaml has the service and what was measured on the pinned image.
What has to be true around it is here.
-
Claimed before it is reachable. Actual has no password setting. A fresh server offers "set a password" to the first client that reaches it, and accepts the answer once.
make uprunsscripts/seed-actual-password.shfirst. It claims an empty volume withACTUAL_SERVER_PASSWORDfrom SOPS, inside the pinned image with--network none, before the service ever starts, and on every run after that it logs in with the SOPS value as a check. The value is never handed to the container, sodocker inspectdoes not show it and it is not in.env.--checkchecks the running service and changes nothing. -
One password for the household, and one session for every device. In password mode Actual has a single user. Every device that logs in is handed the same session token, and by default it never expires. Measured on 26.9.0: a changed password leaves that token valid, so every device stays signed in. To sign every device out, stop the service, delete the sessions, and start it again:
docker compose -f stacks/sensitive/compose.yaml stop actual docker run --rm --network none --user 1001:1001 --read-only --cap-drop ALL \ -v sensitive_actual-data:/data --entrypoint node \ "$(COMPOSE_FILE=stacks/sensitive/compose.yaml ./scripts/image-for.sh actual)" \ -e "console.log(new (require('better-sqlite3'))('/data/server-files/account.sqlite').prepare('DELETE FROM sessions').run().changes)" make up STACK=sensitive
It prints the number of sessions deleted (one). The old token then gets 401, and the next login is issued a new one. Measured on a throwaway volume.
-
Changing the password. Change it in Actual (Settings › Change password), or on
trinitywithdocker exec -it sensitive-actual node src/scripts/reset-password.js, which needs a terminal. Then put the same value in SOPS. Change SOPS alone and the nextmake upwarns that the SOPS password no longer logs in, and changes nothing. Follow a change made because the password leaked with the sign-out above. -
Password login only.
ACTUAL_ALLOWED_LOGIN_METHODSispassword. Header login would take the password in a header from any "trusted proxy", and the image trusts every private range by default. OpenID would be ADR-0022's decision. Logins and the first-run claim allow five failures per client per fifteen minutes, andACTUAL_TRUSTED_PROXIESnames Caddy alone, so the client counted is the phone rather than the proxy. -
No bank sync. GoCardless and SimpleFIN are configured in the app, and neither is. Transactions come in as imported files (OFX, QFX, QIF, CSV, CAMT). Turning bank sync on is a decision (ADR-0062 §4). It puts a third party's credentials in
account.sqliteand has the server reach out on a schedule. -
Clients are a copy, not a backup. Every client holds the whole budget, which survives losing this server. It does not survive a bad sync, which arrives on every client. The nightly set archives
actual-datawith the service stopped. The sentinel is./server-files/account.sqlite, and the budgets are in./user-files. -
Durable, and no second factor. ADR-0023 classes Actual with Immich and Paperless-ngx: it may be down, it may not be lost. ADR-0022's table has it among the services with no second factor. Actual has none short of OpenID.
-
The name needs a host override.
actual.matrix.elysiumis a site block in theCaddyfileand an alias on Caddy, so step-ca issues it a leaf. The override onmorpheusisadd-a-host-override.md's.
The household's PDF editor since #143. It does the jobs that otherwise go to
a free converter website: merge, split, rotate, convert, OCR, sign, compress.
ADR-0063 is the decision. compose.yaml has the service, the measurements
and DIFFERENCE 13. What has to be true around it is here.
- Documents never reach a disk. Uploads, intermediates and results live in
/tmp, a 1 GiB tmpfs counted against the container's 3 GiB limit. Stirling deletes a job's files when the job ends, sweeps anything a failed job left every ten minutes, and a restart erases the rest. The one volume,stirling-pdf-configs, holds accounts and settings. If a document ever turns up there, something has gone wrong. - A big enough job fails instead of spilling. A scan that fills the tmpfs, or an OCR that pushes the container past its limit, fails with an error, and the fix is to split the document. Caddy refuses uploads over 256 MB before they reach it. On 2026-09-29 a 40-page 300 dpi OCR peaked at 1.4 GiB. No household document is near the limit.
- The admin comes from SOPS, once.
STIRLING_ADMIN_USERin.env.exampleandSTIRLING_ADMIN_PASSWORDin SOPS, read on the first start against an empty volume and never again. A rotation is done in the UI first and recorded in SOPS after. SOPS matters here more than for Paperless, because the volume is not backed up and a rebuild recreates the admin from it. Enrol TOTP at first login, in the account settings, before the first real document. Stirling marks the seeded admin as MFA-required. That is ADR-0022's floor. - Nothing phones home, and the hardening was checked, not just set.
Analytics, PostHog, Scarf, the update check, URL-to-PDF, the AI engine and
the mobile QR upload are off. CORS is pinned to the one name, and the heap
dump on OOM is off because it would write a document to
/configs. The running app's/api/v1/config/app-configshowed each of these on a boot with no route out. - Its hardening had two costs, both found by running it.
- The entrypoint
ln -ses diagnostics shortcuts into/usr/local/binunderset -e, which kills the container on a read-only root./dev/nullmounted over the script it links makes it skip that step. - The PDF engine unpacks shared libraries into
/tmp, so that tmpfs isexec. Without it the container is healthy and every pdfium tool answers 500, which is why CI and the check below run a tool rather than trusting the healthcheck.
- The entrypoint
- LibreOffice runs sandboxed, as the same uid. The service starts as
stirlingpdfuser(1001), so the entrypoint cannot give LibreOffice a uid of its own. It keeps its Landlock and seccomp sandbox, andSTIRLING_LO_SANDBOX=requiredrefuses a conversion on a kernel that cannot provide it. Its startup log line says which: "LibreOffice sandbox active (lo-sandbox: landlock ABI 8, seccomp active)". - Nothing to back up.
backup-volumes.shskips the volume by name, and ADR-0023 classes the service as unclassed. A rebuild costs the admin a TOTP re-enrolment.
CI does this on every change to the service or its image.
stirling-pdf/smoke.sh runs after the hardened boot:
it logs in as the seeded admin, merges two pages through pdfium, and fails on
anything but a PDF back. On trinity, after a deploy, do the same by hand:
merge two PDFs in the UI, then
docker logs sensitive-stirling-pdf 2>&1 | grep -E 'sandbox active|UnsatisfiedLink'The first line should appear; the second should not.
make backup STACK=sensitive
make restore STACK=sensitive ARGS="--dry-run --from latest"backup-volumes.sh derives the volume list from compose.yaml and refuses a
volume it cannot verify, so each of the fifteen volumes it archives has a
sentinel entry there — db.sqlite3 for Vaultwarden and memos_prod.db for
Memos, each read off a boot of the pinned image, beside the entries #133
read off boots of every other — and
restore-volumes.sh knows the uid each must come back owned by where that
uid is a constant. Three are skipped by name. ntfy-data is skipped because
ntfy rebuilds its users from SOPS on every start and its cache is notifications
already delivered. adguard-work is skipped because
archiving it would stop the house's only DNS forwarder, and nothing in it is
worth restoring. immich-model-cache is skipped because a
downloadable cache is not data, and a fresh host whose models have not been
fetched yet would otherwise fail the whole run on an empty archive. Both
scripts encrypt to every recipient of
secrets/sensitive.sops.yaml, read from the file itself: trinity's key,
and the technical second's once it joins the rule. Until #131 they took the
first key in .sops.yaml whichever rule it belonged to, which would have
encrypted the estate's weekly backup to trinity's key the day the placeholder
was filled — the two defects #428 describes.
When it runs, and where the sets go. homelab-backup-sensitive runs it
every night at 04:30 (#404 step 9). Each set is copied to oracle and
checked there by sha256 (#535). The run's outcome is the backup-sensitive
job in the estate's ScheduledJob* alerts, with a two-day threshold. At 06:30
homelab-verify-backups-sensitive reads every retained set again, here and
on oracle, along with the library's sets
(#856). A set that rots after
the night it was written fails the next morning, as verify-backups-sensitive.
The units and their installer are in
schedule-maintenance.md.
What this does not give is a copy off the estate. oracle is in the same
room and on the same power, and ADR-0023 requires that copy before Immich or
Paperless-ngx hold a real file. It is step 10's, and ADR-0073's:
built, and waiting on a holder.
And the volumes are not the photographs: the library is a bind mount, and
no volume set contains it. Its own sets are the next section's.
The photographs are the household data most likely to be irreplaceable, and ADR-0023 classes Immich as durable: it may be down, it may not be lost, and an off-estate copy whose staleness is visible was to exist before the first real photo arrived. It did not; the warning below is the record. Three things hold the data. Each is copied off the host now, and none is copied off the estate: the copy ADR-0023 requires, the one that covers the first two rows, still does not exist.
Warning
The first real photographs arrived before that copy did. Two accounts
uploaded 615 assets between 16:59 and 17:01 UTC on 2026-09-28 — the day the
host was built, with #455 undelivered and ADR-0022's record and
ADR-0023's Independent test still open. For their first day the USB disk
was the only copy of the originals anywhere. Since 2026-09-29 a nightly set
goes to oracle (ADR-0064), so losing the disk no longer loses them. That
copy is in the same room and on the same power. Since 2026-10-01 it is
encrypted to household.recipients as well as to
trinity's key (ADR-0073). It does not satisfy ADR-0023, and this
warning stands until #455 exists.
| What | Where | Protected by |
|---|---|---|
| The originals | IMMICH_UPLOAD_LOCATION — the USB disk |
Off-host, interim: make backup-library, nightly at 05:15 (ADR-0064). One encrypted archive of upload/, library/, profile/ and backups/, two sets kept on trinity's SSD and two on oracle, and ARGS=--prove hashes each original against immich-db. Each set is encrypted to the sensitive rule's recipients and to household.recipients (ADR-0073). Off-estate: built, waiting on a holder. make household-copy carries the newest set to the household drive, a WD Elements 5 TB under #455, every ninety days (ADR-0073, carry-the-household-copy.md). It refuses a copy of record until household.recipients has a household key |
| Thumbnails and transcodes | The same disk, thumbs/ and encoded-video/ |
Nothing, on purpose. They are derived from the originals, and a restore regenerates them from Immich's Jobs page. Only their .immich markers are archived, because the server will not start without them |
| Immich's own nightly database dump | IMMICH_UPLOAD_LOCATION/backups/, .sql.gz, fourteen kept, 02:00 by default |
It sits on the same disk on purpose, so a copy of the disk is also a copy of the metadata. It rides in every library set, three hours old at most, so each set is a restore unit on its own |
| The live database | The immich-db named volume, on the SSD |
make backup STACK=sensitive, since #131 closed #428: sentinel PG_VERSION, owner 999, encrypted to trinity's own recipients, and copied to oracle by the same run. Immich's dump on the USB disk is the second route to the same metadata |
The restore that #132 asks to see proven once is Immich's own: a fresh
install, the library tree back on its disk, and a dump fed to psql inside
immich-db before the server first starts. Rehearsed on trinity on
2026-09-28 against copies of the real library, by both routes in the table —
Immich's dump, and the immich-db volume out of a make backup set — with
every one of the 615 originals hashed against the checksum the restored
database holds for it. The procedure, what it proved and what it did not are
restore-the-sensitive-tier.md § Restore Immich.
Upstream calls the database-first order a hard rule; on v3.2.2 the rehearsal
found it is a safety rule instead, and the runbook says why it is kept anyway.
On 2026-09-29 it was rehearsed again from a library set pulled back off
oracle, the case of losing the USB disk. The dump inside the set restored
into a scratch immich-db, ok=615 bad=0 held, and the thumbnails and
transcodes regenerated.
make validateEvery checker in scripts/validate.sh iterates scripts/stacks.sh, so this
stack is covered by everything the estate's is: docker compose config, the
image-pin and digest checks, check_compose_health.py --probe (which execs
each healthcheck binary inside the pinned image — wget in Caddy's, step in
step-ca's, curl, pg_isready and valkey-cli in Paperless-ngx's three), and
check_caddyfile.sh, which runs caddy validate and
caddy fmt --diff against the Caddyfile in the pinned image with a
throwaway keypair where the real one will be mounted.
What make validate still does not prove about this stack, in the order
it matters:
- That it runs on
trinity. That is proved by running it there, which it has since 2026-09-28 under #404, bybuild-the-sensitive-tier-host.md§9. Before the host existed, what had been run was the pieces in isolation, on the monitoring host: the Immich services on 2026-09-09 with these exact settings, an admin created, an upload made and the ML models fetched, which is how the read-only findings incompose.yamlwere made; Paperless-ngx and its two dependencies the same way, a scan consumed and deleted as the operator's uid; Caddy with thisCaddyfileunder the compose file's options; Vaultwarden likewise; and a fullmake backup/make restoreround trip of four of the volumes with a seeded account in the vault — the runbook says exactly what that proved. That was a rehearsal of the file, not of the host. - That the CA tree exists.
step-castarts only against a populated volume, and the volume is populated by a procedure run on two hosts. A freshmake upon a baretrinityfails onconfig/ca.json, loudly and on purpose. - That ACME issuance works.
caddy validateprovisions the issuer against a throwaway root and dials nothing. Whether step-ca answers, validates the challenge and signs is proved by the firstmake up— Caddy's log sayscertificate obtained successfullyand the served chain verifies againstcertificates/tier-ca.pem— and it was proved once on the monitoring host under a throwaway project before this was written (build-the-tier-ca.md§Verify). - That the limits fit the workload. 4 cores and 3 GiB for OCR are a statement about the ProDesk made on a different machine.
- That Home Assistant keeps booting under its hardening. It was booted
once, on the monitoring host on 2026-09-09, from the pinned digest with the
exact
compose.yamlsettings — read-only root, every capability dropped, the two tmpfs mounts, this directory'sconfiguration.yaml— on an internal Docker network with no route out. It served onboarding, wrote only to/config, and logged one error it will log on every start: thedhcpdiscovery integration wantsCAP_NET_RAWto sniff for devices, which on a bridge network a VLAN away from every device would sniff nothing, so the capability stays dropped and the line is expected. That was one boot of one digest, and Dependabot moves the digest monthly, so since 2026-09-23 CI re-runs it:scripts/check_hardened_boot.shboots the service from thiscompose.yamlon an internal network, waits for its healthcheck, and reads read-only root,CapDrop=ALLand no-new-privileges back from the running container (#534). A bump that does not boot hardened cannot merge. A service whose healthcheck can pass while its work fails also carries asmoke.shin its config directory, which the check runs next; Stirling-PDF is the first. What it still cannot tell you is whether the integrations you add later load under the same hardening;make check-hardened-bootis the same boot, run by hand. - That AdGuard filters. Its blocklists are downloaded on first start and
live in the
adguard-workvolume from then on, so the firstmake upneeds the internet and every later one does not.make validatechecks the file parses as YAML and nothing about what it says; the blackbox filtering probe fromprometheusis what checks the service actually blocks the canary, and enabling it is a step in the forwarder runbook.