Files
buildfor_life_budget/docs/deployment.md
T
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

275 lines
11 KiB
Markdown

# 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 `<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.