2 22 Wrapper Repo Service Packaging
Nimmo edited this page 2026-07-29 21:26:57 +01:00

Wrapper-Repo Service Packaging Runbook

Status: reference Created: 2026-06-30

Context

Trialled end-to-end with Librarry (since removed — the app itself didn't fit, but the packaging pattern worked cleanly and is worth reusing). The goal: bring a third-party service that only ships Docker images/Dockerfiles into this repo as a real Nix package and NixOS module, with no Docker involved at deploy time, instead of dropping a docker-compose.yml under /srv/stacks. This is the "last resort is Docker" counterpart to the Forgejo Service Stack NixOS Migration workstream, which covers migrating existing Docker stacks the other direction.

Use this when: a service has no nixpkgs package, you want it declaratively managed, and you're willing to own the upstream pin yourself (no nixpkgs maintainer doing it for you).

Don't use this when: the upstream build is genuinely exotic (non-standard toolchain, requires proprietary build steps, vendors huge binary blobs) — that's when Docker actually is the pragmatic last resort.

Step 1 — Assess buildability before committing to anything

Read the upstream repo's actual source layout and Dockerfiles (even though you won't use them) — they document the real build steps better than any README. Look for:

  • A single, ordinary language toolchain per component (Go module, npm/Vite frontend, etc.) rather than a bespoke multi-tool pipeline.
  • No CGO / native dependencies that complicate buildGoModule-style builders (check go.mod for sqlite drivers, cgo-only packages, etc.).
  • Where migrations/static assets live relative to the build output, since you'll need to wire those paths into the Nix package and the systemd unit.
  • How the production Dockerfile actually serves things (e.g. Vite build copied into an nginx image) — this tells you whether you need an nginx vhost yourself, or whether the app serves its own frontend.
  • Read the upstream reverse-proxy config (nginx.conf/Caddyfile) line by line, especially proxy_pass targets. This is the single thing most likely to bite you later (see Gotchas).
  • Whether the app stores integration credentials in its own database via a settings UI (common in Servarr-style apps) rather than purely via env vars — if so, env vars are just bootstrap defaults, and you don't need to wire those as NixOS secrets at all.

Step 2 — Create the wrapper repo

A separate repo (e.g. git.nimmog.uk/nimmo/<service>-nix), not a subdirectory of this repo. It's a thin packaging flake:

inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.<service>.url = "github:<upstream>/<repo>";
inputs.<service>.flake = false;

outputs = { nixpkgs, <service>, ... }: {
  packages.<system>.<service>-api = pkgs.buildGoModule { src = <service>; ... };
  packages.<system>.<service>-web = pkgs.buildNpmPackage { src = "${<service>}/web"; ... };
};

Compute vendorHash/npmDepsHash for real rather than guessing — set a placeholder lib.fakeHash-style zero hash, run nix build, and read the correct hash out of the "got:" line in the error. Do this locally before pushing; it's fast and avoids burning CI cycles on hash-mismatch failures.

Commit and push a .gitignore for result*.

Step 3 — Automate tracking upstream HEAD (if no tagged releases exist)

A lightweight scheduled Forgejo Actions workflow in the wrapper repo:

nix flake update <service>
nix build .#<service>-api .#<service>-web --no-link   # sanity check
git commit -am "chore: track <service> HEAD" && git push   # only if changed

Twice a day is a reasonable cadence (e.g. 0 2,14 * * *). No Attic push/build-and-publish step is needed here — that's this repo's job (see Step 4), since a plain Nix flake package is already cacheable through the normal build pipeline; only OCI images need a separate publish step.

Step 4 — Wire into nixos-config

# flake.nix
<service>-nix.url = "git+ssh://git@git.nimmog.uk:2222/nimmo/<service>-nix.git";
<service>-nix.inputs.nixpkgs.follows = "nixpkgs";

inputs is already passed through specialArgs to every module, so no further plumbing is needed to reach inputs.<service>-nix.packages.${system} from a host module.

Add a group to nixos_update_run_input_groups() in scripts/nixos-update-common.sh, scoped to whichever host(s) actually consume it — this gets you the same atomic accept/reject-on-build-failure behaviour every other input gets, on both the host-side nixos-auto-update.sh and the scheduled attic-cache.yml CI run, for free:

if nixos_update_input_group "<service>-nix" "$(printf '<host>\n')"; then
  ...

Run nix flake lock once to pick up the new input.

Step 5 — Write the NixOS module

  • modules/services/<service>.nix, imported from the consuming host's default.nix — no enable-flag abstraction needed unless multiple hosts will use it (see AGENTS.md's "don't add abstractions beyond what's needed").
  • Secrets in their own scoped secrets/<service>.yaml file — see the "Per-service secrets files" pattern below.
  • Reference packages as inputs.<service>-nix.packages.${pkgs.stdenv.hostPlatform.system}.<name>.
  • If the app expects integration settings via its own DB/UI rather than env vars (see Step 1), don't manufacture NixOS secrets for those — let the user configure them in-app after first boot.

Per-service secrets files

Each new service gets its own secrets/<name>.yaml. Add a specific .sops.yaml creation rule for its consuming hosts and admin recipients; the broad fallback rule is only for files not yet scoped. You can create the new file (with placeholder values) without a decrypt key, since sops encryption only needs the public age keys already in .sops.yaml:

printf 'db:\n  user: CHANGEME\n  password: CHANGEME\n' \
  | sops --encrypt --input-type yaml --output-type yaml \
      --filename-override secrets/<name>.yaml /dev/stdin \
  > secrets/<name>.yaml

Keep in-file YAML keys short and unprefixed (db/user) — they're already namespaced by the filename. The thing that must stay globally unique across the repo is the NixOS-level sops.secrets."<name>" attribute (left side), since that's one shared attrset and also determines the /run/secrets/<name> runtime path — prefix those with the service name (<service>-db-user).

Use just gen-secret for opaque API-key-style values (defaults to a 32-char hex string, matching the Sonarr/Radarr/Servarr GUID-derived format) and just edit-secrets <name> to open secrets/<name>.yaml.

Networking: prefer the Nebula mesh over 0.0.0.0/LAN

If a service doesn't need to be reachable from the LAN generally, bind its nginx vhost to config.nixosConfig.nebula.overlayIp rather than 0.0.0.0, and scope the firewall rule to the mesh interface specifically rather than a global allowedTCPPorts:

networking.firewall.interfaces."nebula.mesh".allowedTCPPorts = [ port ];

Order the systemd unit after nebula@mesh.service (not just network-online.target) if it talks to anything else over the mesh — see CoreDNS's own config in modules/networking/nebula.nix for the established pattern (bindsTo/after on the nebula device unit).

If a Docker container also needs to reach a mesh-bound service on the same host, know that its traffic arrives via the Docker bridge interface (e.g. br-xxxxxxxxxxxx), not the mesh interface — even though the destination IP belongs to the mesh. An interface-scoped firewall rule for nebula.mesh alone won't match that traffic; you'd need a second rule for the bridge interface specifically (and that interface name is derived from the Docker network's ID, so it changes if the network is ever recreated — fragile by nature). This is usually a sign the other service should migrate off Docker rather than punching a hole for it.

Step 6 — Validate before ever touching the live host

nix flake check
nix build .#nixosConfigurations.<host>.config.system.build.toplevel --no-link

The second command catches anything flake check doesn't — including sops secret keys that don't exist yet (it fails loudly and specifically: secret <name> in <file> is not valid: the key '<key>' cannot be found, which is actually a useful signal that the wiring is otherwise correct).

Gotchas worth remembering

  • proxy_pass trailing slash silently strips the location prefix. If upstream's own nginx.conf uses proxy_pass http://api:8080; with no trailing path, the backend's router expects the full incoming path (/api/v1/...) — mirroring that with a trailing slash (proxy_pass http://127.0.0.1:8080/;) makes nginx rewrite /api/v1/... to /v1/... instead, which 404s. Symptom looked like a backend/DB problem in the UI ("Database unknown", random 404s) but was purely an nginx config bug. Always check the exact upstream proxy_pass syntax, don't assume.
  • Don't assume the upstream Postgres/MySQL supports TLS. sslmode=require hard-fails with "server refused TLS connection" against a plain instance; sslmode=prefer (or disable if you're already routing over an encrypted tunnel like Nebula, making app-level TLS redundant) is the safer default unless you've confirmed otherwise.
  • Forgejo self-hosted runners are scoped at registration time (repo / org / instance) — a runner that already serves one repo won't pick up jobs from a newly created repo unless it was registered instance/org-wide. Two processes registered against the same connection config (e.g. a systemd service and a Docker container both pointed at the same runner-config.yml) will fight over the one runner identity and intermittently fail with 500 Internal Server Error — give each execution context its own registration.
  • Mesh tunnels can flap on service restart. A nixos-rebuild switch that restarts nebula@mesh.service will briefly drop tunnels to other mesh members; a service that depends on a mesh-routed database will see transient connection timeouts right after deploy. If Restart=on-failure is set, this self-heals within seconds — don't chase it as a config bug before checking timestamps against the last rebuild.