1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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.
|