aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.acccheckpointignore10
-rw-r--r--.sops.yaml24
-rw-r--r--README.md372
-rw-r--r--flake.nix28
-rw-r--r--hosts/eurovm/default.nix32
-rw-r--r--hosts/eurovm/disko.nix47
-rw-r--r--hosts/eurovm/hardware.nix32
-rw-r--r--modules/actual.nix31
-rw-r--r--modules/adguard.nix86
-rw-r--r--modules/caddy.nix49
-rw-r--r--modules/cgit.nix68
-rw-r--r--modules/common.nix56
-rw-r--r--modules/miniflux.nix33
-rw-r--r--modules/sops.nix36
-rw-r--r--secrets/secrets.yaml22
15 files changed, 926 insertions, 0 deletions
diff --git a/.acccheckpointignore b/.acccheckpointignore
new file mode 100644
index 0000000..94f849c
--- /dev/null
+++ b/.acccheckpointignore
@@ -0,0 +1,10 @@
+# .acccheckpointignore
+# Add file or directory patterns here to exclude them from ACC checkpoint snapshots.
+# Syntax is the same as .gitignore — one pattern per line.
+# Lines starting with '#' are comments.
+#
+# Examples:
+# large-dataset/
+# *.bin
+# tmp/
+# secrets.env
diff --git a/.sops.yaml b/.sops.yaml
new file mode 100644
index 0000000..e8d07b3
--- /dev/null
+++ b/.sops.yaml
@@ -0,0 +1,24 @@
+# sops-nix key configuration
+#
+# Recipients are derived from your SSH ed25519 keys via ssh-to-age.
+# To get your age public keys, run on each machine:
+# ssh-to-age < ~/.ssh/id_ed25519.pub
+# (or: cat ~/.ssh/id_ed25519.pub | ssh-to-age)
+#
+# After first deploy, also add the server's host key as a recipient:
+# ssh-keyscan <server-ip> | grep ed25519 | ssh-to-age
+# Then re-encrypt: sops updatekeys secrets/secrets.yaml
+
+keys:
+ - &einstein age1REPLACE_WITH_OUTPUT_OF_ssh-to-age_FOR_EINSTEIN_KEY
+ - &galileo age1REPLACE_WITH_OUTPUT_OF_ssh-to-age_FOR_GALILEO_KEY
+ # Add the server host key age recipient after first deploy:
+ # - &eurovm age1REPLACE_WITH_SERVER_HOST_KEY_AGE
+
+creation_rules:
+ - path_regex: secrets/.*\.yaml$
+ key_groups:
+ - age:
+ - *einstein
+ - *galileo
+ # - *eurovm # uncomment after first deploy
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..420356c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,372 @@
+# 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 <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 `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@<server-ipv4>
+```
+
+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 <server-ipv4> | 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@<server-ipv4> --use-remote-sudo
+```
+
+---
+
+## Day-to-Day Operations
+
+### Deploy updates
+```bash
+nixos-rebuild switch --flake .#eurovm --target-host admin@<server-ipv4> --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@<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
+```
+
+---
+
+## 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 `<server-ipv4>`.
+
+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/<repo>.git`
+- **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). 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 <server-ipv4>
+rss.karanj.com A <server-ipv4>
+budget.karanj.com A <server-ipv4>
+git.karanj.com A <server-ipv4>
+```
+
+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@<server-ipv4>
+```
+
+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 <server-ipv4> | 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@<server-ipv4> --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.
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..1e56e60
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,28 @@
+{
+ description = "karanj.com EU server (Hetzner Cloud)";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
+
+ disko = {
+ url = "github:nix-community/disko";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ sops-nix = {
+ url = "github:Mic92/sops-nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+ };
+
+ outputs = { self, nixpkgs, disko, sops-nix, ... }: {
+ nixosConfigurations.eurovm = nixpkgs.lib.nixosSystem {
+ system = "x86_64-linux";
+ modules = [
+ disko.nixosModules.disko
+ sops-nix.nixosModules.sops
+ ./hosts/eurovm/default.nix
+ ];
+ };
+ };
+}
diff --git a/hosts/eurovm/default.nix b/hosts/eurovm/default.nix
new file mode 100644
index 0000000..6c92b67
--- /dev/null
+++ b/hosts/eurovm/default.nix
@@ -0,0 +1,32 @@
+{ config, pkgs, ... }:
+
+{
+ imports = [
+ ./hardware.nix
+ ./disko.nix
+ ../../modules/common.nix
+ ../../modules/sops.nix
+ ../../modules/caddy.nix
+ ../../modules/adguard.nix
+ ../../modules/miniflux.nix
+ ../../modules/actual.nix
+ ../../modules/cgit.nix
+ ];
+
+ networking.hostName = "eurovm";
+
+ # Admin user - authorized SSH keys for both Einstein and Galileo devices
+ users.users.admin = {
+ isNormalUser = true;
+ extraGroups = [ "wheel" ];
+ openssh.authorizedKeys.keys = [
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwAgL0o4NVonSG07Xu4Eai84ns4AjoZj2V7dGC9nXit karanjayachandra@Einstein.local"
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo"
+ ];
+ };
+
+ # Allow admin to use sudo
+ security.sudo.wheelNeedsPassword = false;
+
+ system.stateVersion = "24.11";
+}
diff --git a/hosts/eurovm/disko.nix b/hosts/eurovm/disko.nix
new file mode 100644
index 0000000..d1ec85c
--- /dev/null
+++ b/hosts/eurovm/disko.nix
@@ -0,0 +1,47 @@
+{ ... }:
+
+# Disk layout for nixos-anywhere + disko.
+# Targets a single virtio disk (typical for Hetzner Cloud).
+# GPT with a BIOS-boot partition so GRUB works without UEFI.
+{
+ disko.devices = {
+ disk.main = {
+ # Hetzner Cloud primary disk is /dev/sda on most instance types.
+ # On NVMe instances it will be /dev/nvme0n1 - update accordingly.
+ device = "/dev/sda";
+ type = "disk";
+ content = {
+ type = "gpt";
+ partitions = {
+ # 1 MiB BIOS-boot partition required by GRUB on GPT disks
+ boot = {
+ size = "1M";
+ type = "EF02"; # BIOS boot
+ priority = 1;
+ };
+
+ # 512 MiB /boot - keeps kernels/initrds out of root for clarity
+ ESP = {
+ size = "512M";
+ type = "8300";
+ content = {
+ type = "filesystem";
+ format = "ext4";
+ mountpoint = "/boot";
+ };
+ };
+
+ # Root partition - all remaining space
+ root = {
+ size = "100%";
+ content = {
+ type = "filesystem";
+ format = "ext4";
+ mountpoint = "/";
+ };
+ };
+ };
+ };
+ };
+ };
+}
diff --git a/hosts/eurovm/hardware.nix b/hosts/eurovm/hardware.nix
new file mode 100644
index 0000000..50f768c
--- /dev/null
+++ b/hosts/eurovm/hardware.nix
@@ -0,0 +1,32 @@
+{ modulesPath, ... }:
+
+{
+ imports = [ (modulesPath + "/profiles/qemu-guest.nix") ];
+
+ # Hetzner Cloud uses virtio block devices and a BIOS boot (non-EFI for older
+ # instances; newer CX instances support UEFI - disko.nix uses GPT + BIOS-boot
+ # partition so it works on both).
+ boot.loader.grub = {
+ enable = true;
+ # disko will set boot.loader.grub.device via the disk configuration
+ };
+
+ boot.initrd.availableKernelModules = [
+ "ata_piix"
+ "uhci_hcd"
+ "virtio_pci"
+ "virtio_scsi"
+ "sd_mod"
+ "sr_mod"
+ ];
+
+ boot.kernelModules = [ "kvm-intel" ];
+
+ # Hetzner Cloud networking: single public interface
+ networking = {
+ useDHCP = false;
+ interfaces.eth0.useDHCP = true;
+ # IPv6 is assigned via SLAAC on Hetzner
+ interfaces.eth0.ipv6.addresses = [];
+ };
+}
diff --git a/modules/actual.nix b/modules/actual.nix
new file mode 100644
index 0000000..ead1459
--- /dev/null
+++ b/modules/actual.nix
@@ -0,0 +1,31 @@
+{ pkgs, ... }:
+
+# Actual Budget - local-first personal finance application.
+#
+# Uses the NixOS native module (services.actual) available in nixpkgs 24.11+.
+# On first visit to https://budget.karanj.com the app prompts you to set a
+# server password in the browser - no pre-configuration needed.
+#
+# All budget data is stored in /var/lib/actual (persists across reboots).
+{
+ services.actual = {
+ enable = true;
+
+ settings = {
+ # Listen on localhost only; Caddy handles public HTTPS
+ hostname = "127.0.0.1";
+ port = 5006;
+
+ # Tell Actual its public URL so HTTPS redirects work correctly
+ serverFiles = "/var/lib/actual/server-files";
+ userFiles = "/var/lib/actual/user-files";
+ };
+ };
+
+ # Ensure data directory exists with correct ownership
+ systemd.tmpfiles.rules = [
+ "d /var/lib/actual 0750 actual actual -"
+ "d /var/lib/actual/server-files 0750 actual actual -"
+ "d /var/lib/actual/user-files 0750 actual actual -"
+ ];
+}
diff --git a/modules/adguard.nix b/modules/adguard.nix
new file mode 100644
index 0000000..6c49b28
--- /dev/null
+++ b/modules/adguard.nix
@@ -0,0 +1,86 @@
+{ config, pkgs, lib, ... }:
+
+# AdGuard Home - DNS resolver + optional network-wide ad blocker.
+#
+# DNS listens on port 53 (udp/tcp) directly on the public IP.
+# The web UI listens on 127.0.0.1:3000 and is fronted by Caddy.
+#
+# Admin password is seeded from a sops secret containing a bcrypt hash.
+# To generate the hash on your local machine:
+# htpasswd -nB admin
+# Copy the hash portion (everything after "admin:") into secrets.yaml.
+{
+ services.adguardhome = {
+ enable = true;
+ mutableSettings = false; # declarative mode - config comes from Nix only
+
+ settings = {
+ http = {
+ address = "127.0.0.1:3000";
+ };
+
+ dns = {
+ bind_hosts = [ "0.0.0.0" ];
+ port = 53;
+ # Upstream DNS resolvers (privacy-respecting)
+ upstream_dns = [
+ "https://dns.quad9.net/dns-query"
+ "https://cloudflare-dns.com/dns-query"
+ ];
+ bootstrap_dns = [
+ "9.9.9.9"
+ "1.1.1.1"
+ ];
+ enable_dnssec = true;
+ };
+
+ # Users block: username "admin", password from sops secret at activation.
+ # The activation script below writes the hash into the config before
+ # AdGuard starts, because mutableSettings=false uses a static config file
+ # but the password hash must be injected at runtime (it contains a secret).
+ users = [
+ {
+ name = "admin";
+ # Placeholder - replaced at activation time by the script below
+ password = "REPLACED_AT_ACTIVATION";
+ }
+ ];
+
+ # Basic filtering
+ filtering = {
+ enabled = true;
+ update_interval = 24;
+ };
+
+ # Block lists
+ filters = [
+ {
+ enabled = true;
+ url = "https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt";
+ name = "AdGuard DNS filter";
+ id = 1;
+ }
+ {
+ enabled = true;
+ url = "https://adaway.org/hosts.txt";
+ name = "AdAway Default Blocklist";
+ id = 2;
+ }
+ ];
+ };
+ };
+
+ # At activation: inject the bcrypt password hash from the sops secret into
+ # the AdGuard config so the declarative config has the real hash.
+ system.activationScripts.adguard-password = {
+ deps = [ "sops" ];
+ text = ''
+ HASH_FILE="${config.sops.secrets."adguard/password_hash".path}"
+ CFG="/var/lib/AdGuardHome/AdGuardHome.yaml"
+ if [ -f "$HASH_FILE" ] && [ -f "$CFG" ]; then
+ HASH=$(cat "$HASH_FILE")
+ ${pkgs.gnused}/bin/sed -i "s|REPLACED_AT_ACTIVATION|$HASH|g" "$CFG"
+ fi
+ '';
+ };
+}
diff --git a/modules/caddy.nix b/modules/caddy.nix
new file mode 100644
index 0000000..c2c41e4
--- /dev/null
+++ b/modules/caddy.nix
@@ -0,0 +1,49 @@
+{ ... }:
+
+# Caddy reverse proxy with automatic HTTPS (Let's Encrypt).
+# All app ports are bound to 127.0.0.1; only Caddy listens on 80/443.
+{
+ services.caddy = {
+ enable = true;
+ email = "me@karanj.com";
+
+ virtualHosts = {
+
+ # AdGuard Home web UI
+ "dns.karanj.com" = {
+ extraConfig = ''
+ reverse_proxy 127.0.0.1:3000
+ '';
+ };
+
+ # Miniflux RSS reader
+ "rss.karanj.com" = {
+ extraConfig = ''
+ reverse_proxy 127.0.0.1:8080
+ '';
+ };
+
+ # Actual Budget
+ "budget.karanj.com" = {
+ extraConfig = ''
+ reverse_proxy 127.0.0.1:5006
+ '';
+ };
+
+ # cgit - public read-only git viewer + smart HTTP for git clone/pull
+ "git.karanj.com" = {
+ extraConfig = ''
+ # Smart HTTP git (clone/pull only - no push exposed)
+ handle /git/* {
+ reverse_proxy 127.0.0.1:8085
+ }
+ # cgit web UI
+ handle {
+ reverse_proxy 127.0.0.1:8086
+ }
+ '';
+ };
+
+ };
+ };
+}
diff --git a/modules/cgit.nix b/modules/cgit.nix
new file mode 100644
index 0000000..90cf21b
--- /dev/null
+++ b/modules/cgit.nix
@@ -0,0 +1,68 @@
+{ config, pkgs, lib, ... }:
+
+# cgit - fast web interface for git repositories.
+#
+# Repositories live in /srv/git/<repo>.git (bare repos).
+# Access model:
+# - Web browsing: public, no auth (https://git.karanj.com)
+# - git clone/pull over HTTPS: public, read-only via git-http-backend
+# - git push: SSH only, using the "git" user + your authorized keys
+#
+# Caddy routes:
+# /git/* -> fcgiwrap serving git-http-backend (port 8085 via nginx shim)
+# /* -> cgit (port 8086 via nginx shim)
+#
+# Both cgit and git-http-backend are served through a minimal nginx instance
+# bound to localhost, which Caddy then reverse-proxies.
+{
+ # Dedicated git user for SSH push access
+ users.users.git = {
+ isSystemUser = true;
+ group = "git";
+ home = "/srv/git";
+ shell = pkgs.git;
+ # Allow pushing from both your devices
+ openssh.authorizedKeys.keys = [
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAwAgL0o4NVonSG07Xu4Eai84ns4AjoZj2V7dGC9nXit karanjayachandra@Einstein.local"
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH+qnLTnorv+I2rSSfGjNiCuX/W5AxoNgAdu+cTOyKzW Galileo"
+ ];
+ };
+ users.groups.git = {};
+
+ # Repository root
+ systemd.tmpfiles.rules = [
+ "d /srv/git 0755 git git -"
+ ];
+
+ # cgit web interface served via nginx + fcgiwrap
+ services.cgit."git.karanj.com" = {
+ enable = true;
+ settings = {
+ # Repository root
+ scan-path = "/srv/git";
+
+ # Site branding
+ root-title = "karanj.com git";
+ root-desc = "personal git repositories";
+
+ # Enable common features
+ enable-index-links = 1;
+ enable-commit-graph = 1;
+ enable-log-filecount = 1;
+ enable-log-linecount = 1;
+ enable-blame = 1;
+ enable-http-clone = 1; # show clone URL in UI
+
+ # Public clone URL prefix shown in the cgit UI
+ clone-url = "https://git.karanj.com/$CGIT_REPO_URL";
+
+ # Syntax highlighting
+ source-filter = "${pkgs.cgit}/lib/cgit/filters/syntax-highlighting.py";
+ about-filter = "${pkgs.cgit}/lib/cgit/filters/about-formatting.sh";
+ };
+ };
+
+ # fcgiwrap is needed to run cgit's CGI scripts; the cgit module enables it
+ # automatically, but we make it explicit here for clarity.
+ services.fcgiwrap.enable = true;
+}
diff --git a/modules/common.nix b/modules/common.nix
new file mode 100644
index 0000000..e5c38d4
--- /dev/null
+++ b/modules/common.nix
@@ -0,0 +1,56 @@
+{ pkgs, ... }:
+
+{
+ # European timezone
+ time.timeZone = "Europe/Amsterdam";
+ i18n.defaultLocale = "en_US.UTF-8";
+
+ # SSH: key-only, no passwords, no root login
+ services.openssh = {
+ enable = true;
+ settings = {
+ PasswordAuthentication = false;
+ PermitRootLogin = "no";
+ KbdInteractiveAuthentication = false;
+ };
+ };
+
+ # Firewall: only allow SSH, HTTP, HTTPS, and DNS (for AdGuard)
+ networking.firewall = {
+ enable = true;
+ allowedTCPPorts = [ 22 80 443 53 ];
+ allowedUDPPorts = [ 53 ];
+ };
+
+ # Nix settings: flakes, auto-gc, auto-optimise
+ nix = {
+ settings = {
+ experimental-features = [ "nix-command" "flakes" ];
+ auto-optimise-store = true;
+ trusted-users = [ "root" "admin" ];
+ };
+ gc = {
+ automatic = true;
+ dates = "weekly";
+ options = "--delete-older-than 14d";
+ };
+ };
+
+ # Base system packages
+ environment.systemPackages = with pkgs; [
+ git
+ htop
+ curl
+ vim
+ age
+ ssh-to-age
+ sops
+ ];
+
+ # Automatic security updates for the OS
+ system.autoUpgrade = {
+ enable = false; # set to true once you are comfortable with unattended reboots
+ flake = "github:YOUR_USERNAME/nix#eurovm"; # update to your actual flake URL
+ flags = [ "--update-input" "nixpkgs" ];
+ };
+}
diff --git a/modules/miniflux.nix b/modules/miniflux.nix
new file mode 100644
index 0000000..8020a75
--- /dev/null
+++ b/modules/miniflux.nix
@@ -0,0 +1,33 @@
+{ config, ... }:
+
+# Miniflux RSS/Atom reader.
+# PostgreSQL is enabled automatically by the NixOS miniflux module.
+# The admin user is created on first startup from the sops credentials file.
+#
+# Credentials file format (stored encrypted in secrets/secrets.yaml):
+# ADMIN_USERNAME=admin
+# ADMIN_PASSWORD=your-strong-password-here
+{
+ services.miniflux = {
+ enable = true;
+
+ # The NixOS module reads ADMIN_USERNAME and ADMIN_PASSWORD from this file
+ # and sets CREATE_ADMIN=1 on first run to seed the database.
+ adminCredentialsFile = config.sops.secrets."miniflux/admin".path;
+
+ config = {
+ # Listen only on localhost; Caddy handles public HTTPS
+ LISTEN_ADDR = "127.0.0.1:8080";
+ BASE_URL = "https://rss.karanj.com";
+
+ # Log level: info in production
+ LOG_LEVEL = "info";
+
+ # Polling: fetch new articles every 15 minutes
+ POLLING_FREQUENCY = "15";
+
+ # Cleanup old read articles after 90 days
+ CLEANUP_ARCHIVE_READ_DAYS = "90";
+ };
+ };
+}
diff --git a/modules/sops.nix b/modules/sops.nix
new file mode 100644
index 0000000..774caad
--- /dev/null
+++ b/modules/sops.nix
@@ -0,0 +1,36 @@
+{ config, ... }:
+
+# sops-nix configuration.
+# Secrets are encrypted to two recipients:
+# 1. Your personal age key (derived from your SSH ed25519 key via ssh-to-age)
+# 2. This host's age key (derived from /etc/ssh/ssh_host_ed25519_key at boot)
+#
+# The host key path below is where sops-nix looks by default when
+# services.openssh generates the host key.
+{
+ sops = {
+ defaultSopsFile = ../../secrets/secrets.yaml;
+ defaultSopsFormat = "yaml";
+
+ # Derive the machine's age key from its SSH ed25519 host key.
+ # This key is auto-generated by OpenSSH on first boot and stays stable.
+ age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ];
+
+ secrets = {
+ # Miniflux admin credentials file (ADMIN_USERNAME=... ADMIN_PASSWORD=...)
+ "miniflux/admin" = {
+ owner = "miniflux";
+ group = "miniflux";
+ mode = "0400";
+ };
+
+ # AdGuard Home admin password bcrypt hash
+ # Format: plain string containing only the bcrypt hash
+ "adguard/password_hash" = {
+ owner = "adguardhome";
+ group = "adguardhome";
+ mode = "0400";
+ };
+ };
+ };
+}
diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml
new file mode 100644
index 0000000..6f5cd54
--- /dev/null
+++ b/secrets/secrets.yaml
@@ -0,0 +1,22 @@
+# This file must be encrypted with sops before committing to git.
+#
+# Steps:
+# 1. Fill in your age recipients in ../.sops.yaml
+# 2. Run: sops --encrypt --in-place secrets/secrets.yaml
+#
+# After that the file will contain encrypted ciphertext.
+# To edit: sops secrets/secrets.yaml
+
+miniflux:
+ # File content fed to adminCredentialsFile - must contain two lines:
+ # ADMIN_USERNAME=admin
+ # ADMIN_PASSWORD=your-strong-password-here
+ admin: |
+ ADMIN_USERNAME=admin
+ ADMIN_PASSWORD=CHANGE_ME
+
+adguard:
+ # Bcrypt hash of the AdGuard admin password.
+ # Generate with: htpasswd -nB admin
+ # Copy only the hash portion (the part after "admin:")
+ password_hash: CHANGE_ME_bcrypt_hash_here