aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorKaran Jayachandra <karan.jayachandra@nxp.com>2026-07-17 14:34:18 +0200
committerKaran Jayachandra <karan.jayachandra@nxp.com>2026-07-17 14:34:18 +0200
commit0d0788cd4ee378fba19e47a2c5d5527c196b65e0 (patch)
tree0fbd0e2644e94e0227005947004541fd4ff4d53e /modules
Initial commit
Diffstat (limited to 'modules')
-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
7 files changed, 359 insertions, 0 deletions
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";
+ };
+ };
+ };
+}