# 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 DNS. --- ## 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) caddy.nix reverse proxy + HTTPS virtual hosts adguard.nix AdGuard Home DNS + web UI miniflux.nix Miniflux + PostgreSQL actual.nix Actual Budget cgit.nix cgit + fcgiwrap + git push user ``` --- ## Bootstrap: First Deploy ### 1. Create the Hetzner Cloud VM - Log in to https://console.hetzner.cloud - Create a new server: **CX22** (2 vCPU / 4 GB RAM), location **Nuremberg** or **Falkenstein** - Base image: **Debian 12** (nixos-anywhere will replace it) - Add your SSH public keys to the Hetzner project so root access works during install - Note the assigned **public IPv4 address** ### 2. Create DNS records At your DNS provider (for karanj.com), create **A records** pointing at the server IP: ``` 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 `nixos-rebuild` so Caddy can obtain TLS certificates. ### 3. Set up sops-nix secrets **3a. Convert your SSH public keys to age format:** On your local machine (Einstein): ```bash cat ~/.ssh/id_ed25519.pub | ssh-to-age # -> age1xxxx... (copy this) ``` Repeat on Galileo (or use the public key directly): ```bash echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age # -> age1yyyy... (copy this) ``` **3b. Update `.sops.yaml`:** Replace the placeholder values: ```yaml keys: - &einstein age1xxxx... # output from step 3a (Einstein) - &galileo age1yyyy... # output from step 3a (Galileo) ``` **3c. Generate secrets:** AdGuard password hash (uses Apache htpasswd bcrypt format): ```bash # Install apache2-utils if needed: apt install apache2-utils htpasswd -nB admin # Enter password when prompted; copy everything AFTER "admin:" # Example output: admin:$2y$05$abc123... # Put only the hash part: $2y$05$abc123... ``` Miniflux admin password: choose any strong password. **3d. Fill in and encrypt secrets.yaml:** ```bash # Edit the plaintext file first vim secrets/secrets.yaml # Set ADMIN_PASSWORD and password_hash to real values # Then 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` ### 4. Install NixOS with nixos-anywhere ```bash # From your local machine (requires nix with flakes enabled) nix run github:nix-community/nixos-anywhere -- \ --flake .#eurovm \ root@ ``` nixos-anywhere will: 1. Copy the flake to the server 2. Run disko to partition the disk 3. Install NixOS 4. Reboot into the new system ### 5. Add the server host key as a sops recipient 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 and fill in the age value: ```yaml - &eurovm age1zzzz... ``` And update the `creation_rules` section to include `*eurovm`. Re-encrypt secrets with the new recipient: ```bash sops updatekeys secrets/secrets.yaml ``` Commit and push, then redeploy: ```bash nixos-rebuild switch --flake .#eurovm --target-host admin@ --use-remote-sudo ``` --- ## Day-to-Day Operations ### Deploy updates ```bash nixos-rebuild switch --flake .#eurovm --target-host admin@ --use-remote-sudo ``` ### 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 ``` --- ## First Access - Per App ### AdGuard Home (https://dns.karanj.com) The admin account is pre-created from the sops secret during deployment. - **Username:** `admin` - **Password:** whatever you set as the plaintext before encrypting `secrets.yaml` Log in, then go to **Settings -> General** to verify DNS is working. To use the server as your DNS resolver, point your device's DNS to ``. If you need 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 ``` ### Miniflux (https://rss.karanj.com) The admin account is pre-created from the sops secret on first startup. - **Username:** `admin` (or whatever you set as `ADMIN_USERNAME` in secrets.yaml) - **Password:** whatever you set as `ADMIN_PASSWORD` in secrets.yaml After logging in, go to **Settings -> Users** to change the password or add additional accounts. To reset the password from the server: ```bash sudo -u miniflux miniflux -reset-password ``` ### Actual Budget (https://budget.karanj.com) No pre-configuration needed. On first visit: 1. Open https://budget.karanj.com in your browser 2. The app will display a **"Create server password"** prompt 3. Enter a strong password - this is what all your devices use to sync 4. Click **OK** and the app is ready to use To reset the password, delete `/var/lib/actual/server-files/account.json` on the server and restart the service: `systemctl restart actual` ### cgit (https://git.karanj.com) No login required - the web interface is fully public and read-only. - **Browse:** open https://git.karanj.com in any browser - **Clone/Pull:** `git clone https://git.karanj.com/.git` - **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). No manual action needed. Certificates auto-renew. Caddy logs: `journalctl -u caddy -f` --- ## Next Steps These are the actions to take immediately after cloning this repo, in order. ### Step 1 - Fill in `.sops.yaml` with your age keys On **Einstein**: ```bash cat ~/.ssh/id_ed25519.pub | ssh-to-age ``` On **Galileo** (or derive from the public key directly): ```bash echo "ssh-to-age AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo" | ssh-to-age ``` Paste both `age1...` values into `.sops.yaml` replacing the `REPLACE_WITH_...` placeholders. ### Step 2 - Set real passwords in `secrets/secrets.yaml` Generate the AdGuard bcrypt hash: ```bash htpasswd -nB admin # Copy only the hash part (after "admin:") ``` Then edit the secrets file and fill in real values: ```bash vim secrets/secrets.yaml # Set ADMIN_PASSWORD (Miniflux) and password_hash (AdGuard) ``` Encrypt it: ```bash sops --encrypt --in-place secrets/secrets.yaml ``` Commit both `.sops.yaml` and the encrypted `secrets/secrets.yaml`. ### Step 3 - Create the Hetzner Cloud VM 1. Go 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 the following spec: - **Minimum:** CAX11 (2 ARM vCPU / 4 GB RAM / 40 GB disk, ~3.79 EUR/month) - all services work fine on ARM64 - **Alternative:** CX22 (2 x86 vCPU / 4 GB RAM / 40 GB disk, ~4.35 EUR/month) - use if you need x86 compatibility later - Location: **Nuremberg** or **Falkenstein** - Image: **Debian 12** (nixos-anywhere replaces it) 4. Note the **public IPv4 address** ### Step 4 - Point DNS at the server At your DNS registrar for karanj.com, add: ``` dns.karanj.com A rss.karanj.com A budget.karanj.com A git.karanj.com A ``` Wait for propagation (usually a few minutes with most registrars). Verify: `dig dns.karanj.com +short` should return your server IP. ### Step 5 - Install NixOS with nixos-anywhere Make sure you have Nix with flakes enabled locally, then: ```bash nix run github:nix-community/nixos-anywhere -- \ --flake .#eurovm \ root@ ``` The install takes 5-10 minutes. The server reboots into NixOS at the end. ### Step 6 - Add the server host key to sops After the server is up: ```bash ssh-keyscan | grep ed25519 | ssh-to-age # -> age1zzzz... ``` Uncomment and fill in the `eurovm` key in `.sops.yaml`, add `*eurovm` to the `creation_rules` age list, then re-encrypt: ```bash sops updatekeys secrets/secrets.yaml ``` ### Step 7 - Final deploy Push the updated `.sops.yaml` and re-encrypted `secrets/secrets.yaml`, then run: ```bash nixos-rebuild switch --flake .#eurovm --target-host admin@ --use-remote-sudo ``` ### Step 8 - First login to each service | Service | URL | Action | |---------|-----|--------| | AdGuard Home | https://dns.karanj.com | Log in with `admin` + your sops password | | Miniflux | https://rss.karanj.com | Log in with `admin` + your sops password | | Actual Budget | https://budget.karanj.com | Set server password in browser on first visit | | cgit | https://git.karanj.com | No login needed - public read-only | ### Step 9 - Update `common.nix` flake URL (optional) If you push this repo to GitHub/Forgejo/cgit, update the `autoUpgrade.flake` line in `modules/common.nix` and set `enable = true` to get automatic OS updates. --- ## Firewall Summary | Port | Protocol | Purpose | |------|----------|---------| | 22 | TCP | SSH (admin + git push) | | 80 | TCP | HTTP (Caddy redirects to HTTPS) | | 443 | TCP | HTTPS (all web services) | | 53 | TCP + UDP | DNS (AdGuard Home) | All other ports are closed. App-level ports (3000, 5006, 8080, 8086) are bound to 127.0.0.1 and never exposed directly.