From 819f8fa91cf3276f840cc2b45f4504d0775f515d Mon Sep 17 00:00:00 2001 From: Arlind Date: Wed, 17 Jun 2026 12:54:35 +0200 Subject: [PATCH] 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) --- .env.production.example | 24 +++ .gitignore | 1 + DEPLOY.md | 203 ++++++++++++++++++++++ deploy/bifrost-portal.service | 37 ++++ deploy/nginx/bifrost-portal.fenja.ai.conf | 66 +++++++ scripts/backup.sh | 32 ++++ scripts/deploy.sh | 48 +++++ 7 files changed, 411 insertions(+) create mode 100644 .env.production.example create mode 100644 DEPLOY.md create mode 100644 deploy/bifrost-portal.service create mode 100644 deploy/nginx/bifrost-portal.fenja.ai.conf create mode 100755 scripts/backup.sh create mode 100755 scripts/deploy.sh diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..9c22f30 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore index 7d07d48..a2d986c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ .env .env.* !.env.example +!.env.production.example .astro/ *.db *.db-shm diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..6db393b --- /dev/null +++ b/DEPLOY.md @@ -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= ./scripts/deploy.sh' # or: git reset --hard 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. diff --git a/deploy/bifrost-portal.service b/deploy/bifrost-portal.service new file mode 100644 index 0000000..8459a6e --- /dev/null +++ b/deploy/bifrost-portal.service @@ -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 diff --git a/deploy/nginx/bifrost-portal.fenja.ai.conf b/deploy/nginx/bifrost-portal.fenja.ai.conf new file mode 100644 index 0000000..9d97ef4 --- /dev/null +++ b/deploy/nginx/bifrost-portal.fenja.ai.conf @@ -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; + } +} diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..243392f --- /dev/null +++ b/scripts/backup.sh @@ -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" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..3f7dc78 --- /dev/null +++ b/scripts/deploy.sh @@ -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)"