diff options
| -rw-r--r-- | CLAUDE.md | 120 | ||||
| -rw-r--r-- | README.md | 329 | ||||
| -rw-r--r-- | modules/adguard.nix | 3 |
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. @@ -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" |
