neptun: replace Zitadel with Authentik as the OIDC provider

nixpkgs only carries Zitadel 2.71, which predates the login-v2 split and
cannot take a v3/v4 database (its migrations are forward-only), so the
instance running on the old Debian VPS could never have moved onto it.
authentik-nix ships 2026.5.4 and tracks upstream closely.

The authentik-nix input deliberately does not follow our nixpkgs, per
upstream's warning that overriding it breaks their pinned python
dependency set. That costs a second nixpkgs in the lock, so add
nix-community's Cachix to common.nix -- without it the closure is ~400
local derivations (npm, rust, python). The laptop that runs
scripts/deploy needs the same two lines in /etc/nix/nix.custom.conf.

Authentik's own module creates the database and orders its units against
postgresql.target, and recent versions need no redis, so the wiring is
just the module plus a secret. Pin postgresql explicitly so that editing
system.stateVersion can never silently demand a pg_upgrade of the
identity store.

Secret ownership is not uniform and the difference matters: authentik
and caddy take a systemd EnvironmentFile, which PID 1 reads as root
before dropping privileges, so root:root 0400 is correct. Headplane
opens its secret paths itself while already running as the headscale
user, so those three need an explicit owner or they fail to start.

Also on neptun:

- Pass Caddy's ACME account email through the same EnvironmentFile
  mechanism and reference it with the Caddyfile {$VAR} placeholder.
  services.caddy.email would render the address into the world-readable
  store.
- Stop accepting MagicDNS from our own control server. headscale pushes
  override_local_dns, so joining the tailnet would point neptun's
  resolv.conf at a MagicDNS served by the tailscaled neptun itself hosts
  -- a tailscaled failure would then also take out DNS, ACME renewal and
  finally the certs for the control server every other node needs in
  order to recover.
- Give headplane a writable DNS extra-records file. Its view of
  headscale's config stays read-only, which is the right outcome for a
  declarative box; records are data rather than config.
- Require a password for sudo. Deploys become interactive, but darman's
  key is otherwise the only thing between the public internet and root.
- Enable zram (8 GB, and disko leaves no room for a swap device), and let
  tailscaled-autoconnect retry instead of failing permanently when the
  control server isn't up yet on a first boot.

networking.hosts still carries a PLACEHOLDER address for jupiter --
replace it from `headscale nodes list` once jupiter first enrols.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 07:50:39 +02:00
co-authored by Claude Opus 4.8
parent d7a66f3e3b
commit ac42f231f5
12 changed files with 439 additions and 119 deletions
+11 -3
View File
@@ -1,14 +1,22 @@
# homelab
Flake-based NixOS config. Host: `jupiter` (ZimaBlade, NAS + services).
Flake-based NixOS config. Hosts: `jupiter` (ZimaBlade, NAS + services),
`neptun` (netcup VPS: public reverse proxy, Authentik, headscale),
`mercury` (Raspberry Pi 3B+, DNS/DHCP), `terra` (desktop).
## Structure
```
flake.nix # inputs + nixosConfigurations (jupiter, neptun, kexec, ...)
common.nix # shared base: user, ssh, nix, firewall, timezone
services/ # one reusable module per service
samba.nix avahi.nix audiobookshelf.nix containers.nix caddy.nix tailscale.nix
services/ # one reusable module per service, by category
media/ jellyfin, audiobookshelf, the *arrs, sabnzbd, seerr, ...
network/ caddy, samba, avahi, pihole, unbound
vpn/ tailscale, headscale (control server), headplane (its web UI)
identity/ authentik (OIDC provider, from the authentik-nix flake)
dev/ gitea
desktop/ hyprland
containers.nix # podman backend, shared across categories
hosts/
jupiter/ # ZimaBlade NAS
configuration.nix # host bits + imports common + the services it runs
+14 -1
View File
@@ -11,7 +11,11 @@
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZpkPVhzi1zG5JI9hWyUgdyvNIQbp4ts4jw3idpMhhN erik@laptop"
];
};
security.sudo.wheelNeedsPassword = false;
# Costs a password prompt on every `./scripts/deploy switch` (nixos-rebuild
# --use-remote-sudo). Worth it: darman's key is the only thing between the
# public internet and root on neptun. The password is darman_password from
# each host's sops file.
security.sudo.wheelNeedsPassword = true;
# ---- SSH (key-only) ----
services.openssh = {
@@ -27,6 +31,15 @@
experimental-features = [ "nix-command" "flakes" ];
# trust wheel so `nixos-rebuild --target-host darman@…` can push closures.
trusted-users = [ "root" "@wheel" ];
# authentik (services/identity/authentik.nix) comes from authentik-nix,
# which cache.nixos.org doesn't carry — without this it's ~400 local
# derivations (npm, rust, python). NOTE: the closure is built on whatever
# machine runs ./scripts/deploy, so the LAPTOP needs these two lines too,
# in /etc/nix/nix.custom.conf (Determinate Nix rewrites nix.conf).
extra-substituters = [ "https://nix-community.cachix.org" ];
extra-trusted-public-keys = [
"nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs="
];
};
nix.gc = {
automatic = true;
Generated
+246 -4
View File
@@ -1,5 +1,48 @@
{
"nodes": {
"authentik-nix": {
"inputs": {
"authentik-src": "authentik-src",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix",
"systems": "systems",
"uv2nix": "uv2nix"
},
"locked": {
"lastModified": 1784059115,
"narHash": "sha256-HDox7X6IKv0tgURi1DoWX9NYjY/ngTOfMvlOJsEl0oI=",
"owner": "nix-community",
"repo": "authentik-nix",
"rev": "1a0767799b4be2fc6d0dcf8b77d86f5838eafbc6",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "authentik-nix",
"type": "github"
}
},
"authentik-src": {
"flake": false,
"locked": {
"lastModified": 1783473460,
"narHash": "sha256-pGOd9+Una59JUgOcPC3PoqOqY08GkJtY+jgtk13rJ1Y=",
"owner": "goauthentik",
"repo": "authentik",
"rev": "c2942671a5b98dfa596de7bf247accb48a5c71ee",
"type": "github"
},
"original": {
"owner": "goauthentik",
"ref": "version/2026.5.4",
"repo": "authentik",
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
@@ -20,6 +63,82 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": [
"authentik-nix",
"systems"
]
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784350909,
"narHash": "sha256-ZWyzLbS1yKUTeFJLmdVuWNnHttL333/ldJbEE+KzCrM=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "4ce190229c73d44536caa7072f6308fb2d8feeb3",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "release-26.05",
"repo": "home-manager",
"type": "github"
}
},
"media-manager": {
"flake": false,
"locked": {
@@ -42,9 +161,9 @@
"nixpkgs": [
"nixpkgs"
],
"pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix",
"uv2nix": "uv2nix"
"pyproject-build-systems": "pyproject-build-systems_2",
"pyproject-nix": "pyproject-nix_2",
"uv2nix": "uv2nix_2"
},
"locked": {
"lastModified": 1768002052,
@@ -114,6 +233,37 @@
}
},
"nixpkgs": {
"locked": {
"lastModified": 1783776592,
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1782614948,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1784280462,
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
@@ -130,6 +280,35 @@
}
},
"pyproject-build-systems": {
"inputs": {
"nixpkgs": [
"authentik-nix",
"nixpkgs"
],
"pyproject-nix": [
"authentik-nix",
"pyproject-nix"
],
"uv2nix": [
"authentik-nix",
"uv2nix"
]
},
"locked": {
"lastModified": 1782093830,
"narHash": "sha256-6gmEVe69+KlRkZD4PEEV5xAlB9CB0Y9TiuEgQjDrKTQ=",
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"rev": "430680a19bc85a3bda55f12e4cc1a1aadcf2e478",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "build-system-pkgs",
"type": "github"
}
},
"pyproject-build-systems_2": {
"inputs": {
"nixpkgs": [
"mediamanager-nix",
@@ -159,6 +338,27 @@
}
},
"pyproject-nix": {
"inputs": {
"nixpkgs": [
"authentik-nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782905613,
"narHash": "sha256-SvXJcAemihifkTn4BGvyE5K1FJX9bl4U8DQ5pqKvD0s=",
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"rev": "7af23cfe91064865ecf2e835da28b45b3c6f49fd",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "pyproject.nix",
"type": "github"
}
},
"pyproject-nix_2": {
"inputs": {
"nixpkgs": [
"mediamanager-nix",
@@ -181,10 +381,12 @@
},
"root": {
"inputs": {
"authentik-nix": "authentik-nix",
"disko": "disko",
"home-manager": "home-manager",
"mediamanager-nix": "mediamanager-nix",
"nixos-images": "nixos-images",
"nixpkgs": "nixpkgs",
"nixpkgs": "nixpkgs_2",
"sops-nix": "sops-nix"
}
},
@@ -208,7 +410,47 @@
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1689347949,
"narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=",
"owner": "nix-systems",
"repo": "default-linux",
"rev": "31732fcf5e8fea42e59c2488ad31a0e651500f68",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default-linux",
"type": "github"
}
},
"uv2nix": {
"inputs": {
"nixpkgs": [
"authentik-nix",
"nixpkgs"
],
"pyproject-nix": [
"authentik-nix",
"pyproject-nix"
]
},
"locked": {
"lastModified": 1783511944,
"narHash": "sha256-Z/Ss9rWw9QYcRK+Qqkmty7PB1pIik5XGbrtit+ad2qs=",
"owner": "pyproject-nix",
"repo": "uv2nix",
"rev": "83995ef5e4ece3c9c704aa645bbff439e15a0ac3",
"type": "github"
},
"original": {
"owner": "pyproject-nix",
"repo": "uv2nix",
"type": "github"
}
},
"uv2nix_2": {
"inputs": {
"nixpkgs": [
"mediamanager-nix",
+5 -1
View File
@@ -19,9 +19,13 @@
url = "github:strangeglyph/mediamanager-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Deliberately NOT `inputs.nixpkgs.follows` — upstream states overriding it
# breaks their pinned python dependency set. Costs a second nixpkgs in the
# lock; builds come prebuilt from nix-community's Cachix.
authentik-nix.url = "github:nix-community/authentik-nix";
};
outputs = { self, nixpkgs, disko, sops-nix, nixos-images, mediamanager-nix, ... }@inputs:
outputs = { self, nixpkgs, disko, sops-nix, nixos-images, mediamanager-nix, authentik-nix, ... }@inputs:
let
system = "x86_64-linux";
in
+34 -7
View File
@@ -9,7 +9,7 @@
../../common.nix # shared base: user / ssh / nix / firewall
../../services/network/caddy.nix
../../services/vpn/tailscale.nix
../../services/identity/zitadel.nix
../../services/identity/authentik.nix
../../services/vpn/headscale.nix
../../services/vpn/headplane.nix
];
@@ -23,6 +23,11 @@
networking.hostName = "neptun";
# 8 GB and no swap device (netcup gives one disk, disko takes all of it for
# root). authentik's server + worker + postgres are the memory-hungry part;
# zram is enough headroom at this size and costs no disk.
zramSwap.enable = true;
# ---- Static networking (netcup) ----
# No LAN fallback: get this right or the box is unreachable (use netcup's
# VNC console / rescue system to fix). Values captured from the running VPS.
@@ -36,12 +41,33 @@
# netcup IPv6 gateway is conventionally fe80::1 — VERIFY with `ip -6 route`
# on the running VPS; wrong v6 gw won't break v4 reachability.
networking.defaultGateway6 = { address = "fe80::1"; interface = "eth0"; };
# Public resolvers for early boot; tailscale MagicDNS overrides once up.
networking.nameservers = [ "9.9.9.9" "1.1.1.1" "2620:fe::fe" ];
# ---- Don't take MagicDNS from our own control server ----
# headscale pushes override_local_dns, so joining the tailnet would point
# neptun's resolv.conf at a MagicDNS served by the tailscaled neptun itself
# hosts. A tailscaled failure would then also kill DNS, and with it ACME
# renewal — expiring the certs for the very control server every other node
# needs to recover. Keep the public resolvers above and resolve the one
# tailnet name we proxy to from /etc/hosts instead.
services.tailscale.extraUpFlags = [ "--accept-dns=false" ];
# ⚠️ PLACEHOLDER IP — replace once jupiter first enrols, with its address
# from `headscale nodes list` on this box. Until then the audiobookshelf
# vhost and the gitea SSH forward below both fail to connect.
networking.hosts."100.64.0.2" = [ "jupiter.hosts.mgaction.town" ];
# firewall (enable + 22), caddy (80/443), tailscale (trust tailscale0 + join
# headscale) come from ../../common.nix and ../../services/{caddy,tailscale}.nix.
# ---- ACME account email ----
# services.caddy.email would render the address into the world-readable
# store, so pass it via EnvironmentFile and reference it with the Caddyfile
# {$VAR} parse-time placeholder (see hosts/neptun/secrets.nix). globalConfig
# is types.lines, so this appends to the module's own global block.
services.caddy.environmentFile = config.sops.templates."caddy.env".path;
services.caddy.globalConfig = "email {$ACME_EMAIL}";
# ---- Public reverse proxy vhosts ----
# Caddy gets automatic public HTTPS (Let's Encrypt) for real domains.
# Proxies to jupiter's audiobookshelf over the tailnet (MagicDNS name).
@@ -51,12 +77,13 @@
'';
# TODO: port your other VPS services' vhosts here before deploying.
# ---- Zitadel (identity/OIDC provider) ----
# Runs locally on neptun (see services/identity/zitadel.nix); Caddy just terminates
# TLS and proxies to it.
services.zitadel.settings.ExternalDomain = "auth.mgaction.town";
# ---- Authentik (identity/OIDC provider) ----
# Runs locally on neptun (see services/identity/authentik.nix); Caddy just
# terminates TLS and proxies to its loopback HTTP listener. Authentik serves
# its UI and its OIDC endpoints from one port — no second frontend upstream,
# and no h2c (it's plain HTTP/1.1, unlike Zitadel's gRPC).
services.caddy.virtualHosts."auth.mgaction.town".extraConfig = ''
reverse_proxy http://localhost:8080
reverse_proxy http://127.0.0.1:9000
'';
# ---- Headscale + Headplane (tailnet control server + its web UI) ----
+30 -25
View File
@@ -14,34 +14,39 @@
sops.secrets.darman_password.neededForUsers = true;
users.users.darman.hashedPasswordFile = config.sops.secrets.darman_password.path;
# Zitadel: masterKeyFile takes a path natively (no store leak). The admin
# bootstrap password is different — services.zitadel.steps would render it
# into a world-readable store path, so render a FirstInstance steps file
# from the secret instead and point extraStepsPaths at it (see
# services/identity/zitadel.nix and the pihole.env template on mercury for the same
# pattern).
sops.secrets.zitadel_master_key = { };
sops.secrets.zitadel_admin_password = { };
sops.templates."zitadel-first-instance.yaml".content = ''
FirstInstance:
Org:
Name: mgaction
Human:
UserName: admin
FirstName: Admin
LastName: Admin
Email:
Address: erik.simon.me@gmail.com
Verified: true
Password: ${config.sops.placeholder.zitadel_admin_password}
PasswordChangeRequired: false
# Authentik takes a single systemd EnvironmentFile (services/identity/authentik.nix).
# No `owner` here on purpose: systemd reads EnvironmentFile as root before
# dropping to the service's DynamicUser, so root:root 0400 is what we want.
#
# AUTHENTIK_SECRET_KEY signs sessions/tokens — rotating it logs everyone out.
# The BOOTSTRAP_* vars only take effect on the very first start, where they
# create the `akadmin` superuser; they're inert on every boot after that.
sops.secrets.authentik_secret_key = { };
sops.secrets.authentik_bootstrap_password = { };
sops.secrets.authentik_bootstrap_email = { };
sops.templates."authentik.env".content = ''
AUTHENTIK_SECRET_KEY=${config.sops.placeholder.authentik_secret_key}
AUTHENTIK_BOOTSTRAP_PASSWORD=${config.sops.placeholder.authentik_bootstrap_password}
AUTHENTIK_BOOTSTRAP_EMAIL=${config.sops.placeholder.authentik_bootstrap_email}
'';
# Caddy's ACME account email. Same EnvironmentFile trick as authentik above,
# and root:root 0400 is likewise correct — systemd reads it before dropping
# to User=caddy. Wired up in configuration.nix.
sops.secrets.caddy_acme_email = { };
sops.templates."caddy.env".content = ''
ACME_EMAIL=${config.sops.placeholder.caddy_acme_email}
'';
# Headplane: cookie_secret_path takes a path natively (no store leak).
# oidc.client_secret + the headscale API key are still REPLACE_ME
# placeholders (see services/vpn/headplane.nix) until Zitadel/headscale are
# placeholders (see services/vpn/headplane.nix) until Authentik/headscale are
# actually deployed and those get created for real.
sops.secrets.headplane_cookie_secret = { };
sops.secrets.headplane_oidc_client_secret = { };
sops.secrets.headplane_headscale_api_key = { };
#
# owner: unlike authentik's EnvironmentFile above, headplane opens these
# paths itself, already running as headscale's user — so root:root 0400
# would fail and each needs an explicit owner.
sops.secrets.headplane_cookie_secret.owner = "headscale";
sops.secrets.headplane_oidc_client_secret.owner = "headscale";
sops.secrets.headplane_headscale_api_key.owner = "headscale";
}
+7 -5
View File
@@ -1,10 +1,12 @@
tailscale_authkey: ENC[AES256_GCM,data:CBMidfdilBn1HP3TAu9TCOpuwPnzVQ/3OGCsf0vhFd8=,iv:V4UdZBPyzH8NkiXV40CxLdnfkCv5oG6xEQbNSmpmrmI=,tag:h8R2AIaFXgPycMIP8NyHVA==,type:str]
darman_password: ENC[AES256_GCM,data:otLevSn6bXBrjCu7Dv9PN7Umwrb7CD1PhC1i+J8rp2mURMYVuwF/ZnrlPC7/RDZgINIRBOXOkG9lZo3UjuRyqrtvfUzIm1jGL51l1zvZ/07oNW6OUp4IZEQpoOmz1GuXBlx+yKRQeRDHYA==,iv:pnwp3uAsBoiJdF+QV27h0jztgFJKrIqFlmMT7JkcHoA=,tag:Zb9ziCGsLQt+TcJRCv8tWA==,type:str]
zitadel_master_key: ENC[AES256_GCM,data:LDZwbpwZdQ03O8I2tvIMYnK3MoQTgvOYwrBt2UuCHbk=,iv:sM66sj3JO+bb0Sk2D7M/Ehk7t7VZneuvTRP4IkT1se0=,tag:YKQ4shwZ4sIDWdNehTugew==,type:str]
zitadel_admin_password: ENC[AES256_GCM,data:q+Od3Juzvl/dVPkm0w1C42pjK7kU8ur8V5fl2JAdQkA=,iv:n8NFzQczJVeqe1ybBldbkv4QGj0xyf9/P+uvsHs3NjI=,tag:rkXvmC8kZZOrWRQd+mIanA==,type:str]
authentik_secret_key: ENC[AES256_GCM,data:qLrAWBywlMqT6D3FYDqE9I/5Ep+zDVu9ns9TA4UVOBSQ1uFGEj01TU9norqPf12pi9/Qs32mVzm5BqDG259DxdGT9DZVQc03QVwiIQYtmWo=,iv:OVCIxIP1Xv+nHmYsrxaPgYWQiwzVPUe8pnbyMBVAuoI=,tag:+ywkwPqkY1Dkx1R5cUJ7PA==,type:str]
authentik_bootstrap_email: ENC[AES256_GCM,data:OmqpKAiiFyS/rytnHYRZ,iv:VXiPV5VfduC/IW+E3gDlNAeE+hr+IZ9W7Ty6Npuu59Y=,tag:rjvTHnBxN/pMvpQC5W9S6Q==,type:str]
authentik_bootstrap_password: ENC[AES256_GCM,data:QPCY0ni3jBQY5HyK+vRlyT4YTEo=,iv:41u1Jf+WYksPUY4pvKdHQA1RHW21GAfdVDRuQ7XtdYc=,tag:SYmzv26GbE1kwleXyKLG4Q==,type:str]
tailscale_authkey: ENC[AES256_GCM,data:CBMidfdilBn1HP3TAu9TCOpuwPnzVQ/3OGCsf0vhFd8=,iv:V4UdZBPyzH8NkiXV40CxLdnfkCv5oG6xEQbNSmpmrmI=,tag:h8R2AIaFXgPycMIP8NyHVA==,type:str]
headplane_cookie_secret: ENC[AES256_GCM,data:oXYRG4z16u6HS7zXoWrV2q/HL2o24n4UwVXnQvqBmbY=,iv:itAiy/w6ue4VzqO5xYnvSYN3uCLLmu52dvAAxZ2pCGc=,tag:MEFP171GpSQpAqUFsWY+VA==,type:str]
headplane_oidc_client_secret: ENC[AES256_GCM,data:v86GLehXAI4Ol2ZxjtdtB1aDau0qsm68HBz3Iu4HjySPEi9jpg==,iv:EwtrNGKyVYcZg8AnD87EpHpmp0odeBXi1PM/9uUe+ko=,tag:VATaEMNxt5pnmgAHV13rLw==,type:str]
headplane_headscale_api_key: ENC[AES256_GCM,data:pb2ipeI4rkTXMQMG6hbkSU7OBLcpYJ2/a+9TgSA7PAfL2tTFoDWico8=,iv:j3FkiixUbT+6oLREKP21jQGFFUUU2KqX/ym2xQCeM0k=,tag:ZulMJDpaM1c81yT/LWNWgA==,type:str]
caddy_acme_email: ENC[AES256_GCM,data:j+iPz4lt0Gqs2SKHTS53,iv:DQNfcpYy2v93sKeCvXzLOoBhAo0PjZrxHgFD1oVqYu8=,tag:1yPH/2cW/IiRi9lT8dELEg==,type:str]
sops:
age:
- enc: |
@@ -25,7 +27,7 @@ sops:
Wptkf76aP9UpjhgNkxzedRebQPB7ti+UiVqCvLVimtuHcsm/NJPcRg==
-----END AGE ENCRYPTED FILE-----
recipient: age1hp72xyx2cnd05937e4eww95g5kdtn0wsf9j2nypw330pa69gfdxqn0lpkp
lastmodified: "2026-07-19T20:41:24Z"
mac: ENC[AES256_GCM,data:5LXTmULolfpLxdbnpCIiZ2ys7jqmug7UWr9f6rwrzfpxRlpyXcDhgh1zFqXfXlrvn6eVABNfcGJhIXz7+gZFhTuoEC5GEe60+Wx/mXn6LTJk8T25zk4KUnzTUPSLqz2S5BsGrBOkLGUnJOAyhmzMfToI1qOqzzsswfuuT7hTaeE=,iv:sqIZ4yhCsQ5LKSRrnPJUrG6qaSYW+EOFLjJvDdjStGw=,tag:kt0UzOXfoI+wQHGqLUYAhA==,type:str]
lastmodified: "2026-07-20T05:25:30Z"
mac: ENC[AES256_GCM,data:QFdUgjejtbhY8APVQ4r6uenOK8t0+GC0hazQ2jKAPbxbS4RunkXVzAOBpdIt6woW6TU9FlAVyHFxMGlg6XDI6QgM4KtsSgRbYj06PVl+E2rwFH6lOBgOYtYTS50wXWiyOyqyPDAoHyke+8/R3Ri61XgpbD7DrUOFVhihVYTqf+Y=,iv:UcUZ2o/HiP/viXfM3fRiYRR2uI3rDFb6P2mggqDcAKk=,tag:1YhQAXn3DtE3vJxs1e+MoQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.1
+47
View File
@@ -0,0 +1,47 @@
{ config, pkgs, inputs, ... }:
# Authentik — self-hosted identity/OIDC provider.
#
# Replaced Zitadel: nixpkgs only carries Zitadel 2.71 (no login-v2 split, and
# a v3/v4 database migrates forward only, so an existing instance can't be
# moved onto it). authentik-nix tracks upstream closely instead.
#
# The upstream module owns postgres (createDatabase) AND orders the units
# against postgresql.target, so no manual After= is needed here. No redis —
# recent authentik runs channels/cache on postgres.
#
# TLS terminates at Caddy; every listener is pinned to loopback below so
# nothing is reachable from the tailnet (hosts trust tailscale0).
#
# Needs, wired via sops in the host's secrets.nix: an environmentFile carrying
# - AUTHENTIK_SECRET_KEY (`openssl rand -base64 60`) — signs sessions
# - AUTHENTIK_BOOTSTRAP_PASSWORD first-run akadmin password
# systemd reads EnvironmentFile as root before dropping to the service's
# DynamicUser, so the sops default root:root 0400 is correct — do NOT set
# `owner` on it the way the headplane secrets need.
{
imports = [ inputs.authentik-nix.nixosModules.default ];
# Pinned explicitly: the default tracks system.stateVersion, so editing that
# line would silently demand a pg_upgrade of the identity store. Bump this
# deliberately, with a dump in hand.
services.postgresql.package = pkgs.postgresql_17;
services.authentik = {
enable = true;
environmentFile = config.sops.templates."authentik.env".path;
settings = {
# Default is 0.0.0.0 on all three; behind Caddy loopback is enough.
listen = {
listen_http = "127.0.0.1:9000";
listen_https = "127.0.0.1:9443";
listen_metrics = "127.0.0.1:9300";
};
disable_startup_analytics = true;
error_reporting.enabled = false;
avatars = "initials";
};
};
}
-60
View File
@@ -1,60 +0,0 @@
{ config, ... }:
# Zitadel — self-hosted identity/OIDC provider. Local Postgres (peer-authed
# over the unix socket, no password anywhere) since Zitadel is latency-
# sensitive to its DB. TLS terminates at Caddy; Zitadel itself only listens
# on localhost:8080.
#
# The "zitadel" Postgres role doubles as both User (runtime queries) and
# Admin (bootstrap: creates db/extensions on first start) — granted
# createdb+createrole rather than using the postgres superuser, per
# Zitadel's own guidance to scope bootstrap privileges to a dedicated role.
#
# Needs, wired via sops in the host's secrets.nix:
# - masterKeyFile: 32 raw bytes, e.g. `openssl rand -hex 16`
# - extraStepsPaths: a FirstInstance admin bootstrap file (keeps the admin
# password out of the Nix store — settings.steps would leak it, since
# it's rendered into a world-readable store path)
{
services.postgresql = {
enable = true;
ensureDatabases = [ "zitadel" ];
ensureUsers = [
{
name = "zitadel";
ensureDBOwnership = true;
ensureClauses = {
createdb = true;
createrole = true;
};
}
];
};
services.zitadel = {
enable = true;
tlsMode = "external";
masterKeyFile = config.sops.secrets.zitadel_master_key.path;
extraStepsPaths = [ config.sops.templates."zitadel-first-instance.yaml".path ];
settings = {
Port = 8080;
ExternalPort = 443;
ExternalSecure = true;
Database.postgres = {
Host = "/run/postgresql";
Port = 5432;
Database = "zitadel";
User = {
Username = "zitadel";
SSL.Mode = "disable";
};
Admin = {
Username = "zitadel";
SSL.Mode = "disable";
};
};
};
};
}
+34 -12
View File
@@ -1,28 +1,50 @@
{ config, ... }:
# Headplane — web UI for headscale (services/vpn/headscale.nix; must be enabled
# first). Runs as headscale's own OS user so it can restart headscale when
# settings change from the UI.
# first), running as headscale's own OS user.
#
# It reads headscale's config from the nix store, which is read-only — so the
# UI DISPLAYS the settings but can't change them. That's the intended shape
# for a declaratively-configured box (config_strict already defaults off
# upstream for exactly this reason); edit them here and rebuild instead.
# DNS extra-records are the one thing worth making editable, since they're
# data rather than config — hence the writable extra_records file below,
# which also spares headplane from restarting headscale on every change.
#
# Served at vpn.mgaction.town/admin (path-routed alongside headscale itself,
# see hosts/neptun/configuration.nix). base_url is the site root WITHOUT the
# /admin prefix — Headplane appends that itself, including for the OIDC
# callback.
#
# Auth is Zitadel (services/identity/zitadel.nix) via OIDC. client_id, client_secret,
# and the headscale API key can't be known until Zitadel/headscale are
# actually deployed, so they're placeholders below; direct API-key login
# still works as a fallback until then. Once live:
# 1. In Zitadel: create a project + a Web application for Headplane, with
# redirect URI https://vpn.mgaction.town/admin/oidc/callback. Copy the
# generated client ID into oidc.client_id below.
# Auth is Authentik (services/identity/authentik.nix) via OIDC. client_id,
# client_secret, and the headscale API key can't be known until
# Authentik/headscale are actually deployed, so they're placeholders below;
# direct API-key login still works as a fallback until then. Once live:
# 1. In Authentik: create an OAuth2/OpenID Provider + Application with slug
# `headplane` and redirect URI
# https://vpn.mgaction.town/admin/oidc/callback. Copy the generated
# client ID into oidc.client_id below.
# 2. `./scripts/edit_secrets secrets/neptun.yaml` and replace
# headplane_oidc_client_secret with the app's client secret.
# headplane_oidc_client_secret with the provider's client secret.
# 3. `headscale apikeys create` on the box, and replace
# headplane_headscale_api_key the same way.
#
# NOTE: Authentik issues per-application, so the issuer carries the app slug —
# it is NOT the bare host the way Zitadel's was.
{
# Writable DNS extra-records, shared by both services (they run as the same
# user). tmpfiles seeds an empty JSON array — headscale won't start against
# a missing or unparseable file.
systemd.tmpfiles.rules = [
"d /var/lib/headscale 0750 headscale headscale -"
"f /var/lib/headscale/extra_records.json 0640 headscale headscale - []"
];
services.headscale.settings.dns.extra_records_path = "/var/lib/headscale/extra_records.json";
services.headplane = {
enable = true;
settings.headscale.dns_records_path = "/var/lib/headscale/extra_records.json";
settings.server = {
cookie_secret_path = config.sops.secrets.headplane_cookie_secret.path;
cookie_secure = true; # served over HTTPS via Caddy
@@ -30,8 +52,8 @@
};
settings.oidc = {
issuer = "https://auth.mgaction.town";
client_id = "REPLACE_ME_zitadel_client_id"; # not secret, but not known until the app exists in Zitadel
issuer = "https://auth.mgaction.town/application/o/headplane/";
client_id = "REPLACE_ME_authentik_client_id"; # not secret, but not known until the app exists in Authentik
client_secret_path = config.sops.secrets.headplane_oidc_client_secret.path;
headscale_api_key_path = config.sops.secrets.headplane_headscale_api_key.path;
};
+1 -1
View File
@@ -11,7 +11,7 @@
{
services.headscale = {
enable = true;
port = 8082; # zitadel already sits on the usual 8080 on this host
port = 8082; # off the default 8080 to stay clear of other web apps
settings = {
server_url = "https://vpn.mgaction.town";
+10
View File
@@ -13,4 +13,14 @@
};
# Reach the host's services over the tailnet without opening LAN ports.
networking.firewall.trustedInterfaces = [ "tailscale0" ];
# The upstream unit is a one-shot with no Restart, so a login attempt made
# before the control server is reachable fails permanently until someone
# starts it by hand. That's the norm on a first boot — neptun hosts headscale
# itself, and the other hosts race it. 30s spacing also keeps restarts clear
# of systemd's default start limit (5 within 10s).
systemd.services.tailscaled-autoconnect.serviceConfig = {
Restart = "on-failure";
RestartSec = 30;
};
}