Make NixOS services more declaratively configurable than upstream Nixpkgs modules allow, by pairing each service with its Terraform provider and reconciling the service's runtime state once it is up.
Status: the pattern is implemented and the Forgejo pairing is the worked reference (see
services/forgejo).
Upstream NixOS modules configure a service's static surface — package version, config file, the systemd unit. They deliberately do not manage a service's runtime state: Grafana dashboards/datasources, a Git forge's orgs/repos/teams, and so on. Many such services ship a Terraform provider that manages exactly that state.
Here you declare the desired runtime state in Nix, and a systemd unit applies it (via OpenTofu) against the live, local instance after the service's primary unit starts:
services.forgejo = {
enable = true;
runtime = {
enable = true;
organizations.acme.description = "ACME Corporation";
repositories.widgets = {
owner = "acme";
description = "Widget factory";
};
};
};A pairing only makes sense when a service has admin-declarative runtime state reachable through a provider that the NixOS module cannot already express. Services whose entire surface is config-file-driven (already declarative via their NixOS options) are out of scope.
For each enabled pairing:
- Enable the upstream Nixpkgs service (
services.<svc>.enable = true). - Generate
.tf.jsonfromservices.<svc>.runtime.*(desired state). - Run a
Type=oneshotapply unit orderedAfter=the primary unit, gated on a readiness probe; it re-applies when the generated config changes.
Reconciliation is run-once, not a drift timer. A failed apply fails that
unit visibly (systemctl status) without tearing down the service.
Add this flake as an input and import the pairing's NixOS module
(nixosModules.forgejo, or nixosModules.default for all pairings). Full
installation, configuration examples, the option reference, the resource table,
and the secrets guide live in the per-pairing README:
Secrets should never enter the world-readable Nix store. The admin token and
any secret-valued resource attribute are read at apply time from a host file
path via systemd LoadCredential= into a sensitive Terraform variable; the
generated .tf.json holds only a ${var.…} reference. Each secret attribute
has an <attr>File form (e.g. dataFile, passwordFile) that takes the host
path — prefer it over the literal for any real secret.
flake.nix # outputs: nixosModules, checks, formatter
treefmt.nix # treefmt + nixfmt config
modules/
default.nix # aggregates per-pairing modules into nixosModules.default
lib/ # provider-agnostic helpers: tf-label/file, run-once reconciler
services/ # one directory per service<->provider pairing
forgejo/ # the worked Forgejo <-> svalabs/forgejo pairing
module.nix # NixOS module: services.forgejo.runtime + systemd wiring
lib.nix # provider specifics: wrapped executor + .tf.json generation
pkg.nix # vendor the provider (not in nixpkgs)
checks.nix # NixOS VM test
README.md # usage docs
nix flake check # eval modules + run all NixOS VM tests + formatting
nix build .#checks.<system>.forgejo # run one pairing's VM test
nix fmt # treefmt -> nixfmt across the treeBehavior is proven with NixOS VM integration tests (runNixOSTest): boot a
VM with the pairing enabled, let the reconciler apply, then assert the runtime
state by querying the live service API — not the Terraform state. Eval-only or
build-only success is not evidence the reconciliation works.
- Executor: OpenTofu (MPL 2.0);
terraform(BSL 1.1, unfree) is not used. - Config:
.tf.jsongenerated directly from Nix (builtins.toJSON) — no HCL, no terranix. - State: local, per-host only, co-located under the base service's state
directory (e.g.
/var/lib/forgejo). No remote backends. - Namespace: options live under
services.<svc>.runtime.*, so a pairing reads as a transparent extension of the upstreamservices.<svc>module. - Toolchain: flake
nixpkgstracksnixos-unstable; Nix ≥ 2.18.
Contributors: CLAUDE.md documents the full provider
implementation contract for adding a new pairing.
MIT.