# Hetzner Cloud VPS — public reverse proxy into the clan. # # Role: terminates public TLS via Caddy + Let's Encrypt, reverse-proxies # each declared subdomain over ZeroTier to the appropriate homelab host. # No navidrome/bbbot data ever hits disk here; this box is a relay. { config, lib, pkgs, ... }: { imports = [ ../disko-cloud.nix ]; nixpkgs.hostPlatform = "x86_64-linux"; # Hetzner Cloud vServers boot in BIOS mode (confirmed via rescue: # /sys/firmware/efi doesn't exist, product_name=vServer). systemd-boot # is UEFI-only, so use GRUB/BIOS. disko's EF02 BIOS boot partition # already tells GRUB where to embed stage-1.5; we just enable grub + # set the install device list. boot.loader.systemd-boot.enable = lib.mkForce false; boot.loader.grub.enable = lib.mkForce true; boot.loader.grub.efiSupport = lib.mkForce false; boot.loader.grub.devices = lib.mkForce [ "/dev/sda" ]; # Ensure no default-set .device slips through and duplicates mirroredBoots. boot.loader.grub.device = lib.mkForce "nodev"; # Hetzner Cloud cx23 uses QEMU virtio-scsi for the disk and virtio-net # for the NIC. Without these modules in initrd, the kernel can't find # the root partition and hangs during boot. boot.initrd.availableKernelModules = [ "virtio_pci" "virtio_scsi" "virtio_net" "virtio_blk" "ata_piix" "sd_mod" "sr_mod" ]; boot.kernelModules = [ "virtio_pci" "virtio_scsi" "virtio_net" ]; # Cloud provisioners add the initial root SSH key via cloud-init or # equivalent; we don't run cloud-init. All config is baked at install. networking.hostName = "vps-relay"; networking.useDHCP = lib.mkDefault true; time.timeZone = "Europe/Copenhagen"; # --- User + SSH ------------------------------------------------------ users.users.danny = { isNormalUser = true; extraGroups = [ "wheel" ]; openssh.authorizedKeys.keys = [ # Mac admin key (~/.ssh/id_ed25519_sunken_ship on the laptop — the # key the Mac uses to reach the fleet). Used for `clan machines # update vps-relay` from the Mac and at install via clan. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKW/akfIiVU5o63YrTAJVZhMj7kXfYHOnXDtlpVFW7pf danny@mac-admin" # sunken-ship's own key, so the push node can SSH into vps-relay # over ZeroTier for mesh introspection / debugging. "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIB9t4YAaoHvVouqp+qyFOq8o3SAtXMiAmjF6J0ldyx4g danny@sunken-ship" ]; }; users.users.root.openssh.authorizedKeys.keys = config.users.users.danny.openssh.authorizedKeys.keys; security.sudo.wheelNeedsPassword = false; services.openssh = { enable = true; settings = { PasswordAuthentication = false; KbdInteractiveAuthentication = false; PermitRootLogin = "prohibit-password"; }; }; # --- Firewall -------------------------------------------------------- # Public: 22 (SSH), 80 + 443 (Caddy). # ZT interface: trusted (set in the clan ZT module). networking.firewall.enable = true; networking.firewall.allowedTCPPorts = [ 22 80 443 ]; # fail2ban — public SSH gets brute-force probed within minutes of any # cloud VM being created. Ban offending IPs after a few failures. services.fail2ban = { enable = true; bantime = "1h"; bantime-increment = { enable = true; multipliers = "1 4 16 64 256"; # 1h, 4h, 16h, ~2.7d, ~10.7d maxtime = "30d"; }; jails.sshd.settings = { enabled = true; maxretry = 5; findtime = "10m"; }; }; # --- Caddy reverse proxy -------------------------------------------- # Subdomains → clan backends over ZeroTier. IPs are sunken-ship's / # phantom-ship's ZT IPv6; brackets required in URLs. services.caddy = { enable = true; email = "powerhouseplayer@gmail.com"; # Tell ACME to use Let's Encrypt's production endpoint (Caddy default). virtualHosts = { "navidrome.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:93d5:53a2:de33]:4533 ''; "bbbot.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:93d5:53a2:de33]:8080 ''; # B3Bot beta — bbbot's staging tenant under shipyard_poc_bot. # Same backend host as bbbot prod, port 8081. "b3.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:93d5:53a2:de33]:8081 ''; # Shelfish — phantom-ship's ZT IPv6. "shelfish.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8081 ''; # Scuttle — same backend, different port. WebSocket upgrade is # transparent under reverse_proxy. "scuttle.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8082 ''; # Bananasimulator — same backend, port 8083. "bananasimulator.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8083 ''; # KomTolk (formerly translate-platform) — same backend, port 8080. "komtolk.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8080 ''; # Forgejo on phantom-ship — Phase 1 of the de-platform-from-GitHub # roadmap (vimwiki/diary/2026-05-03.md). "git.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:3000 ''; # Escape Hormuz — turn-based boat-race Mini App, port 8090. "escapehormuz.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8090 ''; # bon — receipt scanner Mini App, port 8091. Camera capture in # the WebView needs HTTPS, which Caddy terminates here. "bon.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8091 ''; # notes — markdown blog (notes.X) + apex landing (X). Same backend # service on phantom :8092 routes by Host header. "notes.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8092 ''; "dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8092 ''; # kf — Kyranna Fardi architecture portfolio. Same notes service on # phantom :8092, routed by Host header (PORTFOLIO_HOST). "kf.dannydannydanny.me".extraConfig = '' reverse_proxy http://[fdd5:53a2:de33:d269:6499:936c:48a:bbdc]:8092 ''; }; }; # --- Basic tooling --------------------------------------------------- environment.systemPackages = with pkgs; [ git htop tcpdump ]; nix.settings.experimental-features = [ "nix-command" "flakes" ]; system.stateVersion = "25.11"; }