Pin Node 24 via .node-version/.nvmrc and pnpm 9.15.0 via package.json#packageManager. Regenerate lockfile as pnpm-lock.yaml. Rewrite README setup + scripts table around pnpm, and add a production deployment guide covering systemd, nginx, upgrades, rollback, and backups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.4 KiB
Deployment
Guide for running buildfor_life_ops on a Linux server behind a reverse proxy. The build output of @sveltejs/adapter-node is a plain Node HTTP server — nothing exotic.
Stack assumptions:
- Linux host (Debian/Ubuntu preferred; works on anything with glibc ≥ 2.31)
- fnm for Node version management — pinned to
24via.node-version - pnpm
9.15.0via Corepack — pinned inpackage.json#packageManager - PostgreSQL 16+ reachable from the app host (local socket or remote)
- A reverse proxy terminating TLS (nginx / Caddy / Traefik — examples below use nginx)
- systemd to supervise the Node process
1. Prepare the host
# As root, one-time
apt update
apt install -y build-essential curl git postgresql-client
# Dedicated service user
adduser --system --group --home /opt/buildfor_life_ops --shell /bin/bash buildfor_life_ops
2. Install fnm and pin Node
# Run as the service user
sudo -iu buildfor_life_ops
curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell
# Activate in current shell and on login
cat >> ~/.bashrc <<'EOF'
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env --use-on-cd --shell bash)"
EOF
source ~/.bashrc
# Install the pinned major — fnm resolves .node-version once the repo is cloned
fnm install 24
fnm default 24
3. Enable pnpm via Corepack
Corepack ships with Node, so nothing extra to install:
corepack enable
corepack prepare pnpm@9.15.0 --activate
pnpm --version # → 9.15.0
4. Clone the repo and install dependencies
cd /opt/buildfor_life_ops
git clone git@gitssh.b4l.co.th:B4L/buildfor_life_ops.git app
cd app
# fnm picks up .node-version automatically on cd; verify:
node --version # → v24.x.x
# Reproducible install — fails if lockfile drifts
pnpm install --frozen-lockfile
5. Configure .env
cp .env.example .env # if present — otherwise copy from a trusted source
$EDITOR .env
Required keys (the app refuses to boot without them — see src/lib/server/env.ts):
| Key | Notes |
|---|---|
DATABASE_URL |
postgres://user:pass@host:5432/buildfor_life_ops |
SESSION_SECRET |
≥ 32 hex chars — openssl rand -hex 32 |
STORAGE_SIGNING_SECRET |
≥ 32 hex chars, independent of SESSION_SECRET |
PUBLIC_BASE_URL |
External URL, e.g. https://ops.b4l.co.th |
STORAGE_BACKEND |
local or s3 |
STORAGE_LOCAL_ROOT |
Absolute path to blob root, e.g. /var/lib/buildfor_life_ops/storage |
Optional keys:
- SMTP:
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS,SMTP_FROM,SMTP_SECURE. Email is silently disabled when any of HOST/PORT/FROM is unset. - Matrix:
MATRIX_HOMESERVER,MATRIX_ACCESS_TOKEN. Matrix delivery is disabled when either is unset. The per-company room comes fromcompanies.settings.matrix_room_id. - OIDC:
OIDC_ENABLED=true+ the fourOIDC_*values when wiring SSO. - S3:
S3_BUCKET,S3_REGION, optionalS3_ENDPOINTfor MinIO/compatibles, plus credentials.
Lock it down:
chmod 600 .env
chown buildfor_life_ops:buildfor_life_ops .env
6. Create the database
sudo -iu postgres psql <<'SQL'
CREATE USER buildfor_life_ops WITH PASSWORD '<strong-password>';
CREATE DATABASE buildfor_life_ops OWNER buildfor_life_ops;
SQL
7. Run migrations
pnpm run db:migrate
Re-run after every deploy — the migrator is idempotent and skips applied migrations.
8. Bootstrap the first admin
pnpm run create-user -- \
--email admin@b4l.co.th \
--password '<strong-password>' \
--name 'Admin' \
--company 'B4L' \
--role admin
9. Build for production
pnpm run build
Output: build/ — a standalone Node server bundle. The node_modules it needs at runtime are the production deps:
pnpm install --prod --frozen-lockfile
(Do this on the deploy host, not a cross-compiled one, so native modules like @node-rs/argon2 and sharp pick the right binaries.)
10. systemd unit
/etc/systemd/system/buildfor_life_ops.service:
[Unit]
Description=buildfor_life_ops (SvelteKit node adapter)
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=buildfor_life_ops
Group=buildfor_life_ops
WorkingDirectory=/opt/buildfor_life_ops/app
EnvironmentFile=/opt/buildfor_life_ops/app/.env
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3000
Environment=BODY_SIZE_LIMIT=10M
# fnm-installed Node — path resolved via the service user's shim dir.
# Pin the exact version here so a stray `fnm default` does not change the runtime.
ExecStart=/opt/buildfor_life_ops/.local/state/fnm_multishells/current/bin/node build/index.js
Restart=on-failure
RestartSec=5
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/opt/buildfor_life_ops/app/storage /var/lib/buildfor_life_ops
ProtectHome=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
If fnm multishell paths are awkward (they rotate per shell), use the canonical alias path instead:
ExecStart=/opt/buildfor_life_ops/.local/share/fnm/aliases/default/bin/node build/index.js
Enable and start:
systemctl daemon-reload
systemctl enable --now buildfor_life_ops
systemctl status buildfor_life_ops
journalctl -u buildfor_life_ops -f
11. Reverse proxy (nginx)
/etc/nginx/sites-available/buildfor_life_ops:
server {
listen 80;
server_name ops.b4l.co.th;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name ops.b4l.co.th;
ssl_certificate /etc/letsencrypt/live/ops.b4l.co.th/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ops.b4l.co.th/privkey.pem;
# Uploads: documents + CSV import. Keep in sync with BODY_SIZE_LIMIT in systemd unit.
client_max_body_size 10m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
}
SvelteKit trusts X-Forwarded-* when ORIGIN or PROTOCOL_HEADER/HOST_HEADER env vars are set. Recommended:
# Add to the systemd unit [Service] block
Environment=ORIGIN=https://ops.b4l.co.th
Environment=PROTOCOL_HEADER=x-forwarded-proto
Environment=HOST_HEADER=x-forwarded-host
12. Upgrades
cd /opt/buildfor_life_ops/app
git fetch --tags
git checkout <tag-or-sha>
# Node version may have changed — fnm re-reads .node-version on cd, but force it:
fnm use --install-if-missing
pnpm install --frozen-lockfile
pnpm run db:migrate
pnpm run build
pnpm install --prod --frozen-lockfile
systemctl restart buildfor_life_ops
journalctl -u buildfor_life_ops -n 100 --no-pager
A migration that cannot be rolled back forward-only (rare — see drizzle/README.md) needs a maintenance window and a DB snapshot first.
13. Rollback
cd /opt/buildfor_life_ops/app
git checkout <previous-tag>
pnpm install --frozen-lockfile
pnpm run build
pnpm install --prod --frozen-lockfile
systemctl restart buildfor_life_ops
Schema rollback is manual. Drizzle does not ship down-migrations. If the previous code cannot read the current schema, restore the DB from the pre-upgrade snapshot before checking out the old tag.
14. Backups
Two things matter:
- Postgres —
pg_dump -Fc buildfor_life_ops > ops-$(date +%F).dump, daily, offsite. Retain ≥ 14 days. - Blob storage — when
STORAGE_BACKEND=local,STORAGE_LOCAL_ROOTholds all uploaded documents. Snapshot it with the filesystem (ZFS/btrfs) or rsync it alongside the DB dump. WhenSTORAGE_BACKEND=s3, rely on bucket versioning + cross-region replication.
The DB is the source of truth for documents.storage_key → blob mapping. A blob directory without its matching DB rows is unusable.
15. Health check
There is no dedicated /healthz endpoint yet. For now, probe GET /login — it returns 200 without a session:
curl -fsS -o /dev/null -w '%{http_code}\n' https://ops.b4l.co.th/login
16. Observability
- Logs:
journalctl -u buildfor_life_ops. All app logs go to stdout/stderr. - Metrics: not wired yet. If/when added, expose on a separate localhost port so nginx does not proxy them publicly.
17. Common pitfalls
Environment validation failedon boot —.envis missing, theEnvironmentFile=path is wrong, or one of themin(32)secrets is too short.sharpfails withcould not load the "sharp" module— cross-compiled install. Re-runpnpm install --prod --frozen-lockfileon the deploy host.@node-rs/argon2prebuilt binary missing — same cause, same fix. If the host is exotic (musl, ARM), setnpm_config_build_from_source=truebefore install.- Cookies not setting —
PUBLIC_BASE_URLmust match the user-facing URL exactly (scheme + host). In production this means HTTPS; the session cookie isSecure. - 413 on document upload — bump both
client_max_body_sizein nginx andBODY_SIZE_LIMITin the systemd unit; they must agree. - fnm picks the wrong Node after a server reboot — ensure
fnm default 24was run for the service user, and the systemdExecStart=points at the aliases path, not a multishell path.