aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKaran Jayachandra <mail@karanjayachandra.com>2026-07-18 22:32:19 +0200
committerKaran Jayachandra <mail@karanjayachandra.com>2026-07-18 22:32:19 +0200
commit718a79f8de2a55f2ab83cac7e8595cd3916ed486 (patch)
tree9161a11f608f7de182f6ae3003ca55acd75a3485
parent3fdf4a3e0dbd55b2b1dce12ca0396dc018c4e0e3 (diff)
Cleaned up the config and the docs
-rw-r--r--CLAUDE.md120
-rw-r--r--README.md329
-rw-r--r--modules/adguard.nix3
3 files changed, 222 insertions, 230 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..35a6923
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,120 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+A flake-based, modular NixOS configuration for a single personal server ("eurovm") running on
+Hetzner Cloud in Europe. There is no application code here — this repo *is* the infrastructure:
+editing a `.nix` file and deploying is the equivalent of shipping a change.
+
+## Commands
+
+There is no local build/test/lint step in the traditional sense — the workflow is evaluate -> deploy
+against the live host. Nix itself must be installed locally with flakes enabled (`nix --version`).
+
+Check the flake evaluates without deploying:
+```bash
+nix flake check
+```
+
+Deploy a change (from an x86_64-linux machine):
+```bash
+nixos-rebuild switch --flake .#eurovm \
+ --target-host admin@<server-ipv4> \
+ --use-remote-sudo
+```
+
+Deploy from a non-x86_64-linux machine (e.g. Apple Silicon) — the server builds its own closure:
+```bash
+nixos-rebuild switch --flake .#eurovm \
+ --target-host admin@<server-ipv4> \
+ --build-host admin@<server-ipv4> \
+ --use-remote-sudo
+```
+
+First-time provisioning of a fresh VM (partitions the disk via disko, installs NixOS):
+```bash
+nix run github:nix-community/nixos-anywhere -- \
+ --flake .#eurovm \
+ --build-on remote \
+ root@<server-ipv4>
+```
+
+Edit encrypted secrets (re-encrypts on save):
+```bash
+sops secrets/secrets.yaml
+```
+
+Add a new sops recipient (e.g. after rotating a key or adding a new machine) and re-encrypt:
+```bash
+sops updatekeys secrets/secrets.yaml
+```
+
+Full first-deploy / bootstrap sequence (age key setup, DNS, secrets, provisioning, per-service
+first-login steps) is documented step by step in `README.md` — follow it rather than re-deriving
+the sequence from the module files.
+
+## Architecture
+
+**Single flake, single host.** `flake.nix` defines exactly one `nixosConfigurations.eurovm`
+target, pinned to `system = "x86_64-linux"` (switch to `aarch64-linux"` for an ARM Hetzner
+instance). Inputs are `nixpkgs` (pinned to `nixos-26.05`), `disko`, and `sops-nix`.
+
+**Host assembly (`hosts/eurovm/`)** wires together hardware profile, disk layout, and every
+service module:
+- `default.nix` — imports everything, sets hostname, the `admin` user + its authorized SSH keys,
+ passwordless sudo for wheel, `system.stateVersion`.
+- `hardware.nix` — Hetzner virtio/qemu-guest profile, GRUB (BIOS boot), network via DHCP on eth0.
+- `disko.nix` — declarative disk partitioning consumed by `nixos-anywhere` on first install: a
+ 1MiB BIOS-boot partition, a 512MiB ext4 `/boot`, and the rest as ext4 `/`.
+
+**Service modules (`modules/`)** are one file per concern, each self-contained and imported by
+`hosts/eurovm/default.nix`:
+- `common.nix` — SSH hardening (key-only, no root login), firewall (22/80/443/53 only), timezone,
+ nix settings (flakes, auto-gc, auto-optimise-store), base packages, disabled autoUpgrade.
+- `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.
+- `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.
+- `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.
+
+**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
+own key (`eurovm`, derived from its SSH host key), so the file can be decrypted both from a
+laptop and by the running server. When adding a new secret, declare it in `modules/sops.nix`
+(owner/group/mode/restartUnits) before referencing its `config.sops.secrets."<name>".path` in a
+service module.
+
+## Conventions to preserve
+
+- Every module takes only the NixOS module arguments it actually uses (`{ config, pkgs, lib, ... }`
+ etc.) — don't pad the argument set.
+- New services follow the same shape: bind the app to `127.0.0.1:<port>`, add a `virtualHosts`
+ entry in `caddy.nix`, and if the service needs a persistent secret, declare it in
+ `modules/sops.nix` first.
+- Comments in modules explain *why* a workaround exists (e.g. the adguard password-injection
+ dance, the nginx shim in cgit) — keep that context when editing those files rather than
+ stripping it.
diff --git a/README.md b/README.md
index 5fb08d5..5fa57b3 100644
--- a/README.md
+++ b/README.md
@@ -40,20 +40,18 @@ modules/
## Prerequisites (Local Machine)
-Before running any deploy commands you need the following installed on your laptop.
+Install these on your laptop before running any deploy commands.
### 1. Nix (with flakes enabled)
-Nix must be installed locally. It is used to evaluate the flake, run `nixos-anywhere`,
-and build the system closure. It is **not** needed on the Hetzner VM in advance -
-`nixos-anywhere` takes care of installing NixOS there.
+Used to evaluate the flake, run `nixos-anywhere`, and build the system closure. Not needed on
+the Hetzner VM in advance - `nixos-anywhere` installs NixOS there for you.
-The easiest installer (enables flakes automatically):
```bash
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
```
-Or use the official installer and then add this to `/etc/nix/nix.conf`:
+Or use the official installer and add this to `/etc/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
@@ -66,20 +64,19 @@ nix run nixpkgs#hello
### 2. sops + ssh-to-age + age
-These are needed to set up encrypted secrets before the first deploy.
-With Nix installed, you can run them directly without a permanent install:
+Needed to set up encrypted secrets before the first deploy. With Nix installed, run without a
+permanent install:
```bash
nix shell nixpkgs#sops nixpkgs#ssh-to-age nixpkgs#age
```
-Or install them permanently:
+Or install permanently:
```bash
nix profile install nixpkgs#sops nixpkgs#ssh-to-age nixpkgs#age
```
### 3. apache2-utils (for AdGuard bcrypt hash)
-Needed to generate the AdGuard admin password hash:
```bash
# macOS
brew install httpd
@@ -93,105 +90,101 @@ nix shell nixpkgs#apacheHttpd
---
-## Bootstrap: First Deploy
+## First-Time Setup
-### 1. Create the Hetzner Cloud VM
+Follow these steps in order, once, right after cloning this repo.
-- Log in to https://console.hetzner.cloud
-- Create a new server: **CX22** (2 vCPU / 4 GB RAM), location **Nuremberg** or **Falkenstein**
-- Base image: **Debian 12** (nixos-anywhere will replace it)
-- Add your SSH public keys to the Hetzner project so root access works during install
-- Note the assigned **public IPv4 address**
+### Step 1 - Convert SSH keys to age and fill in `.sops.yaml`
-### 2. Create DNS records
-
-At your DNS provider (for karanj.com), create **A records** pointing at the server IP:
-
-```
-dns.karanj.com A <server-ipv4>
-rss.karanj.com A <server-ipv4>
-budget.karanj.com A <server-ipv4>
-git.karanj.com A <server-ipv4>
-```
-
-Or a single wildcard: `*.karanj.com A <server-ipv4>`
-
-DNS must resolve **before** the first `nixos-rebuild` so Caddy can obtain TLS certificates.
-
-### 3. Set up sops-nix secrets
-
-**3a. Convert your SSH public keys to age format:**
-
-On your local machine (Einstein):
+On **Einstein**:
```bash
cat ~/.ssh/id_ed25519.pub | ssh-to-age
-# -> age1xxxx... (copy this)
+# -> age1xxxx...
```
-Repeat on Galileo (or use the public key directly):
+On **Galileo** (or derive from the public key directly):
```bash
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age
-# -> age1yyyy... (copy this)
+# -> age1yyyy...
```
-**3b. Update `.sops.yaml`:**
-
-Replace the placeholder values:
+Paste both `age1...` values into `.sops.yaml`:
```yaml
keys:
- - &einstein age1xxxx... # output from step 3a (Einstein)
- - &galileo age1yyyy... # output from step 3a (Galileo)
+ - &einstein age1xxxx... # Einstein
+ - &galileo age1yyyy... # Galileo
```
-**3c. Generate secrets:**
+### Step 2 - Fill in and encrypt `secrets/secrets.yaml`
-AdGuard password hash (uses Apache htpasswd bcrypt format):
+Generate the AdGuard bcrypt hash:
```bash
-# Install apache2-utils if needed: apt install apache2-utils
htpasswd -nB admin
# Enter password when prompted; copy everything AFTER "admin:"
-# Example output: admin:$2y$05$abc123...
-# Put only the hash part: $2y$05$abc123...
+# Example output: admin:$2y$05$abc123... -> keep only $2y$05$abc123...
```
-Miniflux admin password: choose any strong password.
-
-**3d. Fill in and encrypt secrets.yaml:**
+Edit the plaintext file with real values:
```bash
-# Edit the plaintext file first
vim secrets/secrets.yaml
-# Set ADMIN_PASSWORD and password_hash to real values
+# Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard)
+```
-# Then encrypt it in place
+Encrypt it in place:
+```bash
sops --encrypt --in-place secrets/secrets.yaml
```
-The file is now safe to commit. To edit it later: `sops secrets/secrets.yaml`
+The file is now safe to commit. To edit it later: `sops secrets/secrets.yaml`.
+
+### Step 3 - Create the Hetzner Cloud VM
+
+1. Log in to https://console.hetzner.cloud and create a new project (or use an existing one).
+2. Add both your SSH public keys to the project under **Security -> SSH Keys**.
+3. Create a server with:
+ - **Recommended:** CX23 (2 x86 vCPU / 4 GB RAM / 80 GB disk, Cost Optimized line) - good
+ value, x86-64, all services supported
+ - **Cheaper ARM option:** CAX11 (2 ARM vCPU / 4 GB RAM / 40 GB disk) - all services work
+ fine on ARM64
+ - Location: **Nuremberg** or **Falkenstein**
+ - Image: **Debian 12** (nixos-anywhere replaces it)
+4. Note the assigned **public IPv4 address**.
+
+> If you chose CAX11 (ARM64) instead of CX23 (x86_64), change `system = "x86_64-linux"` to
+> `system = "aarch64-linux"` in `flake.nix` first.
+
+### Step 4 - Point DNS at the server
+
+At your DNS provider for karanj.com, create **A records**:
+```
+dns.karanj.com A <server-ipv4>
+rss.karanj.com A <server-ipv4>
+budget.karanj.com A <server-ipv4>
+git.karanj.com A <server-ipv4>
+```
+
+Or a single wildcard: `*.karanj.com A <server-ipv4>`.
-### 4. Install NixOS with nixos-anywhere
+DNS must resolve **before** the first deploy so Caddy can obtain TLS certificates. Verify with
+`dig dns.karanj.com +short`.
+
+### Step 5 - Install NixOS with nixos-anywhere
If deploying from a non-x86_64-linux machine (e.g. an Apple Silicon Mac), add
-`--build-on remote` so the server builds its own closure rather than your local
-machine attempting a cross-architecture build:
+`--build-on remote` so the server builds its own closure rather than attempting a
+cross-architecture build locally:
```bash
-# From your local machine (requires nix with flakes enabled)
nix run github:nix-community/nixos-anywhere -- \
--flake .#eurovm \
--build-on remote \
root@<server-ipv4>
```
-> **Note:** if you chose a CAX11 (ARM64) server instead of a CX23 (x86_64), change
-> `system = "x86_64-linux"` to `system = "aarch64-linux"` in `flake.nix` first.
-
-nixos-anywhere will:
-1. Copy the flake to the server
-2. Run disko to partition the disk
-3. Install NixOS
-4. Reboot into the new system
+This copies the flake to the server, partitions the disk with disko, installs NixOS, and
+reboots into the new system. Takes 5-10 minutes.
-### 5. Add the server host key as a sops recipient
+### Step 6 - Add the server host key and redeploy
After the server reboots, grab its SSH host key and convert it to age:
```bash
@@ -199,19 +192,18 @@ ssh-keyscan <server-ipv4> | grep ed25519 | ssh-to-age
# -> age1zzzz...
```
-Update `.sops.yaml` - uncomment the `eurovm` key and fill in the age value:
+Update `.sops.yaml` - uncomment the `eurovm` key, fill in the age value, and add `*eurovm` to
+the `creation_rules` age list:
```yaml
- &eurovm age1zzzz...
```
-And update the `creation_rules` section to include `*eurovm`.
-
Re-encrypt secrets with the new recipient:
```bash
sops updatekeys secrets/secrets.yaml
```
-Commit and push, then redeploy:
+Commit and push `.sops.yaml` and the re-encrypted `secrets/secrets.yaml`, then deploy:
```bash
nixos-rebuild switch --flake .#eurovm \
--target-host admin@<server-ipv4> \
@@ -219,15 +211,30 @@ nixos-rebuild switch --flake .#eurovm \
--use-remote-sudo
```
+If your local machine is also x86_64-linux you can omit `--build-host`.
+
+### Step 7 - First login to each service
+
+| Service | URL | Action |
+|---------|-----|--------|
+| AdGuard Home | https://dns.karanj.com | Log in with `admin` + the password you set in Step 2 |
+| Miniflux | https://rss.karanj.com | Log in with `admin` (or your `ADMIN_USERNAME`) + your sops password |
+| Actual Budget | https://budget.karanj.com | Set a server password in the browser on first visit - no pre-configuration needed |
+| cgit | https://git.karanj.com | No login needed - public read-only |
+
+To use the server as your device's DNS resolver, point its DNS settings to `<server-ipv4>`.
+
+### Step 8 - (Optional) enable automatic OS updates
+
+If you push this repo to GitHub/Forgejo/cgit, update the `autoUpgrade.flake` line in
+`modules/common.nix` to point at it and set `enable = true`.
+
---
## Day-to-Day Operations
### Deploy updates
-If deploying from a non-x86_64-linux machine (e.g. an Apple Silicon Mac), pass
-`--build-host` so the server compiles its own closure instead of the local machine:
-
```bash
nixos-rebuild switch --flake .#eurovm \
--target-host admin@<server-ipv4> \
@@ -260,184 +267,46 @@ git clone https://git.karanj.com/myrepo.git
---
-## First Access - Per App
-
-### AdGuard Home (https://dns.karanj.com)
+## Per-App Operational Notes
-The admin account is pre-created from the sops secret during deployment.
+### AdGuard Home
-- **Username:** `admin`
-- **Password:** whatever you set as the plaintext before encrypting `secrets.yaml`
-
-Log in, then go to **Settings -> General** to verify DNS is working.
-To use the server as your DNS resolver, point your device's DNS to `<server-ipv4>`.
-
-If you need to reset the password:
+To reset the password:
```bash
# On the server, edit /var/lib/AdGuardHome/AdGuardHome.yaml
# Replace the users[].password bcrypt hash with a new one
# Then: systemctl restart adguardhome
```
-### Miniflux (https://rss.karanj.com)
-
-The admin account is pre-created from the sops secret on first startup.
-
-- **Username:** `admin` (or whatever you set as `ADMIN_USERNAME` in secrets.yaml)
-- **Password:** whatever you set as `ADMIN_PASSWORD` in secrets.yaml
-
-After logging in, go to **Settings -> Users** to change the password or
-add additional accounts.
+### Miniflux
To reset the password from the server:
```bash
sudo -u miniflux miniflux -reset-password
```
-### Actual Budget (https://budget.karanj.com)
+Additional accounts and password changes can also be managed from **Settings -> Users** in the
+web UI.
-No pre-configuration needed. On first visit:
+### Actual Budget
-1. Open https://budget.karanj.com in your browser
-2. The app will display a **"Create server password"** prompt
-3. Enter a strong password - this is what all your devices use to sync
-4. Click **OK** and the app is ready to use
+To reset the server password, delete `/var/lib/actual/server-files/account.json` on the server
+and restart the service: `systemctl restart actual`.
-To reset the password, delete `/var/lib/actual/server-files/account.json` on the
-server and restart the service: `systemctl restart actual`
+### cgit
-### cgit (https://git.karanj.com)
-
-No login required - the web interface is fully public and read-only.
-
-- **Browse:** open https://git.karanj.com in any browser
-- **Clone/Pull:** `git clone https://git.karanj.com/<repo>.git`
+- **Clone/Pull:** `git clone https://git.karanj.com/<repo>.git` (public, read-only)
- **Push:** SSH only - `git push git@<server-ipv4>:/srv/git/<repo>.git`
### Caddy / TLS
-TLS certificates are obtained automatically from Let's Encrypt on first startup
-(registered to me@karanj.com). No manual action needed. Certificates auto-renew.
+TLS certificates are obtained automatically from Let's Encrypt on first startup (registered to
+me@karanj.com) and auto-renew. No manual action needed.
Caddy logs: `journalctl -u caddy -f`
---
-## Next Steps
-
-These are the actions to take immediately after cloning this repo, in order.
-
-### Step 1 - Fill in `.sops.yaml` with your age keys
-
-On **Einstein**:
-```bash
-cat ~/.ssh/id_ed25519.pub | ssh-to-age
-```
-
-On **Galileo** (or derive from the public key directly):
-```bash
-echo "ssh-to-age AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age
-```
-
-Paste both `age1...` values into `.sops.yaml` replacing the `REPLACE_WITH_...` placeholders.
-
-### Step 2 - Set real passwords in `secrets/secrets.yaml`
-
-Generate the AdGuard bcrypt hash:
-```bash
-htpasswd -nB admin
-# Copy only the hash part (after "admin:")
-```
-
-Then edit the secrets file and fill in real values:
-```bash
-vim secrets/secrets.yaml
-# Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard)
-```
-
-Encrypt it:
-```bash
-sops --encrypt --in-place secrets/secrets.yaml
-```
-
-Commit both `.sops.yaml` and the encrypted `secrets/secrets.yaml`.
-
-### Step 3 - Create the Hetzner Cloud VM
-
-1. Go to https://console.hetzner.cloud and create a new project (or use an existing one)
-2. Add both your SSH public keys to the project under **Security -> SSH Keys**
-3. Create a server with the following spec:
- - **Recommended:** CX23 (2 x86 vCPU / 4 GB RAM / 80 GB disk, Cost Optimized line) - good value, x86-64, all services supported
- - **Cheaper ARM option:** CAX11 (2 ARM vCPU / 4 GB RAM / 40 GB disk) - all services work fine on ARM64
- - Location: **Nuremberg** or **Falkenstein**
- - Image: **Debian 12** (nixos-anywhere replaces it)
-4. Note the **public IPv4 address**
-
-### Step 4 - Point DNS at the server
-
-At your DNS registrar for karanj.com, add:
-```
-dns.karanj.com A <server-ipv4>
-rss.karanj.com A <server-ipv4>
-budget.karanj.com A <server-ipv4>
-git.karanj.com A <server-ipv4>
-```
-
-Wait for propagation (usually a few minutes with most registrars).
-Verify: `dig dns.karanj.com +short` should return your server IP.
-
-### Step 5 - Install NixOS with nixos-anywhere
-
-Make sure you have Nix with flakes enabled locally, then:
-```bash
-nix run github:nix-community/nixos-anywhere -- \
- --flake .#eurovm \
- --build-on remote \
- root@<server-ipv4>
-```
-
-The install takes 5-10 minutes. The server reboots into NixOS at the end.
-
-### Step 6 - Add the server host key to sops
-
-After the server is up:
-```bash
-ssh-keyscan <server-ipv4> | grep ed25519 | ssh-to-age
-# -> age1zzzz...
-```
-
-Uncomment and fill in the `eurovm` key in `.sops.yaml`, add `*eurovm` to the
-`creation_rules` age list, then re-encrypt:
-```bash
-sops updatekeys secrets/secrets.yaml
-```
-
-### Step 7 - Final deploy
-
-Push the updated `.sops.yaml` and re-encrypted `secrets/secrets.yaml`, then run:
-```bash
-nixos-rebuild switch --flake .#eurovm \
- --target-host admin@<server-ipv4> \
- --build-host admin@<server-ipv4> \
- --elevate=sudo
-```
-
-### Step 8 - First login to each service
-
-| Service | URL | Action |
-|---------|-----|--------|
-| AdGuard Home | https://dns.karanj.com | Log in with `admin` + your sops password |
-| Miniflux | https://rss.karanj.com | Log in with `admin` + your sops password |
-| Actual Budget | https://budget.karanj.com | Set server password in browser on first visit |
-| cgit | https://git.karanj.com | No login needed - public read-only |
-
-### Step 9 - Update `common.nix` flake URL (optional)
-
-If you push this repo to GitHub/Forgejo/cgit, update the `autoUpgrade.flake` line
-in `modules/common.nix` and set `enable = true` to get automatic OS updates.
-
----
-
## Firewall Summary
| Port | Protocol | Purpose |
@@ -447,5 +316,5 @@ in `modules/common.nix` and set `enable = true` to get automatic OS updates.
| 443 | TCP | HTTPS (all web services) |
| 53 | TCP + UDP | DNS (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. App-level ports (3000, 5006, 8080, 8086) are bound to
+127.0.0.1 and never exposed directly.
diff --git a/modules/adguard.nix b/modules/adguard.nix
index b3a7ee2..85c5f5c 100644
--- a/modules/adguard.nix
+++ b/modules/adguard.nix
@@ -22,6 +22,9 @@
dns = {
bind_hosts = [ "0.0.0.0" ];
port = 53;
+ # 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.
# Upstream DNS resolvers (privacy-respecting)
upstream_dns = [
"https://dns.quad9.net/dns-query"