Files
grabowski 639c261995
Validate / validate (push) Successful in 30s
Regenerate deployment architecture diagram via beautiful-mermaid
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 16:42:08 +07:00

11 KiB

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.

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:

[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.

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:

{
	# 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 IPip -4 -br addr show | grep -v lo on the host.
  • Yggdrasil IPv6yggdrasilctl getSelf | grep -i address (starts with 200: or 201:).
  • NetBird IPnetbird status | grep 'NetBird IP' (usually 100.64.0.0/10 range).
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:

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
sudo systemctl restart tor
sudo cat /var/lib/tor/budget/hostname

The printed <random>.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.

After install, Yggdrasil picks a stable IPv6 address from the 200::/7 range. To see it:

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.


5. NetBird

Package: official from pkgs.netbird.io. Set up:

sudo netbird up --setup-key <your-netbird-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:

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:

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.