Better Status PageDocs

Deployment Guide

Two paths: Docker Compose (recommended — zero dependencies, five commands) or bare metal (if you manage your own Node.js environment).


Prerequisites

  • Docker 24+ and Docker Compose v2 (docker compose version)
  • A domain or IP pointing to your server

The published image on GHCR contains the compiled API and both built frontends, so the server does not need the source code. It needs only two files in one directory: docker-compose.yml and .env.

Quick install (script)

To skip steps 1 to 3, run the installer on the server:

curl -fsSL https://betterstatuspage.dev/install.sh | sh

It checks for curl, Docker and Docker Compose v2, downloads docker-compose.yml and .env.example, generates JWT_SECRET and VAULT_ENCRYPTION_KEY into a .env readable only by its owner (chmod 600), then runs docker compose pull and docker compose up -d. The application listens on 127.0.0.1:3000; continue with step 4 and put Nginx in front for remote access.

VariableDefaultDescription
BSP_DIR/opt/bsp as root, otherwise ~/bspInstall directory.
BSP_REFmainBranch or tag the files are downloaded from. Use a release tag to get the Compose file that matches it.
curl -fsSL https://betterstatuspage.dev/install.sh | BSP_DIR=/srv/bsp sh

Running it again is safe: the Compose file is refreshed, an existing .env is kept (so the secrets stay), and the image is pulled and the container started again. To update, change BSP_IMAGE in .env as described in Updates.

Store VAULT_ENCRYPTION_KEY from the generated .env outside the server as well — backups never include it. To read the script before running it, download it first: curl -fsSLO https://betterstatuspage.dev/install.sh. The manual steps below do the same thing.

1. Download the Compose file and the environment template

mkdir -p /opt/bsp && cd /opt/bsp
curl -fsSLO https://raw.githubusercontent.com/BElluu/BetterStatusPage/main/docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/BElluu/BetterStatusPage/main/.env.example -o .env
chmod 600 .env

2. Configure .env

Generate the two production secrets:

openssl rand -hex 32   # JWT_SECRET
openssl rand -hex 32   # VAULT_ENCRYPTION_KEY

Edit .env and set at least:

BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0
BSP_BIND_ADDRESS=127.0.0.1
JWT_SECRET=<first generated value>
VAULT_ENCRYPTION_KEY=<second generated value>
TRUST_PROXY=1                          # when Nginx is in front, see below
PUBLIC_URL=https://status.example.com  # required for email/webhook subscriptions

The same .env serves two purposes. Docker Compose reads it automatically to fill ${BSP_IMAGE}, ${BSP_BIND_ADDRESS} and ${PORT} in docker-compose.yml, and passes it to the container through env_file. Nothing needs to be exported in the shell.

With Docker, PORT only changes the published host port; the app always listens on 3000 inside the container. NODE_ENV, DATABASE_PATH, UPLOAD_DIR and BACKUP_DIR are fixed by docker-compose.yml and point at the volumes, so their values in .env are ignored.

Store VAULT_ENCRYPTION_KEY somewhere outside the server as well — backups never include it.

3. Pull and start

docker compose pull
docker compose up -d

Building the image from source

To build and run your own image instead of the published one, clone the repository and use the local override, so the production Compose file keeps pointing at GHCR:

git clone https://github.com/BElluu/BetterStatusPage.git
cd BetterStatusPage
cp .env.example .env   # set the secrets as above
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build

4. Open the setup wizard

Navigate to http://your-server:3000/admin — you’ll be greeted by the setup wizard. Create the first administrator account there; the web setup wizard does not read administrator credentials from .env.

The wizard has three steps:

  1. Database — SQLite is the only option and is already selected.
  2. Admin Account — email and password (8 to 128 characters). This account gets the admin role.
  3. Done — the database is created, default branding (site name My Status Page) and an empty page layout are added, monitoring starts, and you are signed in.

Setup runs only once. When it finishes, setup.json is written to the data directory and the wizard closes. Until then /ready returns 503. Further administrators and the other roles are added later under Users, see users and roles.

The status page is at http://your-server:3000.

Useful commands

docker compose logs -f          # stream logs
docker compose restart app      # restart after config change
docker compose down             # stop (data is preserved in the volume)
docker compose down -v          # stop AND delete all data — use with caution
docker compose pull && docker compose up -d  # update to the configured published image

Where is my data?

Application data lives in the bsp_data Docker named volume, mounted at /app/data inside the container:

/app/data/
├── db.sqlite       # the entire database
├── setup.json      # marks setup as complete
└── uploads/        # uploaded logos and favicons

Generated archives live in a separate bsp_backups volume mounted at /app/backups. Both volumes survive docker compose down, container rebuilds, and image upgrades. Use the backup commands below instead of copying the active WAL database or its volume directly.


Option B — Bare metal (VPS / dedicated server)

Prerequisites

  • Node.js 26.10+ — uses the built-in node:sqlite module
  • npm 11+
  • Linux (Ubuntu 22.04+ recommended) or any OS with Node.js support

1. Install Node.js 26

curl -fsSL https://deb.nodesource.com/setup_26.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version   # should print v26.x.x

2. Clone and install dependencies

git clone https://github.com/BElluu/BetterStatusPage.git
cd BetterStatusPage
npm install

3. Configure

cp .env.example .env
nano .env

Set JWT_SECRET and VAULT_ENCRYPTION_KEY. The web setup wizard asks for the first administrator credentials. See the environment variables reference below.

4. Build

npm run build

This compiles the TypeScript API and builds both React frontends. Output:

  • apps/api/dist/ — compiled API
  • apps/admin/dist/ — admin panel static files
  • apps/status/dist/ — status page static files

5. Start

NODE_ENV=production node apps/api/dist/index.js

Open http://localhost:3000/admin to run the setup wizard.

6. Keep it running with PM2

npm install -g pm2
pm2 start apps/api/dist/index.js --name bsp --node-args="--env-file=.env"
pm2 save
pm2 startup    # generates the command to run on boot — follow the printed instructions

Or create ecosystem.config.cjs:

module.exports = {
  apps: [{
    name: 'bsp',
    script: 'apps/api/dist/index.js',
    env: {
      NODE_ENV: 'production',
    },
  }],
}
pm2 start ecosystem.config.cjs
pm2 save && pm2 startup

Nginx reverse proxy

Whether you’re using Docker or bare metal, put Nginx in front — it handles SSL termination, compression, custom ports, and proper SSE proxying.

BetterStatusPage is a single process that serves everything (API + admin panel + status page) on one port. Nginx is what exposes different URLs or ports to the outside world, routing each domain transparently to that one backend. Because the frontend JavaScript uses relative URLs (/api/v1/...), requests always go to the same origin the user sees in their browser — Nginx handles the rest invisibly.

Set TRUST_PROXY=1 when Nginx is the only path to the application. This makes authentication and setup rate limits and the audit log use the real client IP from X-Forwarded-For. TRUST_PROXY=1 trusts a proxy connecting from loopback or a private network address (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7), which covers Nginx on the same host and a proxy container on a Docker network. To narrow it, set an explicit comma-separated list of addresses or CIDR ranges instead, for example TRUST_PROXY=172.18.0.0/16. Do not enable it while the application port is directly reachable from the internet, because clients could spoof forwarding headers.

Production rule: do not expose the application port directly to the internet when TRUST_PROXY is enabled. Bind Docker to 127.0.0.1:3000:3000 or restrict the port with your firewall, then let Nginx be the only public entry point.

Install Nginx

sudo apt install nginx

Scenario 1 — everything on one domain (simplest)

The status page is at the root, admin panel at /admin/.

server {
    listen 443 ssl;
    server_name status.example.com;

    # Nginx refuses bodies over 1 MB by default. A configuration file may be up to 2 MB; backups are larger.
    client_max_body_size 2m;

    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;

        # Required for Server-Sent Events — do not remove
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;
    }
}

The admin panel is then reachable at https://status.example.com/admin/.

Scenario 2 — separate subdomains for status page and admin panel

Two domains, one backend process. The browser JS on each domain makes relative API requests to its own domain, and Nginx proxies those to the shared backend.

# ── Shared proxy settings ─────────────────────────────────────────────────────
# Put this in /etc/nginx/snippets/bsp-proxy.conf
# 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_buffering    off;
# proxy_cache        off;
# proxy_read_timeout 3600s;

# ── Public status page ────────────────────────────────────────────────────────
server {
    listen 443 ssl;
    server_name status.example.com;

    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-Proto $scheme;
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;
    }
}

# ── Admin panel ───────────────────────────────────────────────────────────────
# The admin panel assets are served at /admin/ by the Node.js process.
# Nginx proxies everything — both /admin/* (static files) and /api/v1/* (API calls).
server {
    listen 443 ssl;
    server_name admin.example.com;

    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-Proto $scheme;
        proxy_buffering    off;
        proxy_cache        off;
        proxy_read_timeout 3600s;
    }
}

Users visit https://admin.example.com/admin/ for the admin panel. The JS inside makes requests to admin.example.com/api/v1/..., Nginx proxies those to localhost:3000/api/v1/.... Transparent.

Scenario 3 — custom ports per component

Some deployments expose specific ports directly (without port 443). Set the app’s PORT in .env, then configure Nginx to listen on whatever external port you want:

# Status page accessible on port 4000
server {
    listen 4000 ssl;
    server_name statusklient1.pl;
    ...
    location / { proxy_pass http://127.0.0.1:3000; ... }
}

# Admin panel accessible on port 5000
server {
    listen 5000 ssl;
    server_name adminpanelklient1.pl;
    ...
    location / { proxy_pass http://127.0.0.1:3000; ... }
}

The app itself still runs on port 3000 internally. Nginx is the only thing that needs to know about the external ports.

For each port you expose, make sure your firewall allows it:

sudo ufw allow 4000/tcp
sudo ufw allow 5000/tcp

Scenario 4 — multiple clients on the same server

Each client gets their own BetterStatusPage instance. Run one Docker Compose stack per client, each on a different internal port, then use Nginx to route domains:

/opt/bsp-client1/   → runs on port 3001
/opt/bsp-client2/   → runs on port 3002
/opt/bsp-client3/   → runs on port 3003
# /opt/bsp-client1/.env
PORT=3001
JWT_SECRET=<unique per client>
VAULT_ENCRYPTION_KEY=<unique per client>
server {
    listen 443 ssl;
    server_name status.client1.com;
    location / { proxy_pass http://127.0.0.1:3001; ... }
}

server {
    listen 443 ssl;
    server_name status.client2.com;
    location / { proxy_pass http://127.0.0.1:3002; ... }
}

Each client has fully isolated data (separate SQLite files, separate encryption keys, separate JWT secrets). They never share anything.

Limit backend access to localhost only

When using Nginx as the public entry point, bind the app to localhost only so it can’t be reached directly:

# docker-compose.yml
ports:
  - "127.0.0.1:3000:3000"
# bare metal .env
# PORT=3000 — then Nginx proxies to 127.0.0.1:3000
TRUST_PROXY=1

HTTPS with Let’s Encrypt

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d status.example.com -d admin.example.com

Certbot edits your Nginx configs automatically and sets up auto-renewal.

Behind Cloudflare: “Origin not allowed”

In production the app rejects state-changing requests (setup wizard, login, saving settings) whose Origin header does not match the address the app thinks it is served from. It builds that address from X-Forwarded-Proto and Host.

With Cloudflare’s Flexible SSL mode, the browser talks HTTPS to Cloudflare, but Cloudflare talks plain HTTP to Nginx on port 80. Nginx’s $scheme is then http, so the app sees http://status.example.com while the browser sends Origin: https://status.example.com, and the request fails with 403 Origin not allowed. Setting ALLOWED_ORIGINS is not the fix — the forwarded protocol is wrong.

Pick one of two fixes:

Option 1 — keep Flexible, hardcode the protocol in Nginx. In the location block replace $scheme with a fixed value:

proxy_set_header   X-Forwarded-Proto https;

This works as long as every request reaches Nginx through Cloudflare. Traffic between Cloudflare and your server stays unencrypted.

Option 2 — switch Cloudflare to Full (strict). Set SSL/TLS mode to Full (strict) in the Cloudflare dashboard and give Nginx a listen 443 ssl server block with a certificate (a Cloudflare Origin Certificate or Let’s Encrypt). X-Forwarded-Proto $scheme then carries https and no other change is needed. Traffic is encrypted end to end.

Either way, reload Nginx afterwards:

sudo nginx -t && sudo systemctl reload nginx

Updates

Docker Compose

Set the new version in .env, for example BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0, then:

docker compose pull
docker compose up -d

If a release changes docker-compose.yml, download it again from the matching tag.

The volume is untouched. Database migrations run automatically on startup.

The container runs as the unprivileged node user (UID 1000). Volumes created by older images, which ran as root, are owned by root; if the app then fails to start with a permission error, give the volumes to UID 1000 once:

docker compose run --rm --user root app chown -R 1000:1000 /app/data /app/backups

Bare metal

git pull
npm install
npm run build
pm2 restart bsp

Production checklist

Before exposing an instance publicly:

  • NODE_ENV=production is set.
  • JWT_SECRET is random, non-default, and at least 32 characters.
  • VAULT_ENCRYPTION_KEY is a random 64-character hex string and stored outside the application.
  • The app port is private; public traffic goes through Nginx/SSL.
  • TRUST_PROXY=1 is enabled only when Nginx is the only path to the app.
  • Backups are configured, one backup was created, and verification passes.
  • A restore drill was completed on a non-production copy.
  • /health returns 200 and /ready returns 200 after setup.
  • If SSO is enabled, PUBLIC_URL is set, the same callback URL is registered at the identity provider, and you know the OIDC_FORCE_PASSWORD_LOGIN break-glass switch.
  • GitHub CI or equivalent release checks passed: lint, tests, build, Docker build, and E2E on Linux.

Environment variables

VariableRequiredDescription
BSP_IMAGEDocker ComposeContainer image used by docker-compose.yml, for example ghcr.io/belluu/better-status-page:0.4.0.
BSP_BIND_ADDRESSDocker ComposeHost address for published port. Default/recommended behind Nginx: 127.0.0.1. Use 0.0.0.0 only for direct testing.
PORTNoPort to listen on. Default: 3000
NODE_ENVYes (prod)Set to production. Enables security headers, HSTS, enforces secrets.
JWT_SECRETYes (prod)Signs the HttpOnly admin session cookie. Min 32 chars. Changing it logs everyone out.
VAULT_ENCRYPTION_KEYYes (prod)64-char hex string (32 bytes) for AES-256-GCM vault encryption. Changing it makes all stored secrets unreadable.
ADMIN_EMAILdb:seed onlyRequired only when intentionally running npm run db:seed. The web setup wizard does not read it.
ADMIN_PASSWORDdb:seed onlyRequired only when intentionally running npm run db:seed; minimum 8 characters.
DATA_DIRNoBase directory for application data; the four paths below default to locations inside it. Default: ./data (relative to the working directory)
DATABASE_PATHNoPath to the SQLite file. Default: <DATA_DIR>/db.sqlite
UPLOAD_DIRNoDirectory for uploaded files (logos, favicons). Default: <DATA_DIR>/uploads
BACKUP_DIRNoDirectory for generated backup archives. Default: <DATA_DIR>/backups (the Docker image sets /app/backups)
SETUP_CONFIG_PATHNoMarker file written when the setup wizard finishes. Default: <DATA_DIR>/setup.json
ALLOWED_ORIGINSNoComma-separated CORS origins. Leave unset if Nginx handles CORS, or in single-domain setups.
TRUST_PROXYNoTrusted proxy setting: 1 trusts a proxy on loopback or a private network (Docker bridge included), or give a comma-separated list of addresses/CIDR ranges. Enable only when the app port is not directly exposed.
PUBLIC_URLFor email/webhook subscriptionsPublic address of the status page, e.g. https://status.example.com. All links sent to subscribers are built from it; see subscriptions. An SSO sign-in started on the sign-in screen of a private status page also returns to it.
UPDATE_CHECKNoSet to false to stop the server from asking GitHub for the latest release. By default it does so in the background every 6 hours, and System Health and the Dashboard link to the release when it is newer than the running version.
OIDC_ISSUER / OIDC_CLIENT_IDNoConfigure OpenID Connect single sign-on from the environment instead of Users → Single sign-on. When both are set they override the UI settings and lock that page. Register <PUBLIC_URL>/api/v1/auth/oidc/callback as the redirect URI. Users must already exist; they are matched by verified email. A private status page can let SSO create Viewer accounts.
OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_BUTTON_LABELNoOptional companions to the two variables above (defaults: none, derived from PUBLIC_URL, openid email profile, Sign in with SSO).
OIDC_DISABLE_PASSWORD_LOGIN, OIDC_ALLOW_UNVERIFIED_EMAILNotrue hides and rejects password sign-in while OIDC is active / accepts IdPs that send neither email_verified nor xms_edov (for Entra ID add the xms_edov optional claim instead).
OIDC_FORCE_PASSWORD_LOGINNoBreak-glass: true re-enables password sign-in even if it was disabled, for when the identity provider is down. Restart required.
SCHEDULER_TICK_SECONDSNoHow often the scheduler scans for due monitors. Default: 10. Must be 1-59 seconds.
MONITOR_CHECK_CONCURRENCYNoMaximum number of due monitors checked concurrently. Default: 20.
MONITOR_RESULT_RETENTION_DAYSNoMonitor result retention period. Default: 90.
MONITOR_RESULT_PURGE_CRONNoCron expression for purging old monitor results. Default: 0 2 * * * (daily at 02:00).

Generate secrets:

# JWT_SECRET
openssl rand -hex 32

# VAULT_ENCRYPTION_KEY (must be exactly 64 hex chars)
openssl rand -hex 32

Without OpenSSL, node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" produces the same format.


Backups

BetterStatusPage creates a consistent SQLite snapshot with VACUUM INTO; do not copy a live db.sqlite directly while WAL mode is active. A bsp-backup-TIMESTAMP.backup archive contains the database, setup.json, uploads, a versioned manifest, and SHA-256 checksums.

For the full operational procedure, including restore drills and key handling, see backup-restore.md.

Build the application before using the CLI, then create or verify a backup:

npm run build
npm run backup -- --output ./backups
npm run backup:verify -- --input ./backups/bsp-backup-TIMESTAMP.backup

The admin Backups page can create, download, validate, delete, and schedule backups. Automatic backups support daily or weekly execution and count-based retention.

Restore is deliberately offline. Stop the application first:

pm2 stop bsp
npm run restore -- --input ./backups/bsp-backup-TIMESTAMP.backup --vault-encryption-key YOUR_64_CHARACTER_HEX_KEY
pm2 start bsp

The restore validates every checksum, runs SQLite integrity checks, creates a safety backup of the current state, atomically replaces the data, runs migrations, and rolls back if migration fails.

Docker Compose uses a separate bsp_backups volume, so deleting the application data volume does not delete backup archives:

# Online backup
docker compose exec app node apps/api/dist/cli/backup.js

# List available archives
docker compose run --rm app ls -lh /app/backups

# Offline restore
docker compose stop app
docker compose run --rm app node apps/api/dist/cli/restore.js --input /app/backups/bsp-backup-TIMESTAMP.backup --vault-encryption-key YOUR_64_CHARACTER_HEX_KEY
docker compose up -d app

VAULT_ENCRYPTION_KEY and .env are never included. Keep the key separately. Restore requires the key explicitly and rejects malformed or mismatched values.


Single sign-on

Administrators can sign in through an OpenID Connect identity provider (Entra ID, Keycloak, Okta, Google and others). Set it up from Users → Single sign-on with no restart, or with the OIDC_* variables in the table above. PUBLIC_URL must be set (or OIDC_REDIRECT_URI) so the callback address can be built. See single-sign-on.md.

Recover access when 2FA is lost

An administrator can reset 2FA for another user from Admin → Users. The action requires the administrator to confirm who they are (their current password, or single sign-on when they signed in with it, see single-sign-on.md) and exact confirmation of the target email address. It removes only the target user’s 2FA configuration, revokes all of their active sessions, and writes an audit entry. It does not change their password.

An administrator cannot use this action on their own account. If the installation has only one administrator and both the authenticator and recovery codes are unavailable, use the emergency CLI from a trusted host. Access to the application host and data volume is the authority for this operation.

Build the application before running the local CLI:

npm run build
npm run 2fa:reset -w apps/api -- --email [email protected] --confirm [email protected]

Docker Compose:

docker compose exec app node apps/api/dist/cli/resetTwoFactor.js --email [email protected] --confirm [email protected]

If the application container is not running, use the same persistent data volume through a one-off container:

docker compose run --rm app node apps/api/dist/cli/resetTwoFactor.js --email [email protected] --confirm [email protected]

The confirmation must exactly match the email. A successful reset revokes every session for the account and records an emergency_cli entry in the audit log. Sign in again, with the existing password or single sign-on, and configure 2FA again immediately.


Firewall

Only expose port 443 (and 80 for redirect) to the internet. Keep the app port (3000) internal:

sudo ufw allow 22/tcp    # SSH
sudo ufw allow 80/tcp    # HTTP (for Let's Encrypt redirect)
sudo ufw allow 443/tcp   # HTTPS
sudo ufw enable

If using Docker without Nginx, you can publish the port to localhost only:

ports:
  - "127.0.0.1:3000:3000"

This prevents direct access to port 3000 from outside — all traffic goes through Nginx.