chore(deploy): align deploy artifacts to the target server's conventions

Recon of the live box (Ubuntu 24.04 x86_64, nginx 1.24, certbot 2.9)
showed established conventions from the existing fenja / bifrost-customer
services. Match them so the portal looks like a first-class citizen:

- service runs as the existing `fenja` user, journald logging + full
  hardening block (ProtectKernelModules, LockPersonality), ExecStart on
  /usr/bin/node (box upgraded globally to Node 22)
- code in /opt/bifrost-portal, in-dir .env (EnvironmentFile), data under
  the shared /opt/fenja/data/bifrost-portal (ReadWritePaths)
- nginx: 1.24 `listen ... ssl http2` syntax, certbot options-ssl-nginx +
  dhparam includes, server_tokens off, sites-available/bifrost-portal (no
  .conf) symlinked; 12m body size for photo uploads; port 4322 (free)
- deploy.sh / backup.sh point at the new paths
- DEPLOY.md rewritten as a server-specific runbook incl. the global Node 22
  upgrade + retest of the existing apps, and pnpm via corepack

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind 2026-06-17 13:16:57 +02:00
parent 819f8fa91c
commit 6f656b7121
6 changed files with 177 additions and 158 deletions

View file

@ -1,23 +1,25 @@
# Production environment for bifrost-portal.fenja.ai # Production environment for bifrost-portal.fenja.ai
# Copy to the EnvironmentFile path referenced by the systemd unit # Copy to /opt/bifrost-portal/.env on the server and fill in real values.
# (default: /etc/bifrost-portal.env) and fill in real values. Keep it # Keep it chmod 600, owned by fenja:fenja. NEVER commit the real file.
# chmod 600, owned by the service user. NEVER commit the real file. # (Matches the existing apps' convention of an in-dir .env loaded via
# EnvironmentFile in the systemd unit.)
# Long random string used to sign sessions and invite tokens. # Long random string used to sign sessions and invite tokens.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
BIFROST_SECRET=change-me-openssl-rand-hex-32 BIFROST_SECRET=change-me-openssl-rand-hex-32
# Absolute path to the SQLite database. Lives OUTSIDE the deploy dir so # Absolute path to the SQLite database. Lives under the shared /opt/fenja/data
# tree (the only path the service may write to) and OUTSIDE the deploy dir, so
# redeploys never touch it. Honored by src/lib/db.ts and scripts/migrate.js. # redeploys never touch it. Honored by src/lib/db.ts and scripts/migrate.js.
BIFROST_DB_PATH=/var/lib/bifrost-portal/bifrost.db BIFROST_DB_PATH=/opt/fenja/data/bifrost-portal/bifrost.db
# Absolute path to the runtime uploads dir (event photos). Also outside the # Absolute path to the runtime uploads dir (event photos). Honored by
# deploy dir. Honored by src/lib/uploads.ts. # src/lib/uploads.ts.
BIFROST_UPLOAD_DIR=/var/lib/bifrost-portal/uploads BIFROST_UPLOAD_DIR=/opt/fenja/data/bifrost-portal/uploads
# Bind address + port for the Node standalone server. Loopback only — nginx # 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 # is the only thing that should reach it. 4322 is free on this box (3000/3001
# us clear of it. Verify nothing else on the box uses 4322 (see DEPLOY.md). # are the existing fenja / bifrost-customer apps).
HOST=127.0.0.1 HOST=127.0.0.1
PORT=4322 PORT=4322

194
DEPLOY.md
View file

@ -1,165 +1,171 @@
# Deploying the Bifrost portal — `bifrost-portal.fenja.ai` # Deploying the Bifrost portal — `bifrost-portal.fenja.ai`
This app runs as a **Node standalone SSR server** (Astro `@astrojs/node`) behind 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. the **existing nginx** on the Fenja VPS, alongside the `fenja` and
nginx terminates TLS and reverse-proxies the `bifrost-portal.fenja.ai` hostname `bifrost-customer` apps. It follows the conventions already established on that
to the app on `127.0.0.1:4322`. Data is a single SQLite file plus an uploads dir. box (verified by inspection): `fenja` service user, systemd + journald, code in
`/opt/<app>`, data under `/opt/fenja/data`, in-dir `.env`, certbot TLS.
You run every step here. Nothing in this repo touches the live server on its own. You run every step here. Nothing in this repo touches the live server on its own.
| Thing | Value | | Thing | Value |
|---|---| |---|---|
| Hostname | `bifrost-portal.fenja.ai` | | Hostname | `bifrost-portal.fenja.ai` |
| App bind | `127.0.0.1:4322` (loopback only) | | App bind | `127.0.0.1:4322` (loopback only; 3000/3001 are the existing apps) |
| Code | `/opt/bifrost-portal` (git checkout, built in place) | | Code | `/opt/bifrost-portal` (git checkout, built in place) |
| Persistent data | `/var/lib/bifrost-portal/` (`bifrost.db`, `uploads/`, `backups/`) | | Persistent data | `/opt/fenja/data/bifrost-portal/` (`bifrost.db`, `uploads/`, `backups/`) |
| Service user | `bifrost` (unprivileged) | | Service user | `fenja` (existing) |
| systemd unit | `bifrost-portal.service` | | systemd unit | `bifrost-portal.service` |
| Env file | `/etc/bifrost-portal.env` (chmod 600) | | Env file | `/opt/bifrost-portal/.env` (chmod 600) |
| Server | Ubuntu 24.04, x86_64, nginx 1.24.0, certbot 2.9.0 |
Artifacts referenced below all live in this repo: `deploy/bifrost-portal.service`, Repo artifacts referenced below: `deploy/bifrost-portal.service`,
`deploy/nginx/bifrost-portal.fenja.ai.conf`, `.env.production.example`, `deploy/nginx/bifrost-portal.fenja.ai.conf`, `.env.production.example`,
`scripts/deploy.sh`, `scripts/backup.sh`. `scripts/deploy.sh`, `scripts/backup.sh`.
--- ---
## 0. Pre-flight — confirm the port is free ## 0. Toolchain — upgrade Node to 22, enable pnpm
Another Fenja app already runs on this box. Make sure nothing holds `4322`: The box currently has Node **v20** at `/usr/bin/node`, shared by the running
`fenja` and `bifrost-customer` services. We're upgrading it **globally** to 22.
> ⚠️ This moves those two live apps onto Node 22 as well. Restart and smoke-test
> them right after (last line of this step). Have a moment of downtime tolerance.
```bash ```bash
sudo ss -ltnp | grep ':4322' || echo "4322 is free" # Upgrade the NodeSource apt repo to the 22.x channel and install:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # expect v22.x
# pnpm via corepack (bundled with Node 22) — no separate install:
sudo corepack enable
corepack enable pnpm
pnpm -v # expect a pnpm 9.x/10.x shim
# Move the existing apps onto Node 22 and confirm they still work:
sudo systemctl restart fenja bifrost-customer
sudo systemctl status fenja bifrost-customer --no-pager
curl -fsS https://project-bifrost.fenja.ai/ >/dev/null && echo "existing site OK"
``` ```
If it's taken, pick another loopback port and change it in **both** `/etc/bifrost-portal.env` If either existing app misbehaves on 22, that's the risk we accepted — roll the
(`PORT=`) and `deploy/nginx/bifrost-portal.fenja.ai.conf` (`proxy_pass`). NodeSource repo back to `setup_20.x` and reinstall to recover them.
## 1. DNS ## 1. DNS
Add an A/AAAA record `bifrost-portal` → the VPS IP, same target as the existing Add an A/AAAA record `bifrost-portal` → the same VPS IP as `project-bifrost`.
Fenja site. Confirm before requesting a cert: Confirm before requesting a cert:
```bash ```bash
dig +short bifrost-portal.fenja.ai dig +short bifrost-portal.fenja.ai
``` ```
## 2. One-time server provisioning ## 2. Provision dirs (reuse the `fenja` user)
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 ```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 # Code dir
sudo install -d -o bifrost -g bifrost /opt/bifrost-portal sudo install -d -o fenja -g fenja /opt/bifrost-portal
# Persistent data under the shared, service-writable tree
sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal
sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal/uploads
sudo install -d -o fenja -g fenja /opt/fenja/data/bifrost-portal/backups
``` ```
Clone the repo into the code dir (uses the same `git.fenja.ai` remote this repo ## 3. Clone the repo
already points at — make sure the `bifrost` user, or the user running deploys,
has a deploy key that can read it): The `fenja` user needs read access to `git.fenja.ai` (a deploy key on its
account, or your forwarded agent for the first clone):
```bash ```bash
sudo -u bifrost git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \ sudo -u fenja git clone ssh://git@git.fenja.ai:2222/joh/project-bifrost-platform.git \
/opt/bifrost-portal /opt/bifrost-portal
``` ```
## 3. Environment file ## 4. Environment file
```bash ```bash
sudo cp /opt/bifrost-portal/.env.production.example /etc/bifrost-portal.env sudo -u fenja cp /opt/bifrost-portal/.env.production.example /opt/bifrost-portal/.env
sudo chown bifrost:bifrost /etc/bifrost-portal.env sudo chmod 600 /opt/bifrost-portal/.env
sudo chmod 600 /etc/bifrost-portal.env openssl rand -hex 32 # paste as BIFROST_SECRET
# Generate the session secret and paste it in as BIFROST_SECRET: sudo -u fenja nano /opt/bifrost-portal/.env
openssl rand -hex 32
sudo nano /etc/bifrost-portal.env
``` ```
Make sure `BIFROST_DB_PATH`, `BIFROST_UPLOAD_DIR`, `HOST`, `PORT`, `NODE_ENV` Confirm `BIFROST_DB_PATH`, `BIFROST_UPLOAD_DIR`, `HOST`, `PORT`, `NODE_ENV`
match the table above. `BIFROST_SECRET` is what signs sessions and invite match the table above. `BIFROST_SECRET` signs sessions and invite tokens —
tokens — rotating it later logs everyone out and invalidates pending invites. rotating it later logs everyone out and invalidates pending invites.
## 4. First build + database ## 5. First build + database
```bash ```bash
cd /opt/bifrost-portal cd /opt/bifrost-portal
sudo -u bifrost pnpm install --frozen-lockfile # rebuilds better-sqlite3 for this arch sudo -u fenja pnpm install --frozen-lockfile # builds native better-sqlite3 for this box
sudo -u bifrost pnpm build sudo -u fenja pnpm build
# Create + migrate the production DB at BIFROST_DB_PATH: # Create + migrate the production DB at BIFROST_DB_PATH:
sudo -u bifrost --preserve-env=BIFROST_DB_PATH \ sudo -u fenja bash -c 'set -a; source /opt/bifrost-portal/.env; set +a; node scripts/migrate.js'
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): # Seed the real pilot data (ONE TIME only — skip on later deploys):
sudo -u bifrost \ sudo -u fenja bash -c 'set -a; source /opt/bifrost-portal/.env; set +a; pnpm db:seed:production'
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 > `better-sqlite3` is native; `pnpm install` builds/fetches it for this machine.
> arch (ARM on a Hetzner CAX11). That's why we `pnpm install` here rather than > `scripts/deploy.sh` re-runs install on every deploy, so an arch/Node change is
> copying `node_modules`. `scripts/deploy.sh` does this on every deploy. > always reconciled.
## 5. systemd service ## 6. systemd service
```bash ```bash
sudo cp /opt/bifrost-portal/deploy/bifrost-portal.service /etc/systemd/system/ sudo cp /opt/bifrost-portal/deploy/bifrost-portal.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now bifrost-portal sudo systemctl enable --now bifrost-portal
sudo systemctl status bifrost-portal # should be active (running) sudo systemctl status bifrost-portal --no-pager
curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding" curl -fsS http://127.0.0.1:4322/login >/dev/null && echo "app responding on 4322"
``` ```
Allow the `bifrost` user to restart just this unit without a password (used by Let `fenja` restart just this unit without a password (used by `deploy.sh`):
`deploy.sh`):
```bash ```bash
echo 'bifrost ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \ echo 'fenja ALL=(root) NOPASSWD: /usr/bin/systemctl restart bifrost-portal, /usr/bin/systemctl status bifrost-portal' \
| sudo tee /etc/sudoers.d/bifrost-portal | sudo tee /etc/sudoers.d/bifrost-portal
sudo chmod 440 /etc/sudoers.d/bifrost-portal sudo chmod 440 /etc/sudoers.d/bifrost-portal
``` ```
## 6. nginx + TLS ## 7. nginx + TLS
```bash ```bash
sudo cp /opt/bifrost-portal/deploy/nginx/bifrost-portal.fenja.ai.conf \ sudo cp /opt/bifrost-portal/deploy/nginx/bifrost-portal.fenja.ai.conf \
/etc/nginx/sites-available/ /etc/nginx/sites-available/bifrost-portal
sudo ln -s /etc/nginx/sites-available/bifrost-portal.fenja.ai.conf \ sudo ln -s /etc/nginx/sites-available/bifrost-portal \
/etc/nginx/sites-enabled/ /etc/nginx/sites-enabled/bifrost-portal
sudo nginx -t && sudo systemctl reload nginx # :80 block live for ACME 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): Issue the certificate (certbot wires the cert paths into the file):
```bash ```bash
sudo certbot --nginx -d bifrost-portal.fenja.ai sudo certbot --nginx -d bifrost-portal.fenja.ai
sudo nginx -t && sudo systemctl reload nginx 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 curl -fsSI https://bifrost-portal.fenja.ai/login | head -n1
``` ```
## 7. Nightly backups > The site config already includes the `Strict-Transport-Security` (HSTS)
> header to match the existing site. If you want to verify HTTPS end-to-end
> first, comment that line, reload, confirm, then re-enable.
## 8. Nightly backups
```bash ```bash
sudo -u bifrost crontab -e sudo -u fenja crontab -e
# add: # add:
15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /var/log/bifrost-backup.log 2>&1 15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /opt/fenja/data/bifrost-portal/backup.log 2>&1
``` ```
`backup.sh` writes 30-day-retained `.backup` snapshots to `backup.sh` writes 30-day-retained online `.backup` snapshots to
`/var/lib/bifrost-portal/backups`. Per SPEC §7.3, add a second cron line to sync `/opt/fenja/data/bifrost-portal/backups`. Add a second cron line to sync that
that dir to the Hetzner Storage Box (rclone/rsync) for offsite copies. dir offsite (rclone/rsync) if you want off-box copies.
--- ---
@ -168,36 +174,38 @@ that dir to the Hetzner Storage Box (rclone/rsync) for offsite copies.
After pushing to `master` on `git.fenja.ai`: After pushing to `master` on `git.fenja.ai`:
```bash ```bash
sudo -u bifrost bash -c 'cd /opt/bifrost-portal && ./scripts/deploy.sh' sudo -u fenja bash -c 'cd /opt/bifrost-portal && ./scripts/deploy.sh'
``` ```
It pulls, installs (rebuilding native deps), builds, migrates, and restarts. Pulls, installs (rebuilding native deps), builds, migrates, restarts. The DB and
The DB and uploads are untouched. uploads in `/opt/fenja/data/bifrost-portal` are untouched.
## Rollback ## Rollback
```bash ```bash
cd /opt/bifrost-portal cd /opt/bifrost-portal
sudo -u bifrost git log --oneline -n 10 # find the good commit sudo -u fenja git log --oneline -n 10
sudo -u bifrost bash -c 'BRANCH=<good-sha> ./scripts/deploy.sh' # or: git reset --hard <sha> then deploy sudo -u fenja bash -c 'BRANCH=<good-sha> ./scripts/deploy.sh'
``` ```
Schema migrations are forward-only — a code rollback past a migration may need a Migrations are forward-only — a rollback past a migration may need a DB restore
DB restore. To restore a backup (stop the app first): (stop the app first):
```bash ```bash
sudo systemctl stop bifrost-portal 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 -u fenja bash -c 'gunzip -c /opt/fenja/data/bifrost-portal/backups/bifrost-YYYYMMDD-HHMMSS.db.gz > /opt/fenja/data/bifrost-portal/bifrost.db'
sudo systemctl start bifrost-portal sudo systemctl start bifrost-portal
``` ```
## Troubleshooting ## Troubleshooting
- **502 from nginx** — app not running or wrong port. `systemctl status bifrost-portal`, - **502 from nginx** — app down or wrong port. `systemctl status bifrost-portal`,
`journalctl -u bifrost-portal -n 50`, and re-check `PORT` matches `proxy_pass`. `journalctl -u bifrost-portal -n 50`, check `PORT` matches the `proxy_pass`.
- **App starts then exits** — usually a missing/invalid `/etc/bifrost-portal.env` - **App starts then exits** — bad/missing `/opt/bifrost-portal/.env` or an
or unwritable `BIFROST_DB_PATH`. Check `journalctl -u bifrost-portal`. unwritable `BIFROST_DB_PATH`. `journalctl -u bifrost-portal`.
- **`better-sqlite3` errors on boot** — native module built for the wrong arch. - **`better-sqlite3` errors on boot** — native module built for the wrong Node
Re-run `pnpm install --frozen-lockfile` on the server and rebuild. ABI. Re-run `pnpm install --frozen-lockfile` and restart.
- **Migrations hit the wrong DB** — confirm the env file is sourced; `migrate.js` - **Migrations hit the wrong DB** — make sure the `.env` is sourced; `migrate.js`
honors `BIFROST_DB_PATH` (verified) and falls back to the repo-local dev db otherwise. honors `BIFROST_DB_PATH` (verified) and otherwise falls back to a repo-local db.
- **Existing apps broke after the Node 22 upgrade** — roll NodeSource back to
`setup_20.x`, `apt-get install -y nodejs`, restart `fenja` + `bifrost-customer`.

View file

@ -1,37 +1,49 @@
# systemd unit for the Bifrost portal (bifrost-portal.fenja.ai) # ─────────────────────────────────────────────────────────────
# Install to /etc/systemd/system/bifrost-portal.service # Systemd unit for the Bifrost portal (bifrost-portal.fenja.ai).
# Then: sudo systemctl daemon-reload && sudo systemctl enable --now bifrost-portal # Mirrors the conventions of the existing fenja.service / bifrost-customer.service
# on this box: runs as the `fenja` user, logs to journald, writes only to
# /opt/fenja/data. Astro SSR standalone server (dist/server/entry.mjs).
# #
# Assumes: # Install to: /etc/systemd/system/bifrost-portal.service
# - 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) # sudo cp deploy/bifrost-portal.service /etc/systemd/system/bifrost-portal.service
# - a dedicated unprivileged service user `bifrost` # sudo systemctl daemon-reload
# - persistent data under /var/lib/bifrost-portal (db + uploads) # sudo systemctl enable --now bifrost-portal
# sudo systemctl status bifrost-portal
# sudo journalctl -u bifrost-portal -f
# ─────────────────────────────────────────────────────────────
[Unit] [Unit]
Description=Bifrost portal (Astro SSR, Node standalone) Description=Bifrost portal (Astro SSR)
After=network-online.target After=network.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=bifrost User=fenja
Group=bifrost Group=fenja
WorkingDirectory=/opt/bifrost-portal WorkingDirectory=/opt/bifrost-portal
EnvironmentFile=/etc/bifrost-portal.env EnvironmentFile=/opt/bifrost-portal/.env
ExecStart=/usr/bin/node /opt/bifrost-portal/dist/server/entry.mjs ExecStart=/usr/bin/node /opt/bifrost-portal/dist/server/entry.mjs
Restart=on-failure Restart=on-failure
RestartSec=3 RestartSec=5
# Hardening — the service only needs to read its code and write its data dir. # stdout / stderr → journald
StandardOutput=journal
StandardError=journal
SyslogIdentifier=bifrost-portal
# ─── Hardening (matches the other Fenja units) ───
NoNewPrivileges=true NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/var/lib/bifrost-portal
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true ProtectControlGroups=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
LockPersonality=true
# Only the shared data dir is writable (db, uploads, backups live here)
ReadWritePaths=/opt/fenja/data
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,54 +1,55 @@
# nginx site for bifrost-portal.fenja.ai # nginx site for bifrost-portal.fenja.ai
# Reverse-proxies to the Bifrost portal Node server on 127.0.0.1:4322. # 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. # Coexists with the existing project-bifrost.fenja.ai site; only claims this
# hostname. Matches that site's conventions (nginx 1.24, certbot TLS includes).
# #
# Install: # Install (no .conf extension, to match the existing sites-available layout):
# sudo cp deploy/nginx/bifrost-portal.fenja.ai.conf /etc/nginx/sites-available/ # sudo cp deploy/nginx/bifrost-portal.fenja.ai.conf /etc/nginx/sites-available/bifrost-portal
# sudo ln -s /etc/nginx/sites-available/bifrost-portal.fenja.ai.conf /etc/nginx/sites-enabled/ # sudo ln -s /etc/nginx/sites-available/bifrost-portal /etc/nginx/sites-enabled/bifrost-portal
# sudo nginx -t && sudo systemctl reload nginx # sudo nginx -t && sudo systemctl reload nginx
# #
# TLS: obtain the cert first (see DEPLOY.md). Either run # TLS: the :80 block must be live first so certbot's ACME challenge succeeds.
# sudo certbot --nginx -d bifrost-portal.fenja.ai # Then: sudo certbot --nginx -d bifrost-portal.fenja.ai (edits this file).
# (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 { server {
listen 80; listen 80;
listen [::]:80; listen [::]:80;
server_name bifrost-portal.fenja.ai; server_name bifrost-portal.fenja.ai;
# Let certbot's renewals reach .well-known/acme-challenge on port 80
location /.well-known/acme-challenge/ { location /.well-known/acme-challenge/ {
root /var/www/html; root /var/www/html;
} }
# Everything else goes to HTTPS
location / { location / {
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
} }
# HTTPS — terminates TLS, proxies to the Node app.
server { server {
listen 443 ssl; listen 443 ssl http2;
listen [::]:443 ssl; listen [::]:443 ssl http2;
http2 on;
server_name bifrost-portal.fenja.ai; server_name bifrost-portal.fenja.ai;
ssl_certificate /etc/letsencrypt/live/bifrost-portal.fenja.ai/fullchain.pem; ssl_certificate /etc/letsencrypt/live/bifrost-portal.fenja.ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bifrost-portal.fenja.ai/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/bifrost-portal.fenja.ai/privkey.pem;
# Modern TLS defaults (Mozilla "intermediate"). If certbot manages this include /etc/letsencrypt/options-ssl-nginx.conf;
# file it may append its own ssl_* includes — harmless duplicates aside. ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
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. # ─── Security headers ───
client_max_body_size 12m; # HSTS — confirm the cert + redirect loop is solid before relying on it.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Security headers. HSTS only after you've confirmed HTTPS works end-to-end.
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always; add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy strict-origin-when-cross-origin always; add_header Referrer-Policy strict-origin-when-cross-origin always;
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Event photo uploads can be a few MB (the existing site uses 32k — this
# site accepts image uploads, so it needs headroom).
client_max_body_size 12m;
# Don't leak nginx version
server_tokens off;
location / { location / {
proxy_pass http://127.0.0.1:4322; proxy_pass http://127.0.0.1:4322;
@ -57,10 +58,6 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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; proxy_read_timeout 60s;
} }
} }

View file

@ -4,17 +4,17 @@
# (safe while the app is running — consistent, no locking issues with WAL). # (safe while the app is running — consistent, no locking issues with WAL).
# Keeps 30 days of compressed snapshots. # Keeps 30 days of compressed snapshots.
# #
# Install as a cron job (as the `bifrost` user): # Install as a cron job (as the `fenja` user):
# crontab -e # crontab -e
# 15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /var/log/bifrost-backup.log 2>&1 # 15 3 * * * /opt/bifrost-portal/scripts/backup.sh >> /opt/fenja/data/bifrost-portal/backup.log 2>&1
# #
# Per SPEC §7.3 the offsite target is a Hetzner Storage Box; sync BACKUP_DIR # For offsite copies, sync BACKUP_DIR to remote storage separately
# there separately (e.g. rclone/rsync in a second cron line). # (e.g. rclone/rsync in a second cron line).
set -euo pipefail set -euo pipefail
DB_PATH="${BIFROST_DB_PATH:-/var/lib/bifrost-portal/bifrost.db}" DB_PATH="${BIFROST_DB_PATH:-/opt/fenja/data/bifrost-portal/bifrost.db}"
BACKUP_DIR="${BACKUP_DIR:-/var/lib/bifrost-portal/backups}" BACKUP_DIR="${BACKUP_DIR:-/opt/fenja/data/bifrost-portal/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-30}" RETENTION_DAYS="${RETENTION_DAYS:-30}"
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"

View file

@ -15,7 +15,7 @@ set -euo pipefail
APP_DIR="${APP_DIR:-/opt/bifrost-portal}" APP_DIR="${APP_DIR:-/opt/bifrost-portal}"
SERVICE="${SERVICE:-bifrost-portal}" SERVICE="${SERVICE:-bifrost-portal}"
BRANCH="${BRANCH:-master}" BRANCH="${BRANCH:-master}"
ENV_FILE="${ENV_FILE:-/etc/bifrost-portal.env}" ENV_FILE="${ENV_FILE:-/opt/bifrost-portal/.env}"
cd "$APP_DIR" cd "$APP_DIR"