Add deployment doc: Caddy + Tor + Yggdrasil + NetBird + external TLS proxy
Validate / validate (push) Successful in 32s
Validate / validate (push) Successful in 32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
# 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 ──→ TLS proxy ──┐
|
||||
│ │
|
||||
Tor peer ───┼─ onion ──→ tor daemon ──┤
|
||||
│ ├──→ Caddy:8080 ──→ Node:3000
|
||||
Yggdrasil peer ─┼─ ygg ──→ ygg IPv6 ──┤
|
||||
│ │
|
||||
NetBird peer ──┼─ WG ──→ netbird IP ──┘
|
||||
```
|
||||
|
||||
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 `<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](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 <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:
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user