Table of contents
- Wrapper-Repo Service Packaging Runbook
- Context
- Step 1 — Assess buildability before committing to anything
- Step 2 — Create the wrapper repo
- Step 3 — Automate tracking upstream HEAD (if no tagged releases exist)
- Step 4 — Wire into nixos-config
- Step 5 — Write the NixOS module
- Step 6 — Validate before ever touching the live host
- Gotchas worth remembering
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 (checkgo.modfor 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_passtargets. 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'sdefault.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>.yamlfile — 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_passtrailing slash silently strips the location prefix. If upstream's own nginx.conf usesproxy_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 upstreamproxy_passsyntax, don't assume.- Don't assume the upstream Postgres/MySQL supports TLS.
sslmode=requirehard-fails with "server refused TLS connection" against a plain instance;sslmode=prefer(ordisableif 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 with500 Internal Server Error— give each execution context its own registration. - Mesh tunnels can flap on service restart. A
nixos-rebuild switchthat restartsnebula@mesh.servicewill briefly drop tunnels to other mesh members; a service that depends on a mesh-routed database will see transient connection timeouts right after deploy. IfRestart=on-failureis set, this self-heals within seconds — don't chase it as a config bug before checking timestamps against the last rebuild.