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>
203 lines
7 KiB
Markdown
203 lines
7 KiB
Markdown
# 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.
|