# 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 **port 53 (UDP + TCP)** for plain DNS, **853/tcp** for DNS-over-TLS, and **8443/tcp** for DNS-over-HTTPS - see [AdGuard Home](#adguard-home) below. --- ## 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 rss.karanj.com A budget.karanj.com A git.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: ```bash nixos-rebuild switch --flake .#eurovm \ --target-host admin@ \ --build-host admin@ \ --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 ``. ### 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 nixos-rebuild switch --flake .#eurovm \ --target-host admin@ \ --build-host admin@ \ --use-remote-sudo ``` If your local machine is also x86_64-linux you can omit `--build-host`. ### 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.** Point client devices at: - **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. ### 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` ### 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) | | 53 | TCP + UDP | Plain DNS (AdGuard Home) | | 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.