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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
|
# karanj.com EU Server - NixOS on Hetzner Cloud
A flake-based, modular NixOS configuration for a personal server running in Europe.
## Services
| URL | Service |
| ------------------------- | ---------------------------------------- |
| https://dns.karanj.com | AdGuard Home (DNS ad-blocker + resolver) |
| https://rss.karanj.com | Miniflux (RSS/Atom reader) |
| https://budget.karanj.com | Actual Budget (personal finance) |
| 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 **853/tcp** for DNS-over-TLS and **8443/tcp** for
DNS-over-HTTPS - see [AdGuard Home](#adguard-home) below. Plain DNS (port 53) is disabled;
see the note there for why.
---
## Repository Layout
```
flake.nix top-level flake; single host "eurovm"
.sops.yaml sops-nix age recipient configuration
secrets/secrets.yaml sops-encrypted secrets (miniflux + adguard creds)
hosts/eurovm/
default.nix host assembly, users, SSH keys
hardware.nix Hetzner Cloud virtio/qemu-guest profile
disko.nix disk partitioning for nixos-anywhere
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 + DoH/DoT + web UI
miniflux.nix Miniflux + PostgreSQL
actual.nix Actual Budget
cgit.nix cgit + fcgiwrap + git push user
```
---
## Prerequisites (Local Machine)
Install these on your laptop before running any deploy commands.
### 1. Nix (with flakes enabled)
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.
```bash
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
```
Or use the official installer and add this to `/etc/nix/nix.conf`:
```
experimental-features = nix-command flakes
```
Verify:
```bash
nix --version
nix run nixpkgs#hello
```
### 2. sops + ssh-to-age + age
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 permanently:
```bash
nix profile install nixpkgs#sops nixpkgs#ssh-to-age nixpkgs#age
```
### 3. apache2-utils (for AdGuard bcrypt hash)
```bash
# macOS
brew install httpd
# Linux (Debian/Ubuntu)
sudo apt install apache2-utils
# Or via Nix
nix shell nixpkgs#apacheHttpd
```
---
## First-Time Setup
Follow these steps in order, once, right after cloning this repo.
### Step 1 - Convert SSH keys to age and fill in `.sops.yaml`
On **Einstein**:
```bash
cat ~/.ssh/id_ed25519.pub | ssh-to-age
# -> age1xxxx...
```
On **Galileo** (or derive from the public key directly):
```bash
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age
# -> age1yyyy...
```
Paste both `age1...` values into `.sops.yaml`:
```yaml
keys:
- &einstein age1xxxx... # Einstein
- &galileo age1yyyy... # Galileo
```
### Step 2 - Fill in and encrypt `secrets/secrets.yaml`
Generate the AdGuard bcrypt hash:
```bash
htpasswd -nB admin
# Enter password when prompted; copy everything AFTER "admin:"
# Example output: admin:$2y$05$abc123... -> keep only $2y$05$abc123...
```
Edit the plaintext file with real values:
```bash
vim secrets/secrets.yaml
# Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard)
```
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`.
### 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>`.
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 attempting a
cross-architecture build locally:
```bash
nix run github:nix-community/nixos-anywhere -- \
--flake .#eurovm \
--build-on remote \
root@<server-ipv4>
```
This copies the flake to the server, partitions the disk with disko, installs NixOS, and
reboots into the new system. Takes 5-10 minutes.
### Step 6 - Add the server host key and redeploy
After the server reboots, grab its SSH host key and convert it to age:
```bash
ssh-keyscan <server-ipv4> | grep ed25519 | ssh-to-age
# -> age1zzzz...
```
Update `.sops.yaml` - uncomment the `eurovm` key, fill in the age value, and add `*eurovm` to
the `creation_rules` age list:
```yaml
- &eurovm age1zzzz...
```
Re-encrypt secrets with the new recipient:
```bash
sops updatekeys secrets/secrets.yaml
```
Commit and push `.sops.yaml` and the re-encrypted `secrets/secrets.yaml`, then deploy. Copy
`.env.example` to `.env`, set `SERVER_IP` to the server's Tailscale IP (the server must be
joined to your tailnet first), then:
```bash
./deploy.sh
```
### 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, configure DNS-over-TLS or DNS-over-HTTPS as
described under [AdGuard Home](#adguard-home) below - plain DNS (port 53) is disabled.
### 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
```bash
./deploy.sh
```
Reads `SERVER_IP` from `.env` (copy `.env.example` if you haven't already) and runs
`nixos-rebuild switch` against it over Tailscale, with `--build-host` so the server always
builds its own closure regardless of your local machine's architecture.
### Edit secrets
```bash
sops secrets/secrets.yaml
# Save and exit; sops re-encrypts automatically
# Then redeploy to apply
```
### Create a new git repository
```bash
ssh git@<server-ipv4> init --bare /srv/git/myrepo.git
# On your local machine:
git remote add origin git@<server-ipv4>:/srv/git/myrepo.git
git push -u origin main
# It appears automatically at https://git.karanj.com
```
### Clone a public repository
```bash
git clone https://git.karanj.com/myrepo.git
```
---
## Per-App Operational Notes
### AdGuard Home
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
```
**Encrypted DNS client setup.** Plain DNS (port 53) is disabled - CERT-Bund/BSI flagged it as
an abusable open resolver (spoofable UDP reflection/amplification DDoS vector). Point client
devices at encrypted DNS instead:
- **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.
On iOS, an app like **DNS Override** can install the DoT/DoH settings as a signed
configuration profile (Settings -> General -> VPN & Device Management) without needing an MDM.
### Miniflux
To reset the password from the server:
```bash
sudo -u miniflux miniflux -reset-password
```
Additional accounts and password changes can also be managed from **Settings -> Users** in the
web UI.
### Actual Budget
To reset the server password, delete `/var/lib/actual/server-files/account.json` on the server
and restart the service: `systemctl restart actual`.
### cgit
- **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) and auto-renew. No manual action needed.
Caddy logs: `journalctl -u caddy -f`
---
## Firewall Summary
| Port | Protocol | Purpose |
| ---- | --------- | --------------------------------------------------------------------- |
| 22 | TCP | SSH (admin + git push) |
| 80 | TCP | HTTP (Caddy redirects to HTTPS; also serves the ACME HTTP-01 webroot) |
| 443 | TCP | HTTPS (all web services, via Caddy) |
| 853 | TCP | DNS-over-TLS (AdGuard Home) |
| 8443 | TCP | DNS-over-HTTPS (AdGuard Home) |
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.
|