Files
bmp-website-2026/docs/vps-deployment.md
syntaxbullet 7ff405d1a2 Add migration scripts and preload participation awards script
- Created migration index file to manage database migrations.
- Added script to preload participation awards and related blog posts into the CMS.
- Introduced new types and default data for application phases.
- Added award articles data structure for the upcoming awards.
2026-06-30 19:10:53 +02:00

5.1 KiB

VPS deployment

This project is a Node/Next/Payload application that currently uses:

  • SQLite via DATABASE_URL
  • local filesystem media in public/media
  • no Docker

Treat code and persistent state separately. Git or rsync can move the code, but the database and media directory must be copied and backed up explicitly.

Persistent paths

Use one stable directory on the VPS:

APP_ROOT=/var/www/bmp-website
APP_USER=deploy

Recommended layout:

/var/www/bmp-website/
  current/          # synced application code
  shared/
    .env            # production secrets
    content.db      # SQLite database
    media/          # Payload uploads
  backups/

Create it on the VPS:

sudo mkdir -p "$APP_ROOT"/{current,shared/media,backups}
sudo chown -R "$APP_USER":"$APP_USER" "$APP_ROOT"

Production environment

Create /var/www/bmp-website/shared/.env on the VPS:

DATABASE_URL=file:/var/www/bmp-website/shared/content.db
PAYLOAD_SECRET=replace-with-existing-secret
NEXT_PUBLIC_SERVER_URL=https://example.com
CRON_SECRET=replace-with-existing-secret
PREVIEW_SECRET=replace-with-existing-secret
PORT=3000
NODE_ENV=production

Keep PAYLOAD_SECRET, CRON_SECRET, and PREVIEW_SECRET stable once the site is live. Do not commit the production .env.

NEXT_PUBLIC_SERVER_URL is read during the Next build, so set it correctly before running npm run build.

If you already have the correct local .env, transfer it separately from the application code and lock down its permissions:

scp .env "$SSH_TARGET:$APP_ROOT/shared/.env"
ssh "$SSH_TARGET" "chmod 600 $APP_ROOT/shared/.env"

If you need to package secrets in an archive, make that archive secret-only and encrypt it before transfer:

tar -czf bmp-website-secrets.tar.gz .env
gpg -c bmp-website-secrets.tar.gz
scp bmp-website-secrets.tar.gz.gpg "$SSH_TARGET:$APP_ROOT/shared/"

Then decrypt it on the VPS, move .env into place, and remove the temporary archive files.

Initial transfer

Run these locally from the repository root. Replace the SSH target and domain values first.

SSH_TARGET=deploy@example.com
APP_ROOT=/var/www/bmp-website

Stop any local dev server before copying content.db.

Copy persistent state once:

rsync -az content.db "$SSH_TARGET:$APP_ROOT/shared/content.db"
rsync -az public/media/ "$SSH_TARGET:$APP_ROOT/shared/media/"

Copy application code without local state or build output:

rsync -az --delete \
  --exclude '.env' \
  --exclude '.next/' \
  --exclude 'node_modules/' \
  --exclude 'content.db' \
  --exclude 'public/media/' \
  ./ "$SSH_TARGET:$APP_ROOT/current/"

Link the shared media directory into the app:

ssh "$SSH_TARGET" "mkdir -p $APP_ROOT/current/public && rm -rf $APP_ROOT/current/public/media && ln -s $APP_ROOT/shared/media $APP_ROOT/current/public/media"
ssh "$SSH_TARGET" "ln -sfn $APP_ROOT/shared/.env $APP_ROOT/current/.env"

Do not use --delete when syncing media unless the source is intentionally the source of truth and you have a current backup.

Build on the VPS

Install Node.js 22 or another version accepted by package.json, then run:

cd /var/www/bmp-website/current
npm ci
npm run build

systemd service

Create /etc/systemd/system/bmp-website.service:

[Unit]
Description=BMP website
After=network.target

[Service]
Type=simple
User=deploy
Group=deploy
WorkingDirectory=/var/www/bmp-website/current
EnvironmentFile=/var/www/bmp-website/shared/.env
ExecStart=/usr/bin/env npm run start
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now bmp-website
sudo systemctl status bmp-website --no-pager

nginx reverse proxy

Example server block:

server {
    listen 80;
    server_name example.com www.example.com;

    client_max_body_size 25m;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

After enabling the site, issue TLS certificates with your normal ACME client such as Certbot.

Repeat deploys

Before every deploy, back up the live SQLite database on the VPS:

APP_ROOT=/var/www/bmp-website
sqlite3 "$APP_ROOT/shared/content.db" ".backup '$APP_ROOT/backups/content-$(date +%Y%m%d-%H%M%S).db'"

Then sync code, rebuild, and restart:

rsync -az --delete \
  --exclude '.env' \
  --exclude '.next/' \
  --exclude 'node_modules/' \
  --exclude 'content.db' \
  --exclude 'public/media/' \
  ./ "$SSH_TARGET:$APP_ROOT/current/"

ssh "$SSH_TARGET" "cd $APP_ROOT/current && npm ci && npm run build && sudo systemctl restart bmp-website"

Verify:

ssh "$SSH_TARGET" "systemctl status bmp-website --no-pager"
curl -I https://example.com
curl -I https://example.com/admin