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) |
| https://notes.karanj.com | Memos (note-taking) |
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 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
memos.nix Memos (note-taking)
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.
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:
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:
nix shell nixpkgs#sops nixpkgs#ssh-to-age nixpkgs#age
Or install permanently:
nix profile install nixpkgs#sops nixpkgs#ssh-to-age nixpkgs#age
3. apache2-utils (for AdGuard bcrypt hash)
# 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:
cat ~/.ssh/id_ed25519.pub | ssh-to-age
# -> age1xxxx...
On Galileo (or derive from the public key directly):
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age
# -> age1yyyy...
Paste both age1... values into .sops.yaml:
keys:
- &einstein age1xxxx... # Einstein
- &galileo age1yyyy... # Galileo
Step 2 - Fill in and encrypt secrets/secrets.yaml
Generate the AdGuard bcrypt hash:
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:
vim secrets/secrets.yaml
# Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard)
Encrypt it in place:
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
- Log in to https://console.hetzner.cloud and create a new project (or use an existing one).
- Add both your SSH public keys to the project under Security -> SSH Keys.
- 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)
- Note the assigned public IPv4 address.
If you chose CAX11 (ARM64) instead of CX23 (x86_64), change
system = "x86_64-linux"tosystem = "aarch64-linux"inflake.nixfirst.
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>
notes.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:
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:
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:
- &eurovm age1zzzz...
Re-encrypt secrets with the new recipient:
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:
./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 |
| Memos | https://notes.karanj.com | Create the first account directly in the web UI on first visit |
To use the server as your device's DNS resolver, configure DNS-over-TLS or DNS-over-HTTPS as described under 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
./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
sops secrets/secrets.yaml
# Save and exit; sops re-encrypts automatically
# Then redeploy to apply
Create a new git repository
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
git clone https://git.karanj.com/myrepo.git
Per-App Operational Notes
AdGuard Home
To reset the password:
# 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:
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
Memos
Data is stored as a sqlite database under /var/lib/memos/. No password reset flow needed
beyond what the web UI itself offers - it's a single-user instance with no separate admin
credentials in this config.
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, 5230, 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.
Backup Notes
This server relies on Hetzner's rolling Cloud Backups (whole-disk snapshots) for disaster
recovery. Two things are not at risk from server loss regardless of that: the OS and every
service's configuration are fully reconstructable from this repo via nixos-anywhere +
nixos-rebuild, and the repo itself (including encrypted secrets in secrets/secrets.yaml) is
already mirrored off-server at git@gitlab.com:KaranJayachandra/feynman.git.
What Hetzner's snapshots don't give you is selective or offsite recovery of runtime-generated application data - restoring means restoring the whole disk, and the backups live in the same Hetzner account as the server. If you ever add an independent (offsite) backup job, these are the paths that actually matter, per service:
- Miniflux -
/var/lib/postgresql/(the module's defaultservices.postgresqlintegration). It's a live database, not a flat file - snapshot it withpg_dump, not a raw file copy. - Actual Budget -
/var/lib/actual. - cgit -
/srv/git(the bare repos). - Memos -
/var/lib/memos(sqlite database). - AdGuard Home -
/var/lib/AdGuardHome, low priority:mutableSettings = falsemeans the config is fully regenerated from this repo on every service start, so nothing there is irreplaceable except transient query logs/stats.
Todo
- [ ] Backup Strategy
- [ ] git about pages
- [ ] Webpage deploy
