project-bifrost-platform/DEPLOY.md
Arlind 819f8fa91c 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>
2026-06-17 12:54:35 +02:00

7 KiB

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:

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:

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

# 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):

sudo -u bifrost git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \
     /opt/bifrost-portal

3. Environment file

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

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

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):

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

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):

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:

curl -fsSI https://bifrost-portal.fenja.ai/login | head -n1

7. Nightly backups

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:

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

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):

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.