aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaran Jayachandra <mail@karanjayachandra.com>2026-07-18 23:04:53 +0200
committerKaran Jayachandra <mail@karanjayachandra.com>2026-07-18 23:04:53 +0200
commit9ceddb33272e5fca6382c1b4dec2074bd1167738 (patch)
treecac68c1bdc90c9ab618e4fdb4c8eb65d27d9a647
parent718a79f8de2a55f2ab83cac7e8595cd3916ed486 (diff)
Simplify config and add AdGuard DoH/DoT support
- Replace the hand-rolled Podman OCI container for Actual Budget with the native services.actual module (available in the pinned nixpkgs release); the container never actually had a backend enabled, so it likely never ran. - Collapse cgit's Caddy routing to a single reverse proxy - the smart-HTTP git backend was already served on the same nginx vhost/port as cgit itself, so the separate /git/* -> 8085 route was dead and pointed at a port nothing listened on. - Drop the unused kvm-intel kernel module from the guest hardware profile, and rename disko's misleadingly-named ESP partition (it's ext4, not a real EFI System Partition). - Point common.nix's disabled autoUpgrade flake URL at this repo's own cgit hosting instead of a generic GitHub placeholder. - Add AdGuard Home DNS-over-TLS (853) and DNS-over-HTTPS (8443) support, backed by an independent ACME certificate (modules/acme.nix) issued via a webroot Caddy serves on port 80. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md58
-rw-r--r--README.md29
-rw-r--r--hosts/eurovm/default.nix1
-rw-r--r--hosts/eurovm/disko.nix6
-rw-r--r--hosts/eurovm/hardware.nix2
-rw-r--r--modules/acme.nix32
-rw-r--r--modules/actual.nix32
-rw-r--r--modules/adguard.nix36
-rw-r--r--modules/caddy.nix25
-rw-r--r--modules/cgit.nix14
-rw-r--r--modules/common.nix14
11 files changed, 173 insertions, 76 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 35a6923..c11918d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -76,30 +76,48 @@ service module:
- `sops.nix` — sops-nix wiring. The host's own age key is derived at boot from
`/etc/ssh/ssh_host_ed25519_key`, so no separate host key material needs to be provisioned.
Declares which secrets exist, their owning user/group, and file mode.
-- `caddy.nix` — the only thing that binds to 80/443. Reverse-proxies each subdomain to a
- service's localhost port. All application ports (3000, 5006, 8080, 8085/8086) are bound to
- `127.0.0.1` and never exposed directly — Caddy is the sole public HTTP(S) entry point and
- handles Let's Encrypt automatically.
-- `adguard.nix` — AdGuard Home; DNS on `0.0.0.0:53`, web UI on `127.0.0.1:3000`. Runs with
- `mutableSettings = false` (fully declarative config), which creates a specific problem: the
- module's own `ExecStartPre` overwrites the state file with a config containing a literal
- `REPLACED_AT_ACTIVATION` placeholder on every start. A second `ExecStartPre`, ordered with
- `lib.mkAfter`, substitutes in the real bcrypt hash from the sops secret immediately before
- `ExecStart`. It also uses a static (non-`DynamicUser`) system user, because sops-nix chowns the
- secret to that user during activation while the service is stopped — a `DynamicUser` doesn't
- exist at that point. If you touch this module, preserve that ordering; it's the whole point of
- the file.
+- `acme.nix` — an independent `security.acme` certificate for `dns.karanj.com`, used only by
+ AdGuard's own DoH/DoT TLS listener (not by Caddy, which gets its HTTPS for the same domain
+ through its own separate, built-in ACME client). Issued via HTTP-01 against a webroot that
+ `caddy.nix` serves on port 80 for that one vhost. The cert directory is group-owned by
+ `adguardhome` (`security.acme.certs.<name>.group`) so the service can read it without running
+ as root, and `reloadServices` restarts `adguardhome` on renewal.
+- `caddy.nix` — the main thing that binds to 80/443 (see `adguard.nix` below for the one
+ exception). Reverse-proxies each subdomain to a service's localhost port; the `dns.karanj.com`
+ vhost additionally serves the ACME HTTP-01 webroot for `acme.nix`. Application ports (5006,
+ 8080, 8086) are bound to `127.0.0.1` and never exposed directly — Caddy is the public HTTP(S)
+ entry point for everything except AdGuard's encrypted-DNS ports, and handles Let's Encrypt
+ automatically for its own certs.
+- `adguard.nix` — AdGuard Home; plain DNS on `0.0.0.0:53`, DNS-over-TLS on `0.0.0.0:853`,
+ DNS-over-HTTPS on `0.0.0.0:8443` (path `/dns-query`), web UI on `0.0.0.0:3000` (port 3000 is
+ never opened in the firewall, so it's still effectively internal-only, reached via Caddy).
+ `http.address` binds `0.0.0.0` rather than `127.0.0.1` specifically because AdGuard's
+ HTTPS/DoH listener reuses that same bind host internally (confirmed from AdGuard's source) —
+ narrowing it back to loopback would silently make DoH unreachable. TLS for DoH/DoT uses the
+ cert from `acme.nix`; `systemd.services.adguardhome.after`/`wants` on `acme-dns.karanj.com.service`
+ ensures the cert exists before AdGuard's first start.
+ Runs with `mutableSettings = false` (fully declarative config), which creates a specific
+ problem: the module's own `ExecStartPre` overwrites the state file with a config containing a
+ literal `REPLACED_AT_ACTIVATION` placeholder on every start. A second `ExecStartPre`, ordered
+ with `lib.mkAfter`, substitutes in the real bcrypt hash from the sops secret immediately before
+ `ExecStart`. It also uses a static (non-`DynamicUser`) system user, because sops-nix (and now
+ the acme cert's group ownership) need a stable UID/GID to chown to during activation, while a
+ `DynamicUser` only exists while its service is actually running. If you touch this module,
+ preserve that ordering; it's the whole point of the file.
- `miniflux.nix` — RSS reader; admin account seeded from a sops credentials file (`ADMIN_USERNAME`
/ `ADMIN_PASSWORD` format) on first run. Uses the NixOS module's own PostgreSQL integration.
-- `actual.nix` — Actual Budget, run as a Podman OCI container (`virtualisation.oci-containers`)
- rather than a native NixOS module, because none exists for this nixpkgs version. No secrets
- involved; the server password is set interactively on first browser visit.
+- `actual.nix` — Actual Budget via the native `services.actual` module (ships in this flake's
+ pinned nixpkgs). No secrets involved; the server password is set interactively on first
+ browser visit.
- `cgit.nix` — git hosting. Three-way access split: web browsing is public/unauthenticated via
cgit, `git clone`/`pull` over HTTPS is public/read-only via `git-http-backend` + fcgiwrap, and
- `git push` is SSH-only through a dedicated `git` system user with its own authorized keys. Both
- cgit and git-http-backend actually run behind a minimal localhost-only nginx instance, which
- Caddy then proxies to — nginx exists here purely as an implementation detail for cgit/fcgiwrap,
- not as the public-facing server.
+ `git push` is SSH-only through a dedicated `git` system user with its own authorized keys. cgit
+ browsing and git-http-backend are both served by the *same* localhost-only nginx vhost
+ (127.0.0.1:8086) — nginx tells the two apart by matching the request path (a regex for
+ `info/refs`/`git-upload-pack`), not by a separate port — and Caddy reverse-proxies everything
+ for `git.karanj.com` straight to that one vhost. Only `git-upload-pack` is wired up, so push
+ over HTTP is impossible regardless of nginx config; push stays SSH-only structurally, not just
+ by convention.
**Secrets (`secrets/secrets.yaml` + `.sops.yaml`)** — encrypted with sops using age keys. Three
recipients: two personal keys (`einstein`, `galileo` — the user's two machines) plus the host's
diff --git a/README.md b/README.md
index 5fa57b3..f714059 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,8 @@ A flake-based, modular NixOS configuration for a personal server running in Euro
| https://git.karanj.com | cgit (git repository browser) |
All services are reverse-proxied by **Caddy** with automatic TLS via Let's Encrypt.
-AdGuard also listens directly on **port 53 (UDP + TCP)** for DNS.
+AdGuard also listens directly on **port 53 (UDP + TCP)** for plain DNS, **853/tcp** for
+DNS-over-TLS, and **8443/tcp** for DNS-over-HTTPS - see [AdGuard Home](#adguard-home) below.
---
@@ -29,8 +30,9 @@ hosts/eurovm/
modules/
common.nix SSH hardening, firewall, timezone, nix settings
sops.nix sops-nix wiring (age key from SSH host key)
+ acme.nix independent ACME cert for AdGuard's own DoH/DoT TLS
caddy.nix reverse proxy + HTTPS virtual hosts
- adguard.nix AdGuard Home DNS + web UI
+ adguard.nix AdGuard Home DNS + DoH/DoT + web UI
miniflux.nix Miniflux + PostgreSQL
actual.nix Actual Budget
cgit.nix cgit + fcgiwrap + git push user
@@ -278,6 +280,15 @@ To reset the password:
# Then: systemctl restart adguardhome
```
+**Encrypted DNS client setup.** Point client devices at:
+- **DNS-over-TLS:** `tls://dns.karanj.com:853`
+- **DNS-over-HTTPS:** `https://dns.karanj.com:8443/dns-query`
+
+These are served by AdGuard itself with its own Let's Encrypt certificate (obtained
+automatically via `security.acme` - see `modules/acme.nix`), independently of Caddy's
+certificate for the same domain. Nothing to configure manually; the cert renews on its own
+and restarts `adguardhome` automatically when it does.
+
### Miniflux
To reset the password from the server:
@@ -312,9 +323,13 @@ Caddy logs: `journalctl -u caddy -f`
| Port | Protocol | Purpose |
|------|----------|---------|
| 22 | TCP | SSH (admin + git push) |
-| 80 | TCP | HTTP (Caddy redirects to HTTPS) |
-| 443 | TCP | HTTPS (all web services) |
-| 53 | TCP + UDP | DNS (AdGuard Home) |
+| 80 | TCP | HTTP (Caddy redirects to HTTPS; also serves the ACME HTTP-01 webroot) |
+| 443 | TCP | HTTPS (all web services, via Caddy) |
+| 53 | TCP + UDP | Plain DNS (AdGuard Home) |
+| 853 | TCP | DNS-over-TLS (AdGuard Home) |
+| 8443 | TCP | DNS-over-HTTPS (AdGuard Home) |
-All other ports are closed. App-level ports (3000, 5006, 8080, 8086) are bound to
-127.0.0.1 and never exposed directly.
+All other ports are closed at the firewall. App-level ports (5006, 8080, 8086) are bound to
+127.0.0.1 and never exposed directly. AdGuard's web UI (3000) binds all interfaces (required
+so its DoH/DoT listener - which shares the same bind host - reaches the public interface),
+but stays unreachable externally because the firewall never opens port 3000.
diff --git a/hosts/eurovm/default.nix b/hosts/eurovm/default.nix
index 6c92b67..b487644 100644
--- a/hosts/eurovm/default.nix
+++ b/hosts/eurovm/default.nix
@@ -6,6 +6,7 @@
./disko.nix
../../modules/common.nix
../../modules/sops.nix
+ ../../modules/acme.nix
../../modules/caddy.nix
../../modules/adguard.nix
../../modules/miniflux.nix
diff --git a/hosts/eurovm/disko.nix b/hosts/eurovm/disko.nix
index d1ec85c..5176340 100644
--- a/hosts/eurovm/disko.nix
+++ b/hosts/eurovm/disko.nix
@@ -20,8 +20,10 @@
priority = 1;
};
- # 512 MiB /boot - keeps kernels/initrds out of root for clarity
- ESP = {
+ # 512 MiB /boot - keeps kernels/initrds out of root for clarity.
+ # Not a real EFI System Partition (ext4, not FAT32/type EF00) -
+ # this is a BIOS-boot setup, GRUB just needs a plain filesystem here.
+ bootfs = {
size = "512M";
type = "8300";
content = {
diff --git a/hosts/eurovm/hardware.nix b/hosts/eurovm/hardware.nix
index 50f768c..443175e 100644
--- a/hosts/eurovm/hardware.nix
+++ b/hosts/eurovm/hardware.nix
@@ -20,8 +20,6 @@
"sr_mod"
];
- boot.kernelModules = [ "kvm-intel" ];
-
# Hetzner Cloud networking: single public interface
networking = {
useDHCP = false;
diff --git a/modules/acme.nix b/modules/acme.nix
new file mode 100644
index 0000000..23c995f
--- /dev/null
+++ b/modules/acme.nix
@@ -0,0 +1,32 @@
+{ ... }:
+
+# Shared ACME (Let's Encrypt) infrastructure for services that need their own
+# certificate rather than being fronted by Caddy - currently just AdGuard
+# Home's DNS-over-HTTPS/DNS-over-TLS listener (modules/adguard.nix), which
+# terminates TLS itself on its own ports rather than through Caddy.
+#
+# Challenge type: HTTP-01 via a shared webroot that Caddy serves on port 80
+# for dns.karanj.com (see modules/caddy.nix) - no DNS provider API
+# credentials needed. This is a separate Let's Encrypt certificate from the
+# one Caddy obtains for its own reverse-proxied HTTPS on the same domain;
+# issuing two independent certs for one domain is fine and well within
+# Let's Encrypt's rate limits.
+{
+ security.acme = {
+ acceptTerms = true;
+ defaults.email = "me@karanj.com";
+
+ certs."dns.karanj.com" = {
+ webroot = "/var/lib/acme/acme-challenge";
+ # Owns the issued cert/key files by the adguardhome group so the
+ # service can read them without running as root.
+ group = "adguardhome";
+ # Restart (not just reload) so AdGuard re-reads the cert on renewal.
+ reloadServices = [ "adguardhome.service" ];
+ };
+ };
+
+ # Make sure Caddy (which serves the HTTP-01 webroot) is already up before
+ # the first issuance attempt.
+ systemd.services."acme-dns.karanj.com".after = [ "caddy.service" ];
+}
diff --git a/modules/actual.nix b/modules/actual.nix
index 62593a1..95bbc7d 100644
--- a/modules/actual.nix
+++ b/modules/actual.nix
@@ -1,32 +1,22 @@
-{ pkgs, ... }:
+{ ... }:
# Actual Budget - local-first personal finance application.
#
-# Runs as a Podman OCI container because the native services.actual NixOS
-# module is not available in nixos-24.11. The container listens on
-# 127.0.0.1:5006; Caddy handles public HTTPS termination.
+# Native NixOS module (services.actual, available since this flake's pinned
+# nixpkgs release). Listens on 127.0.0.1:5006; Caddy handles public HTTPS
+# termination.
#
# On first visit to https://budget.karanj.com the app prompts you to set a
# server password in the browser - no pre-configuration needed.
#
-# All budget data is persisted in /var/lib/actual on the host.
+# All budget data is persisted in /var/lib/actual on the host (the module's
+# own StateDirectory).
{
- # Ensure the data directory exists before the container starts
- systemd.tmpfiles.rules = [
- "d /var/lib/actual 0750 root root -"
- ];
-
- virtualisation.oci-containers.containers.actual = {
- image = "docker.io/actualbudget/actual-server:latest";
-
- ports = [ "127.0.0.1:5006:5006" ];
-
- volumes = [
- "/var/lib/actual:/data"
- ];
-
- environment = {
- ACTUAL_PORT = "5006";
+ services.actual = {
+ enable = true;
+ settings = {
+ hostname = "127.0.0.1";
+ port = 5006;
};
};
}
diff --git a/modules/adguard.nix b/modules/adguard.nix
index 85c5f5c..e66d072 100644
--- a/modules/adguard.nix
+++ b/modules/adguard.nix
@@ -2,8 +2,20 @@
# AdGuard Home - DNS resolver + optional network-wide ad blocker.
#
-# DNS listens on port 53 (udp/tcp) directly on the public IP.
-# The web UI listens on 127.0.0.1:3000 and is fronted by Caddy.
+# Plain DNS listens on port 53 (udp/tcp) directly on the public IP.
+# DNS-over-TLS (853/tcp) and DNS-over-HTTPS (8443/tcp, path /dns-query) are
+# also served directly by AdGuard itself, terminating TLS with their own
+# independent Let's Encrypt cert (modules/acme.nix) - NOT via Caddy, since
+# Caddy already owns port 443 for its own reverse-proxied vhosts and
+# AdGuard's HTTPS/DoH listener needs its own port.
+#
+# AdGuard's HTTPS/DoH listener binds to the same host as the plain `http`
+# listener below (confirmed in AdGuardHome's source: both come from
+# HTTPConfig.Address) - that's why `http.address` binds 0.0.0.0 rather than
+# 127.0.0.1 even though the plain-HTTP web UI is still meant to be reached
+# only via Caddy's reverse proxy: port 3000 itself is never opened in the
+# firewall (modules/common.nix), so it's still unreachable directly.
+# DNS-over-TLS reuses `dns.bind_hosts` below, so it needs no such change.
#
# Admin password is seeded from a sops secret containing a bcrypt hash.
# To generate the hash on your local machine:
@@ -16,7 +28,19 @@
settings = {
http = {
- address = "127.0.0.1:3000";
+ address = "0.0.0.0:3000";
+ };
+
+ tls = {
+ enabled = true;
+ server_name = "dns.karanj.com";
+ port_https = 8443;
+ port_dns_over_tls = 853;
+ # DNS-over-QUIC and DNSCrypt were not requested - keep them off.
+ port_dns_over_quic = 0;
+ port_dnscrypt = 0;
+ certificate_path = "/var/lib/acme/dns.karanj.com/fullchain.pem";
+ private_key_path = "/var/lib/acme/dns.karanj.com/key.pem";
};
dns = {
@@ -25,6 +49,7 @@
# Clients configure this server's public IPv4 address (not
# dns.karanj.com - that hostname only resolves to the HTTPS web UI
# via Caddy) as their plain DNS resolver, e.g. <server-ipv4>:53.
+ # For encrypted DNS, see the client setup notes in README.md.
# Upstream DNS resolvers (privacy-respecting)
upstream_dns = [
"https://dns.quad9.net/dns-query"
@@ -96,6 +121,11 @@
# `sed -i`: sed -i creates a temp file and chowns/renames it in place, and
# this unit's hardened SystemCallFilter (~@privileged) blocks the chown
# syscall, killing sed with SIGSYS ("Bad system call").
+ # Don't attempt the first start until the ACME cert referenced by
+ # tls.certificate_path/private_key_path above actually exists.
+ systemd.services.adguardhome.after = [ "acme-dns.karanj.com.service" ];
+ systemd.services.adguardhome.wants = [ "acme-dns.karanj.com.service" ];
+
systemd.services.adguardhome.serviceConfig = {
DynamicUser = lib.mkForce false;
User = "adguardhome";
diff --git a/modules/caddy.nix b/modules/caddy.nix
index c2c41e4..f82c7f0 100644
--- a/modules/caddy.nix
+++ b/modules/caddy.nix
@@ -9,10 +9,17 @@
virtualHosts = {
- # AdGuard Home web UI
+ # AdGuard Home web UI, plus the HTTP-01 webroot for the independent
+ # ACME cert AdGuard uses for its own DoH/DoT TLS (modules/acme.nix).
"dns.karanj.com" = {
extraConfig = ''
- reverse_proxy 127.0.0.1:3000
+ handle /.well-known/acme-challenge/* {
+ root * /var/lib/acme/acme-challenge
+ file_server
+ }
+ handle {
+ reverse_proxy 127.0.0.1:3000
+ }
'';
};
@@ -30,17 +37,13 @@
'';
};
- # cgit - public read-only git viewer + smart HTTP for git clone/pull
+ # cgit - public read-only git viewer + smart HTTP for git clone/pull.
+ # Both cgit browsing and git-http-backend (clone/pull) are served by
+ # the same nginx vhost on 8086 - nginx itself routes between them by
+ # matching the request path, so a single proxy here covers both.
"git.karanj.com" = {
extraConfig = ''
- # Smart HTTP git (clone/pull only - no push exposed)
- handle /git/* {
- reverse_proxy 127.0.0.1:8085
- }
- # cgit web UI
- handle {
- reverse_proxy 127.0.0.1:8086
- }
+ reverse_proxy 127.0.0.1:8086
'';
};
diff --git a/modules/cgit.nix b/modules/cgit.nix
index 058fc79..de15e08 100644
--- a/modules/cgit.nix
+++ b/modules/cgit.nix
@@ -8,12 +8,14 @@
# - git clone/pull over HTTPS: public, read-only via git-http-backend
# - git push: SSH only, using the "git" user + your authorized keys
#
-# Caddy routes:
-# /git/* -> fcgiwrap serving git-http-backend (port 8085 via nginx shim)
-# /* -> cgit (port 8086 via nginx shim)
-#
-# Both cgit and git-http-backend are served through a minimal nginx instance
-# bound to localhost, which Caddy then reverse-proxies.
+# The NixOS cgit module serves both cgit browsing and git-http-backend
+# (clone/pull, via gitHttpBackend.enable which defaults to true) on the
+# *same* nginx vhost/location - nginx tells them apart by matching the
+# request path against a regex (.../info/refs|git-upload-pack for the
+# smart-HTTP protocol), not by a separate port. That combined vhost is
+# bound to localhost:8086 below, and Caddy reverse-proxies everything
+# for git.karanj.com straight to it. Only git-upload-pack is wired up, so
+# push over HTTP is impossible regardless - push stays SSH-only.
{
# Dedicated git user for SSH push access
users.users.git = {
diff --git a/modules/common.nix b/modules/common.nix
index e5c38d4..e27d276 100644
--- a/modules/common.nix
+++ b/modules/common.nix
@@ -15,10 +15,13 @@
};
};
- # Firewall: only allow SSH, HTTP, HTTPS, and DNS (for AdGuard)
+ # Firewall: SSH, HTTP/HTTPS (Caddy), plain DNS, and AdGuard's own
+ # DNS-over-TLS (853) + DNS-over-HTTPS (8443) listeners.
+ # Port 3000 (AdGuard's plain-HTTP web UI) is deliberately NOT opened here -
+ # see modules/adguard.nix for why it still binds 0.0.0.0 anyway.
networking.firewall = {
enable = true;
- allowedTCPPorts = [ 22 80 443 53 ];
+ allowedTCPPorts = [ 22 80 443 53 853 8443 ];
allowedUDPPorts = [ 53 ];
};
@@ -47,10 +50,13 @@
sops
];
- # Automatic security updates for the OS
+ # Automatic security updates for the OS.
+ # This repo is self-hosted via cgit (modules/cgit.nix), so the flake URL
+ # below points at this same server rather than GitHub - update the repo
+ # name if you push this config somewhere else.
system.autoUpgrade = {
enable = false; # set to true once you are comfortable with unattended reboots
- flake = "github:YOUR_USERNAME/nix#eurovm"; # update to your actual flake URL
+ flake = "git+https://git.karanj.com/feynman.git#eurovm";
flags = [ "--update-input" "nixpkgs" ];
};
}