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