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 — the budget entry in tsunaminoai.cloudflare.origins (origin port 9110). This is the user-facing path.
  • https://<host>.<tailnet>:<port + 1> — a step-ca TLS vhost for admin/break-glass access, present whenever tsunaminoai.pki.acme.enable is 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:

  1. Deploy with oidcEnvFile pointing at a placeholder secret, and set the server password over the tailnet vhost.
  2. Create an app registration in the Entra tenant with https://budget.inaba.network/openid/callback as a Web platform redirect URI, and generate a client secret (shown exactly once).
  3. sops set three lines into actual-budget/oidc-env and 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 port paperlessPort (default 8011, → container 8000).
  • 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, default true) for LLM auto-tagging/titling and vision OCR, on host port paperlessGpt.port (default 8013).
  • anythingllm (mintplexlabs/anythingllm:latest) — optional RAG chat over the library (anythingLlm.enable, default true), on host port anythingLlm.port (default 13001).

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 (default 6052) — dashboard web UI, opened only on lanInterface (default vmbr0).
  • configDir (default /var/lib/esphome) — mounted as /config. Managed device configs are bind-mounted in directly (ESPHome rejects /nix/store symlinks); secrets.yaml must be placed manually in configDir.
  • iotVlan.{network,prefixLength,gateway} — a static route is installed via networking.localCommands so 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.