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>
126 lines
5.9 KiB
Nix
126 lines
5.9 KiB
Nix
{ config, pkgs, lib, ... }:
|
|
|
|
# netcup VPS (UEFI, /dev/vda). Public reverse proxy + tailnet node.
|
|
{
|
|
imports = [
|
|
./hardware-configuration.nix
|
|
./disk-config.nix # disko: vda partitions + filesystems
|
|
./secrets.nix # sops-nix: tailscale authkey
|
|
../../common.nix # shared base: user / ssh / nix / firewall
|
|
../../services/network/caddy.nix
|
|
../../services/vpn/tailscale.nix
|
|
../../services/identity/authentik.nix
|
|
../../services/vpn/headscale.nix
|
|
../../services/vpn/headplane.nix
|
|
];
|
|
|
|
# ---- Boot (UEFI) ----
|
|
boot.loader.systemd-boot.enable = true;
|
|
boot.loader.efi.canTouchEfiVariables = true;
|
|
# Root is on the virtio disk — pin these so stage-1 mounts it regardless of
|
|
# what nixos-generate-config detects in the installer.
|
|
boot.initrd.availableKernelModules = [ "virtio_pci" "virtio_blk" "virtio_scsi" ];
|
|
|
|
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.
|
|
networking.useDHCP = false;
|
|
networking.usePredictableInterfaceNames = false; # keep the NIC named eth0
|
|
networking.interfaces.eth0 = {
|
|
ipv4.addresses = [ { address = "159.195.64.117"; prefixLength = 22; } ];
|
|
ipv6.addresses = [ { address = "2a0a:4cc0:c2:19e1:44b4:8dff:fe4d:c7d7"; prefixLength = 64; } ];
|
|
};
|
|
networking.defaultGateway = { address = "159.195.64.1"; interface = "eth0"; };
|
|
# 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"; };
|
|
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).
|
|
# Needs a public A record -> this VPS IP (ports 80/443 opened by the module).
|
|
services.caddy.virtualHosts."audiobookshelf.mgaction.town".extraConfig = ''
|
|
reverse_proxy http://jupiter.hosts.mgaction.town:8000
|
|
'';
|
|
# TODO: port your other VPS services' vhosts here before deploying.
|
|
|
|
# ---- 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://127.0.0.1:9000
|
|
'';
|
|
|
|
# ---- Headscale + Headplane (tailnet control server + its web UI) ----
|
|
# Path-routed on one vhost: Headplane owns /admin* (uses `handle`, not
|
|
# `handle_path`, since it needs the prefix kept in the forwarded path for
|
|
# its own assets + OIDC callback); everything else goes to headscale.
|
|
# `flush_interval -1`: headscale's node-update endpoint is a long-poll and
|
|
# Caddy would otherwise buffer it, showing clients stale state.
|
|
services.caddy.virtualHosts."vpn.mgaction.town".extraConfig = ''
|
|
handle /admin* {
|
|
reverse_proxy http://localhost:3000
|
|
}
|
|
handle {
|
|
reverse_proxy http://localhost:8082 {
|
|
flush_interval -1
|
|
}
|
|
}
|
|
'';
|
|
|
|
# ---- Gitea SSH forward ----
|
|
# Caddy only proxies HTTP; forward :2222 over the tailnet to gitea's own
|
|
# SSH server on jupiter (services/dev/gitea.nix), so
|
|
# `ssh://git@git.mgaction.town:2222/...` works. Also needs a matching
|
|
# inbound-2222 rule in netcup's edge firewall panel (not managed by Nix).
|
|
systemd.services.gitea-ssh-forward = {
|
|
description = "Forward :2222 to jupiter's gitea SSH server over tailscale";
|
|
after = [ "network-online.target" "tailscaled.service" ];
|
|
wants = [ "network-online.target" ];
|
|
wantedBy = [ "multi-user.target" ];
|
|
serviceConfig = {
|
|
DynamicUser = true;
|
|
ExecStart = "${pkgs.socat}/bin/socat TCP-LISTEN:2222,fork,reuseaddr TCP:jupiter.hosts.mgaction.town:2222";
|
|
Restart = "always";
|
|
};
|
|
};
|
|
networking.firewall.allowedTCPPorts = [ 2222 ];
|
|
|
|
system.stateVersion = "26.05"; # set at install time; do NOT bump on upgrades
|
|
}
|