Containers#
NixOS container modules live under modules/nixos/containers/. Each runs an
OCI workload via Podman (virtualisation.oci-containers.backend = "podman",
dockerCompat = true) using the compose2nix pattern: explicit per-container
systemd units, dedicated podman-network-*/podman-volume-* oneshots, and a
podman-compose-*-root.target that ties them together.
Only four of these modules are wired into the flake. modules/nixos/default.nix
imports:
./containers/actual-budget./containers/doc-pipeline./containers/esphome./containers/open-webui
The containers/n8n and containers/test directories also exist but are not
imported anywhere — they are raw compose2nix output (see the
# Auto-generated using compose2nix headers) and are inert until added to the
imports list. The four imported modules are enabled on ereshkigal.
tsunaminoai.actualBudget#
Actual Budget’s sync server, defined in
modules/nixos/containers/actual-budget/default.nix. Actual is local-first: the
real application is the browser/desktop client, and this server only holds the
account database and one encrypted blob per budget file.
A single container, actual-budget
(docker.io/actualbudget/actual-server, pinned via the image option), on host
port port (default 5006, → container 5006). Unlike the other container
modules there are no podman-network-*/podman-volume-* oneshots — one
container on a host bind mount needs neither — but the
podman-compose-actualbudget-root.target convention is kept.
State lives in dataDir (default /var/lib/actual), bind-mounted at /data:
server-files/account.sqlite— hashed server password, the budget-file index and the active session token.user-files/*.blob— one blob per budget file.
It is single-digit MB in practice, so it stays on the root filesystem rather
than a kurVolumes LV. The image currently runs as root (upstream’s Dockerfile
creates an actual user but emits no USER directive), which is why the bind
mount is root-owned and why image is pinned to a release tag.
Ingress and authentication#
Setting externalUrl drops the container publish to 127.0.0.1, closes the
firewall for port, and leaves two routes in:
budget.inaba.network— thebudgetentry intsunaminoai.cloudflare.origins(origin port9110). This is the user-facing path.https://<host>.<tailnet>:<port + 1>— a step-ca TLS vhost for admin/break-glass access, present whenevertsunaminoai.pki.acme.enableis set.
Authentication is layered. Cloudflare Access gates the tunnel against the Entra group, and Actual then runs its own OIDC login directly against Entra — an app registration in the tenant, not a Cloudflare Access SaaS app.
This is the one origin that deliberately has no oidc.enable. The other OIDC
consumers (Grafana, Kavita, Audiobookshelf, Jellyfin) let terranix create a CF
Access SaaS app and make Cloudflare the identity provider; Actual talks to
Entra itself, so the budget origin stays a plain self_hosted Access app.
Nothing in the flake manages the Entra side — modules/flake/terraform only
loads the cloudflare provider, and the Entra IdP and groups are referenced as
externally-managed GUIDs.
ACTUAL_OPENID_ENFORCE is deliberately left unset, so Actual’s own password
login still works on the tailnet vhost.
Actual’s other SSO mode, ACTUAL_LOGIN_METHOD=header, is not used: it
expects the proxy to inject the user’s password in x-actual-password, which
is not an identity assertion. There is no trusted identity header anywhere in
this module.
Bootstrap order:
- Deploy with
oidcEnvFilepointing at a placeholder secret, and set the server password over the tailnet vhost. - Create an app registration in the Entra tenant with
https://budget.inaba.network/openid/callbackas a Web platform redirect URI, and generate a client secret (shown exactly once). sops setthree lines intoactual-budget/oidc-envand redeploy:
ACTUAL_OPENID_DISCOVERY_URL=https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration
ACTUAL_OPENID_CLIENT_ID=<application-client-id>
ACTUAL_OPENID_CLIENT_SECRET=<client-secret-value>
Podman passes the file through --env-file, so the secret never enters the
Nix store.
4. Log in at budget.inaba.network via OpenID. The first OIDC login becomes
the permanent server owner and cannot be changed from the UI.
Registering the tailnet URL as a second redirect URI buys nothing: Actual emits
exactly one redirect_uri, built as ACTUAL_OPENID_SERVER_HOSTNAME +
/openid/callback, and reuses redirect_uris[0] on callback. Port 5007
stays password-auth.
ACTUAL_USER_CREATION_MODE=login auto-provisions subsequent accounts, which is
safe because Cloudflare Access has already checked the Entra group before Actual
sees the request.
Check which claim becomes the owner before the first login
Actual resolves the account identity from the userinfo response, in the
order preferred_username ?? login ?? email ?? id ?? sub. Entra’s userinfo
endpoint does not return preferred_username, so the identity lands on
email — or on the opaque sub GUID if the account has no mail attribute.
Since the first OIDC login is permanently the server owner, confirm what
the tenant returns before logging in.
Backups#
dataDir and /var/backup/actual-budget are both added to
services.borgmatic.configurations.voile.source_directories, because the data
directory holds two different kinds of file:
| File | Written | How it is backed up |
|---|---|---|
user-files/file-<id>.blob |
whole, on upload | archived live from dataDir |
server-files/account.sqlite |
continuously | sqlite3 .backup snapshot |
user-files/group-<id>.sqlite |
continuously | sqlite3 .backup snapshot |
The group-<id>.sqlite files are easy to mistake for static blobs because they
sit beside them, but they are live databases — the CRDT sync log, one row in
messages_binary per sync message. A raw copy taken while a client is syncing
can be torn, so actual-budget-backup-stage.service snapshots every
*.sqlite under dataDir into the mirrored path beneath
/var/backup/actual-budget/.
The unit is ordered Before=borgmatic.service rather than being a borgmatic
before_backup hook — hooks inherit borgmatic’s hardened sandbox. Each
snapshot is promoted from a temp file only on success, so a failure leaves the
previous good copy in place, and the unit exits non-zero so a persistent
failure surfaces as SystemdUnitFailed rather than silently stale backups.
At restore time the staged tree under /var/backup/actual-budget/ is
authoritative over the same paths inside dataDir, which may be torn.
Snapshots of deleted budget files are left in place deliberately.
tsunaminoai.docPipeline#
Paperless-NGX document archive plus its LLM sidecars, defined in
modules/nixos/containers/doc-pipeline/default.nix.
Containers in the paperless_default Podman network:
- paperless-web (
ghcr.io/paperless-ngx/paperless-ngx:latest) — the web UI, on host portpaperlessPort(default8011, → container8000). - paperless-db (
postgres:17-alpine) and paperless-broker (valkey/valkey:9-alpine) — internal-only Postgres and Redis-compatible broker. - paperless-gpt (
ghcr.io/icereed/paperless-gpt:latest) — optional sidecar (paperlessGpt.enable, defaulttrue) for LLM auto-tagging/titling and vision OCR, on host portpaperlessGpt.port(default8013). - anythingllm (
mintplexlabs/anythingllm:latest) — optional RAG chat over the library (anythingLlm.enable, defaulttrue), on host portanythingLlm.port(default13001).
Inference is delegated to Ollama on another host via the ollamaHost option
(use mokou’s Tailscale FQDN); ollamaModel (default qwen2.5vl:7b) is the
vision model. Containers reach that host through the host routing table via
--add-host=host.containers.internal:host-gateway.
The module NFS-mounts voile’s document share (voileSharePath, default
/volume2/Books) at /mnt/voile/documents over the 10G backhaul and uses
consume/export subdirectories there for ingest and archival copies.
Paperless state is staged into /var/backup/paperless by borgmatic
before_backup hooks (Postgres dump plus snapshots of the paperless_media and
paperless_data volumes) for the existing borg job to voile.
The paperless-gpt API token comes from the sops secret paperless/api-token,
formatted into a sops.templates env file and injected via the systemd
EnvironmentFile — never inlined.
tsunaminoai.esphome#
ESPHome dashboard/builder, defined in
modules/nixos/containers/esphome/default.nix.
Runs ghcr.io/esphome/esphome:latest with --network=host so it can reach IoT
devices on a separate VLAN and participate in mDNS on the LAN. Key options:
port(default6052) — dashboard web UI, opened only onlanInterface(defaultvmbr0).configDir(default/var/lib/esphome) — mounted as/config. Managed device configs are bind-mounted in directly (ESPHome rejects/nix/storesymlinks);secrets.yamlmust be placed manually inconfigDir.iotVlan.{network,prefixLength,gateway}— a static route is installed vianetworking.localCommandsso the host forwards IoT VLAN traffic to the gateway.
Avahi is enabled on lanInterface for LAN-side mDNS discovery.
tsunaminoai.openWebui#
Open-WebUI LLM chat + RAG knowledge base, defined in
modules/nixos/containers/open-webui/default.nix.
Runs ghcr.io/open-webui/open-webui:main on host port port (default 3000,
→ container 8080) in the openwebui_default network. It talks to Ollama via
ollamaHost (mokou over Tailscale) for both chat and nomic-embed-text
embeddings, with an embedded ChromaDB vector store. Login is required
(WEBUI_AUTH = "true"; first registered user becomes admin).
A nightly systemd timer (paperless-openwebui-sync, OnCalendar = 02:00) pulls
already-OCR’d Paperless documents into an Open-WebUI knowledge collection. It
reads the sops secrets paperless/api-token and openwebui/api-key.
TLS reverse proxy#
When tsunaminoai.pki.acme.enable is set, doc-pipeline and open-webui add nginx
virtual hosts serving each UI over HTTPS on port + 1, using the host’s
step-ca ACME certificate (useACMEHost = <hostname>.<tailscaleDomain>). All
three modules register their UIs as Homer dashboard entries.
Torrent traffic routing#
Routing torrent traffic out over Tailscale is not done with network
namespaces. It is handled by modules/nixos/servarr/tailscale-routing.nix,
which enables IP forwarding and installs iptables mangle rules that mark
BitTorrent/qBittorrent/Transmission ports (and the media user’s traffic) with
fwmark 1, then routes marked packets out tailscale0 via a dedicated routing
table (ip rule add fwmark 1 table 100). See the servarr module for the full
stack.
Related#
- Paperless — the biggest compose stack.
- Open-WebUI — an OCI-container module.
- ESPHome — another containerized service.