# Deployment Self-hosted on a single Linux box (Proxmox LXC / VM / bare metal). Four layers, innermost to outermost: 1. **Node app** (`adapter-node`) — listens on `127.0.0.1:3000` 2. **Caddy** (internal) — listens on LAN IP, Yggdrasil IPv6, NetBird IP, and `127.0.0.1` (for Tor). HTTP only — no TLS. 3. **Tor / Yggdrasil / NetBird** — overlay transports that connect peers to Caddy 4. **External reverse proxy** (off-box, e.g. at the network edge) — does TLS termination for the public hostname; forwards to LAN IP:8080 ``` ┌────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐ ┌────────────────┐ │ │ │ │ │ │ │ │ │ Internet user ├────►│ External TLS proxy ├────►│ Internal Caddy :8080 ├────►│ Node app :3000 │ │ │ │ │ │ │ │ │ └────────────────┘ └─────────────────────┘ └──────────────────────┘ └────────────────┘ ▲ │ ┌────────────────┐ ┌─────────────────────┐ │ │ │ │ │ │ │ Tor peer ├────►│ tor daemon (.onion) ├─────────────────┤ │ │ │ │ │ └────────────────┘ └─────────────────────┘ │ │ ┌────────────────┐ ┌─────────────────────┐ │ │ │ │ │ │ │ Yggdrasil peer ├────►│ Yggdrasil IPv6 ├─────────────────┤ │ │ │ │ │ └────────────────┘ └─────────────────────┘ │ │ ┌────────────────┐ ┌─────────────────────┐ │ │ │ │ │ │ │ NetBird peer ├────►│ NetBird IP ├─────────────────┘ │ │ │ │ └────────────────┘ └─────────────────────┘ ``` _Diagram rendered from a Mermaid source via [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid)._ All non-TLS: the external proxy terminates TLS, Tor encrypts its own path, Yggdrasil/NetBird are encrypted overlays. Caddy and the node app speak plain HTTP. --- ## 1. Node app `systemd` unit at `/etc/systemd/system/buildfor-life-budget.service`: ```ini [Unit] Description=B4L Budget (SvelteKit adapter-node) After=network-online.target postgresql.service Wants=network-online.target [Service] Type=simple User=budget WorkingDirectory=/opt/buildfor-life-budget EnvironmentFile=/opt/buildfor-life-budget/.env ExecStart=/usr/bin/node build/index.js Restart=on-failure RestartSec=5 # Hardening NoNewPrivileges=true ProtectSystem=strict ProtectHome=true ReadWritePaths=/var/lib/buildfor-life-budget/uploads PrivateTmp=true [Install] WantedBy=multi-user.target ``` `/opt/buildfor-life-budget/.env` must contain: ``` PORT=3000 HOST=127.0.0.1 ORIGIN=https://budget.example.com PROTOCOL_HEADER=x-forwarded-proto HOST_HEADER=x-forwarded-host BODY_SIZE_LIMIT=26214400 DATABASE_URL=postgresql://budget_app:...@127.0.0.1:5432/buildfor_life_budget UPLOADS_DIR=/var/lib/buildfor-life-budget/uploads FAVICON_FETCH_ENABLED=true ``` `ORIGIN` stays the public HTTPS URL — SvelteKit uses this for CSRF and cookie checks. If you hit the app via Tor/Yggdrasil/NetBird with a different Host header, either: - set `csrf.checkOrigin: false` in `svelte.config.js`, or - access each transport via a Host that matches `ORIGIN` (point DNS / hosts-file entries at the transport IPs). The second approach is cleaner — all transports see the same `Host: budget.example.com` and SvelteKit doesn't care which IP the connection came from. ```bash sudo systemctl daemon-reload sudo systemctl enable --now buildfor-life-budget.service ``` --- ## 2. Internal Caddy Package: `sudo apt install caddy` (or use the official repo for latest). `/etc/caddy/Caddyfile`: ```caddyfile { # Off — TLS is handled by the upstream external proxy. auto_https off admin off } # Listen on every transport-facing IP. Same backend. # LAN IP · Yggdrasil IPv6 · NetBird IP · localhost (for Tor) http://192.168.10.5:8080, http://[200:abcd:1234:...]:8080, http://100.64.0.5:8080, http://127.0.0.1:8080 { # Forward real client info so SvelteKit's PROTOCOL_HEADER/HOST_HEADER work. # The external TLS proxy should set X-Forwarded-Proto=https; default to http for # the overlays (Tor/Ygg/NetBird) which do their own encryption. @fromTls header X-Forwarded-Proto https header @fromTls X-Forwarded-Proto https reverse_proxy 127.0.0.1:3000 { header_up Host {host} header_up X-Real-IP {remote_host} header_up X-Forwarded-For {remote_host} header_up X-Forwarded-Proto {scheme} header_up X-Forwarded-Host {host} } encode zstd gzip request_body { max_size 26MB } log { output file /var/log/caddy/budget.log { roll_size 50mb roll_keep 10 } format console } } ``` Replace the three IPs with the real values: - **LAN IP** — `ip -4 -br addr show | grep -v lo` on the host. - **Yggdrasil IPv6** — `yggdrasilctl getSelf | grep -i address` (starts with `200:` or `201:`). - **NetBird IP** — `netbird status | grep 'NetBird IP'` (usually `100.64.0.0/10` range). ```bash sudo systemctl enable --now caddy sudo systemctl reload caddy # after edits ``` **Firewall** — the box should accept `:8080` inbound only on those four interfaces. With `ufw`: ```bash sudo ufw allow in on eth0 from 192.168.10.0/24 to any port 8080 proto tcp # LAN sudo ufw allow in on tun-ygg to any port 8080 proto tcp # Yggdrasil sudo ufw allow in on wt0 to any port 8080 proto tcp # NetBird (interface name varies) # Tor connects via 127.0.0.1 — no firewall rule needed sudo ufw deny 8080/tcp # block the rest ``` --- ## 3. Tor onion service Package: `sudo apt install tor`. Append to `/etc/tor/torrc`: ``` HiddenServiceDir /var/lib/tor/budget/ HiddenServicePort 80 127.0.0.1:8080 HiddenServiceVersion 3 ``` ```bash sudo systemctl restart tor sudo cat /var/lib/tor/budget/hostname ``` The printed `.onion` is the address. Protect the contents of `/var/lib/tor/budget/` — anyone with the private key can impersonate this service. Back them up offline. --- ## 4. Yggdrasil Package: official `.deb` from [yggdrasil-network.github.io](https://yggdrasil-network.github.io/installation-linux.html). After install, Yggdrasil picks a stable IPv6 address from the `200::/7` range. To see it: ```bash sudo yggdrasilctl getSelf ``` No Yggdrasil-side config needed for this app — peers who have your Yggdrasil address can reach `http://[200:...]:8080` once they're on the network. Add trusted peers in `/etc/yggdrasil.conf`'s `Peers` block, or rely on public peers listed at [publicpeers.neilalexander.dev](https://publicpeers.neilalexander.dev/). --- ## 5. NetBird Package: official from `pkgs.netbird.io`. Set up: ```bash sudo netbird up --setup-key sudo netbird status ``` Note the IP under `NetBird IP` — that's what Caddy binds to. NetBird manages WireGuard tunnels; any peer in the same NetBird network can hit that IP. Keep the daemon running via the included `netbird.service`. --- ## 6. External reverse proxy (TLS termination) This sits on your network edge, not on the app box. Example Caddyfile on the edge: ```caddyfile budget.example.com { reverse_proxy 192.168.10.5:8080 { header_up X-Forwarded-Proto https header_up X-Forwarded-Host {host} header_up Host {host} } } ``` Caddy auto-provisions a Let's Encrypt cert. If you use nginx/Traefik instead, forward the same headers so the internal Caddy and the SvelteKit app see the correct `https` protocol and public host. --- ## Update / redeploy On the app box: ```bash cd /opt/buildfor-life-budget sudo -u budget git pull sudo -u budget npm ci --omit=dev=false sudo -u budget npm run build sudo -u budget npm run db:push # only if schema changed sudo systemctl restart buildfor-life-budget ``` Caddy config does not need reloading unless IPs change. --- ## Troubleshooting | Symptom | Likely cause | |---|---| | External URL 502 from the edge proxy | Internal Caddy not up, or LAN firewall blocking `:8080` from the edge. | | `.onion` loads but forms return 403 | CSRF origin mismatch — either configure a hosts-file entry for the onion pointing to `budget.example.com`, or disable `csrf.checkOrigin` in `svelte.config.js`. | | Yggdrasil peer can't reach the address | Both sides must be on Yggdrasil; check `yggdrasilctl getPeers` on both ends. | | NetBird IP changes after reconnect | Setup key vs reusable key — use a fixed/dedicated peer config so the IP stays stable. | | SvelteKit sees HTTP for TLS-terminated traffic | External proxy not sending `X-Forwarded-Proto: https` header. | | Scheduler doesn't tick | Systemd unit restart killed the in-process interval; check logs for `[scheduler] recurring bills started`. | --- ## Security notes - The internal Caddy MUST NOT be reachable from the internet directly. The four-IP bind list keeps it on private transports only; verify with `ss -tlnp | grep :8080` that no `0.0.0.0:8080` shows up. - `.onion` key files and NetBird setup keys are secrets — back them up to an offline medium, not to the repo. - PostgreSQL should bind to `127.0.0.1` only (`listen_addresses = 'localhost'` in `postgresql.conf`). - File uploads under `UPLOADS_DIR` are served via auth-checked endpoints only — never expose that directory statically.