feat(deploy): nginx reverse-proxy deploy setup for bifrost-portal.fenja.ai

Run the Astro Node standalone server as a hardened systemd service on
127.0.0.1:4322, behind the existing nginx which terminates TLS and proxies
the bifrost-portal.fenja.ai hostname. Coexists with the other Fenja site;
its config is untouched.

- deploy/bifrost-portal.service: systemd unit (bifrost user, EnvironmentFile,
  ProtectSystem, ReadWritePaths to the data dir only)
- deploy/nginx/bifrost-portal.fenja.ai.conf: HTTP->HTTPS + proxy site block
- .env.production.example: prod env vars (secret, db path, uploads, host/port)
- scripts/deploy.sh: server-side pull -> install (rebuild native dep) ->
  build -> migrate -> restart; persistent data untouched
- scripts/backup.sh: nightly online .backup, 30-day retention
- DEPLOY.md: full runbook (port check, DNS, provision, TLS, backups, rollback)

Persistent data (db, uploads, backups) lives in /var/lib/bifrost-portal,
outside the /opt/bifrost-portal build dir, so redeploys never wipe it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind 2026-06-17 12:54:35 +02:00
parent fab927884c
commit 819f8fa91c
7 changed files with 411 additions and 0 deletions

24
.env.production.example Normal file
View file

@ -0,0 +1,24 @@
# Production environment for bifrost-portal.fenja.ai
# Copy to the EnvironmentFile path referenced by the systemd unit
# (default: /etc/bifrost-portal.env) and fill in real values. Keep it
# chmod 600, owned by the service user. NEVER commit the real file.
# Long random string used to sign sessions and invite tokens.
# Generate with: openssl rand -hex 32
BIFROST_SECRET=change-me-openssl-rand-hex-32
# Absolute path to the SQLite database. Lives OUTSIDE the deploy dir so
# redeploys never touch it. Honored by src/lib/db.ts and scripts/migrate.js.
BIFROST_DB_PATH=/var/lib/bifrost-portal/bifrost.db
# Absolute path to the runtime uploads dir (event photos). Also outside the
# deploy dir. Honored by src/lib/uploads.ts.
BIFROST_UPLOAD_DIR=/var/lib/bifrost-portal/uploads
# Bind address + port for the Node standalone server. Loopback only — nginx
# is the only thing that should reach it. 4321 is the dev port; 4322 keeps
# us clear of it. Verify nothing else on the box uses 4322 (see DEPLOY.md).
HOST=127.0.0.1
PORT=4322
NODE_ENV=production

1
.gitignore vendored
View file

@ -3,6 +3,7 @@ node_modules/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.production.example
.astro/ .astro/
*.db *.db
*.db-shm *.db-shm

203
DEPLOY.md Normal file
View file

@ -0,0 +1,203 @@
# Deploying the Bifrost portal — `bifrost-portal.fenja.ai`
This app runs as a **Node standalone SSR server** (Astro `@astrojs/node`) behind
the **existing nginx** on the Fenja VPS, coexisting with the other Fenja site.
nginx terminates TLS and reverse-proxies the `bifrost-portal.fenja.ai` hostname
to the app on `127.0.0.1:4322`. Data is a single SQLite file plus an uploads dir.
You run every step here. Nothing in this repo touches the live server on its own.
| Thing | Value |
|---|---|
| Hostname | `bifrost-portal.fenja.ai` |
| App bind | `127.0.0.1:4322` (loopback only) |
| Code | `/opt/bifrost-portal` (git checkout, built in place) |
| Persistent data | `/var/lib/bifrost-portal/` (`bifrost.db`, `uploads/`, `backups/`) |
| Service user | `bifrost` (unprivileged) |
| systemd unit | `bifrost-portal.service` |
| Env file | `/etc/bifrost-portal.env` (chmod 600) |
Artifacts referenced below all live in this repo: `deploy/bifrost-portal.service`,
`deploy/nginx/bifrost-portal.fenja.ai.conf`, `.env.production.example`,
`scripts/deploy.sh`, `scripts/backup.sh`.
---
## 0. Pre-flight — confirm the port is free
Another Fenja app already runs on this box. Make sure nothing holds `4322`:
```bash
sudo ss -ltnp | grep ':4322' || echo "4322 is free"
```
If it's taken, pick another loopback port and change it in **both** `/etc/bifrost-portal.env`
(`PORT=`) and `deploy/nginx/bifrost-portal.fenja.ai.conf` (`proxy_pass`).
## 1. DNS
Add an A/AAAA record `bifrost-portal` → the VPS IP, same target as the existing
Fenja site. Confirm before requesting a cert:
```bash
dig +short bifrost-portal.fenja.ai
```
## 2. One-time server provisioning
Run as a sudo-capable user. Assumes Node 22, pnpm, nginx, sqlite3, certbot, git
are already present (the existing Fenja app implies most are).
```bash
# Service user (no login shell, no home spam)
sudo useradd --system --create-home --home-dir /var/lib/bifrost-portal \
--shell /usr/sbin/nologin bifrost
# Persistent data dirs
sudo install -d -o bifrost -g bifrost /var/lib/bifrost-portal
sudo install -d -o bifrost -g bifrost /var/lib/bifrost-portal/uploads
sudo install -d -o bifrost -g bifrost /var/lib/bifrost-portal/backups
# Code dir
sudo install -d -o bifrost -g bifrost /opt/bifrost-portal
```
Clone the repo into the code dir (uses the same `git.fenja.ai` remote this repo
already points at — make sure the `bifrost` user, or the user running deploys,
has a deploy key that can read it):
```bash
sudo -u bifrost git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \
/opt/bifrost-portal
```
## 3. Environment file
```bash
sudo cp /opt/bifrost-portal/.env.production.example /etc/bifrost-portal.env
sudo chown bifrost:bifrost /etc/bifrost-portal.env
sudo chmod 600 /etc/bifrost-portal.env
# Generate the session secret and paste it in as BIFROST_SECRET:
openssl rand -hex 32
sudo nano /etc/bifrost-portal.env
```
Make sure `BIFROST_DB_PATH`, `BIFROST_UPLOAD_DIR`, `HOST`, `PORT`, `NODE_ENV`
match the table above. `BIFROST_SECRET` is what signs sessions and invite
tokens — rotating it later logs everyone out and invalidates pending invites.
## 4. First build + database
```bash
cd /opt/bifrost-portal
sudo -u bifrost pnpm install --frozen-lockfile # rebuilds better-sqlite3 for this arch
sudo -u bifrost pnpm build
# Create + migrate the production DB at BIFROST_DB_PATH:
sudo -u bifrost --preserve-env=BIFROST_DB_PATH \
bash -c 'set -a; source /etc/bifrost-portal.env; set +a; node scripts/migrate.js'
# Seed the real pilot data (one time only — skip on later deploys):
sudo -u bifrost \
bash -c 'set -a; source /etc/bifrost-portal.env; set +a; pnpm db:seed:production'
```
> `better-sqlite3` is native and must be built **on the server** for its CPU
> arch (ARM on a Hetzner CAX11). That's why we `pnpm install` here rather than
> copying `node_modules`. `scripts/deploy.sh` does this on every deploy.
## 5. systemd service
```bash
sudo cp /opt/bifrost-portal/deploy/bifrost-portal.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bifrost-portal
sudo systemctl status bifrost-portal # should be active (running)
curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding"
```
Allow the `bifrost` user to restart just this unit without a password (used by
`deploy.sh`):
```bash
echo 'bifrost ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \
| sudo tee /etc/sudoers.d/bifrost-portal
sudo chmod 440 /etc/sudoers.d/bifrost-portal
```
## 6. nginx + TLS
```bash
sudo cp /opt/bifrost-portal/deploy/nginx/bifrost-portal.fenja.ai.conf \
/etc/nginx/sites-available/
sudo ln -s /etc/nginx/sites-available/bifrost-portal.fenja.ai.conf \
/etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx # :80 block live for ACME
```
Issue the certificate (certbot edits the file to wire up the cert paths):
```bash
sudo certbot --nginx -d bifrost-portal.fenja.ai
sudo nginx -t && sudo systemctl reload nginx
```
Verify, then optionally enable HSTS (uncomment the `Strict-Transport-Security`
line in the nginx conf and reload) once you're happy HTTPS is solid:
```bash
curl -fsSI https://bifrost-portal.fenja.ai/login | head -n1
```
## 7. Nightly backups
```bash
sudo -u bifrost crontab -e
# add:
15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /var/log/bifrost-backup.log 2>&1
```
`backup.sh` writes 30-day-retained `.backup` snapshots to
`/var/lib/bifrost-portal/backups`. Per SPEC §7.3, add a second cron line to sync
that dir to the Hetzner Storage Box (rclone/rsync) for offsite copies.
---
## Ongoing deploys
After pushing to `master` on `git.fenja.ai`:
```bash
sudo -u bifrost bash -c 'cd /opt/bifrost-portal && ./scripts/deploy.sh'
```
It pulls, installs (rebuilding native deps), builds, migrates, and restarts.
The DB and uploads are untouched.
## Rollback
```bash
cd /opt/bifrost-portal
sudo -u bifrost git log --oneline -n 10 # find the good commit
sudo -u bifrost bash -c 'BRANCH=<good-sha> ./scripts/deploy.sh' # or: git reset --hard <sha> then deploy
```
Schema migrations are forward-only — a code rollback past a migration may need a
DB restore. To restore a backup (stop the app first):
```bash
sudo systemctl stop bifrost-portal
sudo -u bifrost bash -c 'gunzip -c /var/lib/bifrost-portal/backups/bifrost-YYYYMMDD-HHMMSS.db.gz > /var/lib/bifrost-portal/bifrost.db'
sudo systemctl start bifrost-portal
```
## Troubleshooting
- **502 from nginx** — app not running or wrong port. `systemctl status bifrost-portal`,
`journalctl -u bifrost-portal -n 50`, and re-check `PORT` matches `proxy_pass`.
- **App starts then exits** — usually a missing/invalid `/etc/bifrost-portal.env`
or unwritable `BIFROST_DB_PATH`. Check `journalctl -u bifrost-portal`.
- **`better-sqlite3` errors on boot** — native module built for the wrong arch.
Re-run `pnpm install --frozen-lockfile` on the server and rebuild.
- **Migrations hit the wrong DB** — confirm the env file is sourced; `migrate.js`
honors `BIFROST_DB_PATH` (verified) and falls back to the repo-local dev db otherwise.

View file

@ -0,0 +1,37 @@
# systemd unit for the Bifrost portal (bifrost-portal.fenja.ai)
# Install to /etc/systemd/system/bifrost-portal.service
# Then: sudo systemctl daemon-reload && sudo systemctl enable --now bifrost-portal
#
# Assumes:
# - code checkout at /opt/bifrost-portal (built in place: dist/server/entry.mjs)
# - environment file at /etc/bifrost-portal.env (chmod 600, see .env.production.example)
# - a dedicated unprivileged service user `bifrost`
# - persistent data under /var/lib/bifrost-portal (db + uploads)
[Unit]
Description=Bifrost portal (Astro SSR, Node standalone)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=bifrost
Group=bifrost
WorkingDirectory=/opt/bifrost-portal
EnvironmentFile=/etc/bifrost-portal.env
ExecStart=/usr/bin/node /opt/bifrost-portal/dist/server/entry.mjs
Restart=on-failure
RestartSec=3
# Hardening — the service only needs to read its code and write its data dir.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bifrost-portal
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,66 @@
# nginx site for bifrost-portal.fenja.ai
# Reverse-proxies to the Bifrost portal Node server on 127.0.0.1:4322.
# Coexists with other Fenja sites on this box — it only claims this hostname.
#
# Install:
# sudo cp deploy/nginx/bifrost-portal.fenja.ai.conf /etc/nginx/sites-available/
# sudo ln -s /etc/nginx/sites-available/bifrost-portal.fenja.ai.conf /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
#
# TLS: obtain the cert first (see DEPLOY.md). Either run
# sudo certbot --nginx -d bifrost-portal.fenja.ai
# (certbot edits this file in place), OR issue with --webroot and keep the
# 443 block below as-is. The :80 block must exist before certbot runs.
# HTTP — ACME challenge + redirect everything else to HTTPS.
server {
listen 80;
listen [::]:80;
server_name bifrost-portal.fenja.ai;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS — terminates TLS, proxies to the Node app.
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name bifrost-portal.fenja.ai;
ssl_certificate /etc/letsencrypt/live/bifrost-portal.fenja.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bifrost-portal.fenja.ai/privkey.pem;
# Modern TLS defaults (Mozilla "intermediate"). If certbot manages this
# file it may append its own ssl_* includes — harmless duplicates aside.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
# Event photo uploads can be a few MB; keep headroom above the app's limit.
client_max_body_size 12m;
# Security headers. HSTS only after you've confirmed HTTPS works end-to-end.
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://127.0.0.1:4322;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Upgrade headers in case any route uses them; harmless otherwise.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
}

32
scripts/backup.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
#
# Nightly SQLite backup for the Bifrost portal. Uses the online .backup API
# (safe while the app is running — consistent, no locking issues with WAL).
# Keeps 30 days of compressed snapshots.
#
# Install as a cron job (as the `bifrost` user):
# crontab -e
# 15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /var/log/bifrost-backup.log 2>&1
#
# Per SPEC §7.3 the offsite target is a Hetzner Storage Box; sync BACKUP_DIR
# there separately (e.g. rclone/rsync in a second cron line).
set -euo pipefail
DB_PATH="${BIFROST_DB_PATH:-/var/lib/bifrost-portal/bifrost.db}"
BACKUP_DIR="${BACKUP_DIR:-/var/lib/bifrost-portal/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
mkdir -p "$BACKUP_DIR"
stamp="$(date -u +%Y%m%d-%H%M%S)"
out="$BACKUP_DIR/bifrost-$stamp.db"
echo "==> Backing up $DB_PATH -> $out"
sqlite3 "$DB_PATH" ".backup '$out'"
gzip -f "$out"
echo "==> Pruning backups older than $RETENTION_DAYS days"
find "$BACKUP_DIR" -name 'bifrost-*.db.gz' -type f -mtime "+$RETENTION_DAYS" -delete
echo "==> Backup complete: ${out}.gz"

48
scripts/deploy.sh Executable file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Server-side deploy for the Bifrost portal. Run ON THE VPS, as the `bifrost`
# service user, from inside the checkout (/opt/bifrost-portal).
#
# cd /opt/bifrost-portal && ./scripts/deploy.sh
#
# Pulls latest, installs deps (rebuilding the native better-sqlite3 for this
# box's arch), builds, migrates, and restarts the service. Idempotent and
# safe to re-run. Does NOT touch the database file or uploads — those live in
# /var/lib/bifrost-portal and persist across deploys.
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/bifrost-portal}"
SERVICE="${SERVICE:-bifrost-portal}"
BRANCH="${BRANCH:-master}"
ENV_FILE="${ENV_FILE:-/etc/bifrost-portal.env}"
cd "$APP_DIR"
echo "==> Loading $ENV_FILE for migrate (BIFROST_DB_PATH)"
set -a; # shellcheck disable=SC1090
source "$ENV_FILE"; set +a
echo "==> Fetching origin/$BRANCH"
git fetch --prune origin
git checkout "$BRANCH"
git reset --hard "origin/$BRANCH"
echo "==> Installing dependencies (frozen lockfile)"
# pnpm rebuilds better-sqlite3 for this machine's arch via onlyBuiltDependencies.
pnpm install --frozen-lockfile
echo "==> Building"
pnpm build
echo "==> Applying database migrations -> $BIFROST_DB_PATH"
node scripts/migrate.js
echo "==> Restarting $SERVICE"
sudo systemctl restart "$SERVICE"
echo "==> Waiting for health"
sleep 2
sudo systemctl --no-pager --lines=0 status "$SERVICE"
echo "==> Deploy complete: $(git rev-parse --short HEAD)"