# 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](#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. ```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 rss.karanj.com A budget.karanj.com A git.karanj.com A notes.karanj.com A ``` Or a single wildcard: `*.karanj.com A `. 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@ ``` 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 | 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 | | 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](#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@ init --bare /srv/git/myrepo.git # On your local machine: git remote add origin git@:/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/.git` (public, read-only) - **Push:** SSH only - `git push git@:/srv/git/.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 default `services.postgresql` integration). It's a live database, not a flat file - snapshot it with `pg_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 = false` means 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