A self-hosted status page that doesn't make you cry.
TypeScript
2
277 commits
updated Oct 14, 2026
English · Polski
A self-hosted status page that doesn't make you cry. 🟢
Monitor your services. Alert your team. Keep your users in the loop. No cloud. No subscription. No drama.
You know the drill. Your API goes down at 3 AM, users start tweeting, your boss is calling, and your status page is... a static HTML file someone last updated in 2019 that says "All systems operational" in Comic Sans.
BetterStatusPage fixes that. It's a fully self-hosted monitoring and status page platform that runs as a single Node.js process with zero external dependencies — no Postgres, no Redis, no Kubernetes, no $200/month SaaS bill. Just clone, configure, deploy. Done.
BetterStatusPage is currently in beta. The current target is a practical self-hosted deployment for small teams and personal infrastructure:
Thank you in advance for your interest in the project and for every installation, test, bug report, and piece of feedback. Early real-world testing is especially valuable and will directly help shape BetterStatusPage on the way to a stable release.
Full documentation: https://docs.betterstatuspage.dev. Before exposing a production instance, read:
| Type | What it checks |
|---|---|
| HTTPS | URLs, status codes, response times, keywords in the body, header and JSON field checks, a slow-response limit, full auth flows (Basic, OAuth2, CAS), and optional TLS certificate expiry warnings |
| Ping / TCP | Whether a host is alive — via ICMP or TCP port check |
| DNS | Whether your records resolve correctly — A, AAAA, MX, CNAME, TXT, with custom resolver support |
| SQL Server | Runs a test query against MSSQL and validates the result |
| PostgreSQL | Runs a test query against PostgreSQL and validates the result |
| MySQL / MariaDB | Runs a test query against MySQL or MariaDB and validates the result |
| MongoDB | Runs a test command against MongoDB and validates the result |
| Docker | State and healthcheck of a container, through the Docker Engine API |
| Webhook (passive) | Lets external services ping you to signal they're alive — silence means trouble |
Every monitor gets: configurable intervals, timeouts, retries, color-coded tags for grouping, a 30-day uptime bar on the status page, and 90 days of check results kept by default, with a per-monitor admin page for uptime, response-time percentiles, incidents and recent failures. Oh, and there's a built-in test runner so you can validate your config before hitting Save and immediately regretting it.
See docs.betterstatuspage.dev/monitors for every monitor type, auth flows, heartbeat URLs, certificate warnings and dependencies.
When something breaks (or recovers), BetterStatusPage can shout at you via:
<!here> / <!channel> mentions — just an incoming webhook URLAll channels support template variables like {{monitor_name}}, {{status}}, {{error_message}} etc., so your alerts can say "API Gateway is down: connection timeout" instead of "status changed".
Recovery notifications are optional per channel — because sometimes you want to know when things come back up, and sometimes you just want to sleep.
TLS certificate expiry warnings — switch them on per HTTPS monitor and pick the lead time (14 days by default). The monitor's channels get one warning when the certificate enters that window, then reminders 7, 3 and 1 days before it expires, and an all-clear once it has been renewed (if the channel sends recoveries). The certificate is read every 6 hours; the public status page is not affected, and a certificate that has already expired fails the check and alerts as down. The admin monitor list shows how many days each certificate has left, and the test runner shows the expiry date. Certificate notifications add the template variables {{event_type}} (certificate), {{cert_expires_in}}, {{cert_expires_at}}, {{cert_days_left}} and {{cert_host}}; {{status}} is cert-expiring or cert-renewed.
Every delivery is persisted with its individual attempts. Failed sends retry automatically after 1 and 5 minutes, then remain visible in the admin delivery history for manual retry. Delivery history is retained for 180 days.
See docs.betterstatuspage.dev/notification-channels for email, webhook, template variables and delivery retries. See docs.betterstatuspage.dev/discord-integration for a step-by-step Discord setup guide. See docs.betterstatuspage.dev/teams-integration for a step-by-step Microsoft Teams setup guide. See docs.betterstatuspage.dev/slack-integration for a step-by-step Slack setup guide. See docs.betterstatuspage.dev/telegram-integration for a step-by-step Telegram setup guide.
An alerting system you had to mute is worse than no alerting system. Four controls, all off by default, so nothing changes until you ask for it:
Everything the rules stop is still recorded in the delivery history with the reason — so you can check what was not sent and loosen the rule if it was too strict.
See docs.betterstatuspage.dev/alert-hygiene for how the rules combine, how to pick values, and the API shape.
A notification tells people. An action changes something: it sends an HTTP request when a monitor stays down for a set time, or comes back up. Start a standby server through a Jenkins job when your primary is down for five minutes, stop it again when the primary is back.
down or back up, with a delay (a down action is dropped if the monitor recovers first) and a cooldown so a flapping check cannot run it again and again.Authorization: Basic {{secret_jenkins_basic}}), as many as you need, and is never stored with the action or shown in the run history.Only administrators manage actions. See docs.betterstatuspage.dev/actions.
Storing passwords in environment variables is fine until it isn't. BetterStatusPage has a built-in AES-256-GCM encrypted vault where you can stash credentials and reference them from monitors and SMTP settings. Supports:
Prefer to keep secrets elsewhere? A vault can also read them live from HashiCorp Vault (KV v2, token or AppRole) instead of storing them.
Your secrets never appear in logs, list responses, the audit log, or your git diff — only an administrator who clicks Reveal sees a value.
See docs.betterstatuspage.dev/vault for secret types, field mapping and where secrets can be used.
Let your team sign in with the identity provider they already use — Microsoft Entra ID, Keycloak, Okta, Google Workspace, Authentik, Auth0 or any other OIDC provider. Authorization Code flow with PKCE, state and nonce, ending in the same hardened server-side session as a password login.
.env edit, no restart. The secret is stored encrypted and never shown again.OIDC_FORCE_PASSWORD_LOGIN=true is the break-glass switch if your IdP goes down.OIDC_* environment variables, which then override and lock the UI form.See docs.betterstatuspage.dev/single-sign-on for setup, provider notes and troubleshooting.
The public status page isn't just a list of green dots. It's a fully customizable grid layout you design yourself — drag in monitor cards, group them by service (and drag them into or out of groups), add markdown text blocks, drop in an incident feed, resize everything, done. No CSS required.
See docs.betterstatuspage.dev/customizing-the-status-page for the builder blocks, branding, dark mode and translations.
Create incidents, set severity (minor → major → critical), link affected monitors, post real-time updates as the situation unfolds, and mark resolved when the dust settles. On the public page, an active minor incident marks its linked monitors as degraded, while major and critical incidents mark them as down. Resolving the incident restores the status reported by monitoring checks. A monitor can also open an incident by itself when it stays down for a set time, as a draft you publish or published at once, written from a default or custom template, and close it after recovery.
Notification channels wake up your team; subscriptions keep your users in the loop. Visitors subscribe from the status page by email or webhook, add the feed to a Slack channel with /feed subscribe, follow the RSS/Atom feed, or poll the JSON status API (summary.json, components.json).
See docs.betterstatuspage.dev/subscriptions for setup, delivery rules and the webhook payload.
Not every status page is for the whole internet. Switch on Users → Status page access → Private status page and only signed-in users see it. Everyone else gets a sign-in screen in your page's branding, with password and SSO sign-in.
private and noindexSee docs.betterstatuspage.dev/private-status-page for who can view a private page and what it switches off.
The status page shows 30 days; Monitoring → Reports in the admin console covers any date range you still have results for (90 days by default). See uptime, checks, average response time and incidents per monitor, and export a summary or a day-by-day CSV for SLA reviews.
See docs.betterstatuspage.dev/reports for how uptime is counted and the CSV columns.
Scheduled that 3 AM database migration? Let people know in advance instead of letting them think you're on fire.
Maintenance windows suppress alert noise for the duration of planned downtime — no more on-call pages for work you're doing on purpose. Create a window with a name, description, start/end time, and optionally scope it to specific monitors (or flip the switch for "all monitors"). While a window is active:
See docs.betterstatuspage.dev/incidents-and-maintenance for incident statuses, impact and exactly what a maintenance window silences.
Six months from now, someone will ask "who changed the check interval on the payments monitor from 30 seconds to 5 minutes last Thursday?" The answer is in the audit log.
Every mutation in the admin panel is recorded — who did it, when, and exactly what changed. For updates, you get a field-level diff with before and after values (sensitive fields like passwords are always redacted). Covered entities:
| What | Tracked operations |
|---|---|
| Monitors | Create, update (name, type, interval, timeout, retries, tags, config), delete |
| Incidents | Create, update (title, status, impact), delete |
| Maintenance windows | Create, update, delete |
| Notification channels | Create, update (name, type, enabled, config), delete |
| SMTP settings | Configure / update |
| Subscription settings | Configure or change methods, notification types and component scope |
| Subscribers | Delete (single or several at once) |
| Subscriber deliveries | Manual retry of a failed delivery |
| Vaults & secrets | Create, update (name, value change flagged as [redacted]), delete |
| Users | Create, role change, password reset, delete |
| API tokens | Created or revoked (never the token), and every change made with one, under the creator's email and the token's name |
| Import | Each import with its counts, and every monitor and channel it changed |
| User security | Enable or disable TOTP two-factor authentication, password changes, SSO account linking, revoked temporary passwords |
| SSO settings | Configure or change OpenID Connect settings |
| Branding, page builder, translations | Branding saves, layout saves, locale and translation changes |
| Backups | Backup created or deleted, automatic backup schedule changes |
| Notification deliveries | Manual retry of a failed delivery |
| Status page access | Make the status page private or public, viewer accounts from SSO and their email domains |
| Sign-ins | Every sign-in with a password or SSO: allowed, or denied with the reason (unknown email, wrong password, wrong 2FA code, refused by SSO…) |
The audit log page (admin-only) lets you filter by user, entity type, action (create / update / delete / allowed / denied), and date range. Click any row to expand the diff inline.
pl) ship with complete built-in copyNot every outage is what it looks like. When your API goes down because the database went down, you don't want three alerts — you want one, for the actual root cause.
BetterStatusPage lets you declare that monitor A depends on monitor B. When B goes down, A automatically shows Dependency Issue instead of Down — and its alert is suppressed, because B already fired it. Once B recovers, A returns to its normal status on the next check.
On the public status page, affected monitors display a "Caused by: Database" chip so visitors understand the hierarchy at a glance, not just a wall of red dots.
Configure it per monitor in the admin panel → monitor edit → Depends on tab.
Status changes propagate to both the admin dashboard and the public page instantly via Server-Sent Events. The moment a monitor flips from 🟢 to 🔴, everyone sees it. No polling, no page refresh, no "wait, is this stale?"
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Admin UI │ │ Public Status Page │ │
│ │ React 19 │ │ React 19 │ │
│ │ Vite · RQ · DnD│ │ SSE · i18n · dark mode │ │
│ └────────┬─────────┘ └────────────┬─────────────┘ │
└────────────┼───────────────────────────────┼────────────────┘
│ REST + SSE │ REST + SSE
┌────────────▼───────────────────────────────▼────────────────┐
│ Fastify 5 (Node.js 26+) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Admin API │ │ Public API │ │ Webhook API │ │
│ │ Auth + RBAC │ │ open/session │ │ token auth │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Background Workers │ │
│ │ │ │
│ │ Scheduler Notifier │ │
│ │ ├── HTTPS checker Vault resolver │ │
│ │ ├── Ping / TCP Result purger (daily) │ │
│ │ ├── DNS resolver │ │
│ │ └── DB checkers (SQL, PostgreSQL, MySQL, Mongo) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Drizzle ORM · SQLite (WAL mode) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
SQLite instead of Postgres — A status page for most teams doesn't need a database server. SQLite in WAL mode handles concurrent reads and writes without breaking a sweat, has zero ops overhead, and your entire database is a single file you can back up with cp. If your status page grows to the point where SQLite is a bottleneck, you have much bigger problems (and probably a dedicated ops team).
SSE instead of WebSockets — Status updates only ever flow server → client. SSE handles that perfectly, uses plain HTTP, needs nothing from a reverse proxy beyond turning buffering off, and auto-reconnects. Why bring a bazooka to a knife fight?
Monorepo with @bsp/shared — The API and both frontends share one TypeScript package for all domain types. Change a type in one place, the compiler yells at you everywhere it matters. No "oh we forgot to update the frontend types" post-mortems.
AES-256-GCM vault in-process — A random 12-byte IV per secret, authentication tag verification on decrypt, secrets never logged. Everything a small team needs for encrypted secrets without standing up another service.
| Layer | Technology | Version |
|---|---|---|
| Runtime | Node.js | 26+ |
| Language | TypeScript | 5.7 |
| Backend | Fastify | 5.x |
| Database | SQLite (node:sqlite) | built-in |
| ORM | Drizzle | 0.45 |
| Frontend | React | 19 |
| Build | Vite | 6.x |
| Styling | Tailwind CSS | 3.x |
| Data fetching | TanStack Query | 5.x |
| State | Zustand | 5.x |
| Drag & drop | dnd-kit | 6.x |
| Auth | Server-side sessions, HttpOnly JWT cookies, CSRF, TOTP, bcrypt, OpenID Connect (PKCE) | — |
| Nodemailer | 9.x | |
| SQL Server | mssql | 11.x |
| PostgreSQL | pg | 8.x |
| MySQL / MariaDB | mysql2 | 3.x |
| MongoDB | mongodb | 7.x |
| Scheduler | node-cron | 3.x |
Docker Compose with the published GHCR image is the recommended deployment path. No clone is needed — the server only needs docker-compose.yml and .env.
On a Linux server with Docker and Docker Compose v2:
curl -fsSL https://betterstatuspage.dev/install.sh | sh
The script downloads docker-compose.yml and .env.example, generates JWT_SECRET and VAULT_ENCRYPTION_KEY, pulls the image and starts the container on 127.0.0.1:3000. It installs into /opt/bsp as root and ~/bsp otherwise (override with BSP_DIR), and is safe to run again: an existing .env is never overwritten. Keep a copy of VAULT_ENCRYPTION_KEY — backups do not include it. Prefer to see every step? Do it manually:
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
Edit .env and set at least:
BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0
JWT_SECRET=<random 32+ char secret>
VAULT_ENCRYPTION_KEY=<64-char hex key>
Generate safe values (run twice, one per secret):
openssl rand -hex 32
Start the application:
docker compose pull
docker compose up -d
Open http://your-server:3000/admin for first-run setup. For internet-facing deployments, put Nginx/SSL in front and keep the application port private. See docs.betterstatuspage.dev/deployment.
node:sqlite module, so no ancient runtimesgit clone https://github.com/BElluu/BetterStatusPage.git
cd BetterStatusPage
npm install
npm run dev
Three things start:
| App | URL |
|---|---|
| API | http://localhost:3000 |
| Admin | http://localhost:5173 |
| Status page | http://localhost:5174 |
Open the admin URL and you'll be greeted by a setup wizard. Create your admin account, and you're in.
npm run lint # ESLint for API, shared types, and both React apps
npm test # API integration tests + frontend component tests
npm run test:api:coverage # API tests with enforced coverage thresholds
npm run test:coverage # frontend coverage report with enforced thresholds
npm run build # strict TypeScript and production builds for all workspaces
npm run backup # create a consistent database + uploads backup (after build)
Browser smoke tests use Playwright. Install Chromium once, then run the suite:
npx playwright install chromium
npm run test:e2e
The E2E suite starts from an empty instance every run (runtime data lives under .e2e/ and is wiped first), walks through the setup wizard, and then covers monitors, incidents, maintenance, email subscriptions, the page builder's drag and drop, role-based access and a private status page with a viewer account. GitHub Actions runs lint, tests, coverage, builds, Playwright E2E, and a production Docker smoke test on pull requests and pushes to main.
npm run build # builds the API and both frontends
npm start # starts the API, which serves them as static files
One process. One port. That's it.
Copy .env.example to .env:
PORT=3000
NODE_ENV=production
BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0
BSP_BIND_ADDRESS=127.0.0.1
JWT_SECRET=something-long-random-and-secret
VAULT_ENCRYPTION_KEY=64-char-hex-string # see below
DATABASE_PATH=./data/db.sqlite
UPLOAD_DIR=./data/uploads
# Comma-separated list of allowed origins for CORS
ALLOWED_ORIGINS=https://status.example.com,https://admin.example.com
# Set only when port 3000 is reachable exclusively through one trusted reverse proxy
TRUST_PROXY=1
# Optional scheduler tuning
SCHEDULER_TICK_SECONDS=10
MONITOR_CHECK_CONCURRENCY=20
MONITOR_RESULT_RETENTION_DAYS=90
MONITOR_RESULT_PURGE_CRON=0 2 * * *
# Public URL of the status page — required for email and webhook subscriptions; all subscriber links use it,
# and SSO sign-ins started on a private page's sign-in screen return to it
PUBLIC_URL=https://status.example.com
# Optional: let subscriber webhooks reach internal addresses (intranet status pages only)
SUBSCRIBER_WEBHOOK_ALLOW_PRIVATE=false
🔑 Generate a vault encryption key:
openssl rand -hex 32Keep this somewhere safe. If it changes, all stored secrets become unreadable. Yes, all of them.
| Role | What they can do |
|---|---|
| admin | Everything, including users, single sign-on, vaults, audit log, backups and API tokens |
| operator | Monitors, incidents, maintenance, notifications, import, page builder, branding, localization, and settings |
| branding | Page builder, branding, localization, and account settings |
| viewer | Views a private status page; no access to the admin console |
Administrator sessions are stored server-side and authenticated with an HttpOnly, SameSite=Strict cookie. State-changing browser requests require a matching CSRF token. Users of the admin console can enable TOTP two-factor authentication from Settings and receive eight single-use recovery codes; it applies to password and SSO sign-ins alike. Administrators can also enable OpenID Connect single sign-on from Users → Single sign-on. Sensitive actions (sign-in settings, 2FA, password changes) are confirmed the way the session signed in: with the current password, or by signing in again at the identity provider in a pop-up.
Scripts and CI/CD can call the admin API with a token from Administration → API tokens, for example to open an incident from a pipeline. A token carries only the permissions you tick (read or write for monitors, notification channels, incidents, maintenance, subscribers and the page's appearance, read for reports, the audit log and system health, and a separate one for using vault secrets) and expires after 90 days unless you choose otherwise. It is shown once, and cannot manage users, single sign-on, status page access, vaults, backups or other tokens.
See docs.betterstatuspage.dev/api for the token rules and
curlexamples.
Every monitor, notification channel and action can be written as a YAML document with a kind, identified by a key instead of a numeric id and with the known secret fields masked, for version control and review. A YAML button on a monitor, a channel and an action shows that object; Import in the left menu (and the API) creates or updates objects from pasted or uploaded documents, with a preview before anything is written. Documents are checked completely, applied in one transaction, and applying them twice changes nothing. An import never deletes anything.
See docs.betterstatuspage.dev/configuration-as-code for the format and what is included.
server {
listen 443 ssl;
server_name status.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE requires these — don't skip them
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
npm i -g pm2
pm2 start npm --name "bsp" -- start
pm2 save && pm2 startup
Download docker-compose.yml and .env.example (saved as .env) as shown in Quick production start, set BSP_IMAGE, JWT_SECRET and VAULT_ENCRYPTION_KEY in .env, then:
docker compose pull
docker compose up -d
Data (SQLite database + uploads) is stored in a named Docker volume (bsp_data) and survives container rebuilds, restarts, and image upgrades. It is only deleted if you explicitly run docker compose down -v.
To build the image from source instead, clone the repository and use the local override so the production Compose file continues to use GHCR:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
See docs.betterstatuspage.dev/deployment for the full deployment guide, including Nginx + SSL setup, bare-metal install, PM2, backups, and firewall configuration.
Before making an instance public:
NODE_ENV=production.JWT_SECRET with a random non-default value.VAULT_ENCRYPTION_KEY to a random 64-character hex value and store it outside the app.TRUST_PROXY=1 only when the app is reachable exclusively through your reverse proxy.BetterStatusPage/
├── apps/
│ ├── api/ # Fastify backend
│ │ └── src/
│ │ ├── db/ # Schema, migrations, seed
│ │ ├── routes/ # Endpoint handlers
│ │ ├── workers/ # Scheduler, checkers, notifier
│ │ ├── crypto/ # AES-256-GCM vault encryption
│ │ └── services/ # SSE broadcaster
│ ├── admin/ # Admin dashboard (React)
│ └── status/ # Public status page (React)
└── packages/
└── shared/ # Shared TypeScript types (@bsp/shared)
Found a bug? Have an idea? PRs are welcome — just open an issue first for anything bigger than a typo fix so we can discuss the approach.
git checkout -b feature/your-ideanpm run lint && npm test && npm run buildMIT — do whatever you want. Just please don't set the status page font to Comic Sans. 😅
BetterStatusPage is completely free and open source. If you find it useful and want to support continued development, voluntary donations are appreciated but never required!
Your support helps keep the project alive and motivates continued development — but using BetterStatusPage, testing it, and sharing feedback is support enough!
A self-hosted status page that doesn't make you cry.
TypeScript
2
277 commits
updated Oct 14, 2026
English · Polski
A self-hosted status page that doesn't make you cry. 🟢
Monitor your services. Alert your team. Keep your users in the loop. No cloud. No subscription. No drama.
You know the drill. Your API goes down at 3 AM, users start tweeting, your boss is calling, and your status page is... a static HTML file someone last updated in 2019 that says "All systems operational" in Comic Sans.
BetterStatusPage fixes that. It's a fully self-hosted monitoring and status page platform that runs as a single Node.js process with zero external dependencies — no Postgres, no Redis, no Kubernetes, no $200/month SaaS bill. Just clone, configure, deploy. Done.
BetterStatusPage is currently in beta. The current target is a practical self-hosted deployment for small teams and personal infrastructure:
Thank you in advance for your interest in the project and for every installation, test, bug report, and piece of feedback. Early real-world testing is especially valuable and will directly help shape BetterStatusPage on the way to a stable release.
Full documentation: https://docs.betterstatuspage.dev. Before exposing a production instance, read:
| Type | What it checks |
|---|---|
| HTTPS | URLs, status codes, response times, keywords in the body, header and JSON field checks, a slow-response limit, full auth flows (Basic, OAuth2, CAS), and optional TLS certificate expiry warnings |
| Ping / TCP | Whether a host is alive — via ICMP or TCP port check |
| DNS | Whether your records resolve correctly — A, AAAA, MX, CNAME, TXT, with custom resolver support |
| SQL Server | Runs a test query against MSSQL and validates the result |
| PostgreSQL | Runs a test query against PostgreSQL and validates the result |
| MySQL / MariaDB | Runs a test query against MySQL or MariaDB and validates the result |
| MongoDB | Runs a test command against MongoDB and validates the result |
| Docker | State and healthcheck of a container, through the Docker Engine API |
| Webhook (passive) | Lets external services ping you to signal they're alive — silence means trouble |
Every monitor gets: configurable intervals, timeouts, retries, color-coded tags for grouping, a 30-day uptime bar on the status page, and 90 days of check results kept by default, with a per-monitor admin page for uptime, response-time percentiles, incidents and recent failures. Oh, and there's a built-in test runner so you can validate your config before hitting Save and immediately regretting it.
See docs.betterstatuspage.dev/monitors for every monitor type, auth flows, heartbeat URLs, certificate warnings and dependencies.
When something breaks (or recovers), BetterStatusPage can shout at you via:
<!here> / <!channel> mentions — just an incoming webhook URLAll channels support template variables like {{monitor_name}}, {{status}}, {{error_message}} etc., so your alerts can say "API Gateway is down: connection timeout" instead of "status changed".
Recovery notifications are optional per channel — because sometimes you want to know when things come back up, and sometimes you just want to sleep.
TLS certificate expiry warnings — switch them on per HTTPS monitor and pick the lead time (14 days by default). The monitor's channels get one warning when the certificate enters that window, then reminders 7, 3 and 1 days before it expires, and an all-clear once it has been renewed (if the channel sends recoveries). The certificate is read every 6 hours; the public status page is not affected, and a certificate that has already expired fails the check and alerts as down. The admin monitor list shows how many days each certificate has left, and the test runner shows the expiry date. Certificate notifications add the template variables {{event_type}} (certificate), {{cert_expires_in}}, {{cert_expires_at}}, {{cert_days_left}} and {{cert_host}}; {{status}} is cert-expiring or cert-renewed.
Every delivery is persisted with its individual attempts. Failed sends retry automatically after 1 and 5 minutes, then remain visible in the admin delivery history for manual retry. Delivery history is retained for 180 days.
See docs.betterstatuspage.dev/notification-channels for email, webhook, template variables and delivery retries. See docs.betterstatuspage.dev/discord-integration for a step-by-step Discord setup guide. See docs.betterstatuspage.dev/teams-integration for a step-by-step Microsoft Teams setup guide. See docs.betterstatuspage.dev/slack-integration for a step-by-step Slack setup guide. See docs.betterstatuspage.dev/telegram-integration for a step-by-step Telegram setup guide.
An alerting system you had to mute is worse than no alerting system. Four controls, all off by default, so nothing changes until you ask for it:
Everything the rules stop is still recorded in the delivery history with the reason — so you can check what was not sent and loosen the rule if it was too strict.
See docs.betterstatuspage.dev/alert-hygiene for how the rules combine, how to pick values, and the API shape.
A notification tells people. An action changes something: it sends an HTTP request when a monitor stays down for a set time, or comes back up. Start a standby server through a Jenkins job when your primary is down for five minutes, stop it again when the primary is back.
down or back up, with a delay (a down action is dropped if the monitor recovers first) and a cooldown so a flapping check cannot run it again and again.Authorization: Basic {{secret_jenkins_basic}}), as many as you need, and is never stored with the action or shown in the run history.Only administrators manage actions. See docs.betterstatuspage.dev/actions.
Storing passwords in environment variables is fine until it isn't. BetterStatusPage has a built-in AES-256-GCM encrypted vault where you can stash credentials and reference them from monitors and SMTP settings. Supports:
Prefer to keep secrets elsewhere? A vault can also read them live from HashiCorp Vault (KV v2, token or AppRole) instead of storing them.
Your secrets never appear in logs, list responses, the audit log, or your git diff — only an administrator who clicks Reveal sees a value.
See docs.betterstatuspage.dev/vault for secret types, field mapping and where secrets can be used.
Let your team sign in with the identity provider they already use — Microsoft Entra ID, Keycloak, Okta, Google Workspace, Authentik, Auth0 or any other OIDC provider. Authorization Code flow with PKCE, state and nonce, ending in the same hardened server-side session as a password login.
.env edit, no restart. The secret is stored encrypted and never shown again.OIDC_FORCE_PASSWORD_LOGIN=true is the break-glass switch if your IdP goes down.OIDC_* environment variables, which then override and lock the UI form.See docs.betterstatuspage.dev/single-sign-on for setup, provider notes and troubleshooting.
The public status page isn't just a list of green dots. It's a fully customizable grid layout you design yourself — drag in monitor cards, group them by service (and drag them into or out of groups), add markdown text blocks, drop in an incident feed, resize everything, done. No CSS required.
See docs.betterstatuspage.dev/customizing-the-status-page for the builder blocks, branding, dark mode and translations.
Create incidents, set severity (minor → major → critical), link affected monitors, post real-time updates as the situation unfolds, and mark resolved when the dust settles. On the public page, an active minor incident marks its linked monitors as degraded, while major and critical incidents mark them as down. Resolving the incident restores the status reported by monitoring checks. A monitor can also open an incident by itself when it stays down for a set time, as a draft you publish or published at once, written from a default or custom template, and close it after recovery.
Notification channels wake up your team; subscriptions keep your users in the loop. Visitors subscribe from the status page by email or webhook, add the feed to a Slack channel with /feed subscribe, follow the RSS/Atom feed, or poll the JSON status API (summary.json, components.json).
See docs.betterstatuspage.dev/subscriptions for setup, delivery rules and the webhook payload.
Not every status page is for the whole internet. Switch on Users → Status page access → Private status page and only signed-in users see it. Everyone else gets a sign-in screen in your page's branding, with password and SSO sign-in.
private and noindexSee docs.betterstatuspage.dev/private-status-page for who can view a private page and what it switches off.
The status page shows 30 days; Monitoring → Reports in the admin console covers any date range you still have results for (90 days by default). See uptime, checks, average response time and incidents per monitor, and export a summary or a day-by-day CSV for SLA reviews.
See docs.betterstatuspage.dev/reports for how uptime is counted and the CSV columns.
Scheduled that 3 AM database migration? Let people know in advance instead of letting them think you're on fire.
Maintenance windows suppress alert noise for the duration of planned downtime — no more on-call pages for work you're doing on purpose. Create a window with a name, description, start/end time, and optionally scope it to specific monitors (or flip the switch for "all monitors"). While a window is active:
See docs.betterstatuspage.dev/incidents-and-maintenance for incident statuses, impact and exactly what a maintenance window silences.
Six months from now, someone will ask "who changed the check interval on the payments monitor from 30 seconds to 5 minutes last Thursday?" The answer is in the audit log.
Every mutation in the admin panel is recorded — who did it, when, and exactly what changed. For updates, you get a field-level diff with before and after values (sensitive fields like passwords are always redacted). Covered entities:
| What | Tracked operations |
|---|---|
| Monitors | Create, update (name, type, interval, timeout, retries, tags, config), delete |
| Incidents | Create, update (title, status, impact), delete |
| Maintenance windows | Create, update, delete |
| Notification channels | Create, update (name, type, enabled, config), delete |
| SMTP settings | Configure / update |
| Subscription settings | Configure or change methods, notification types and component scope |
| Subscribers | Delete (single or several at once) |
| Subscriber deliveries | Manual retry of a failed delivery |
| Vaults & secrets | Create, update (name, value change flagged as [redacted]), delete |
| Users | Create, role change, password reset, delete |
| API tokens | Created or revoked (never the token), and every change made with one, under the creator's email and the token's name |
| Import | Each import with its counts, and every monitor and channel it changed |
| User security | Enable or disable TOTP two-factor authentication, password changes, SSO account linking, revoked temporary passwords |
| SSO settings | Configure or change OpenID Connect settings |
| Branding, page builder, translations | Branding saves, layout saves, locale and translation changes |
| Backups | Backup created or deleted, automatic backup schedule changes |
| Notification deliveries | Manual retry of a failed delivery |
| Status page access | Make the status page private or public, viewer accounts from SSO and their email domains |
| Sign-ins | Every sign-in with a password or SSO: allowed, or denied with the reason (unknown email, wrong password, wrong 2FA code, refused by SSO…) |
The audit log page (admin-only) lets you filter by user, entity type, action (create / update / delete / allowed / denied), and date range. Click any row to expand the diff inline.
pl) ship with complete built-in copyNot every outage is what it looks like. When your API goes down because the database went down, you don't want three alerts — you want one, for the actual root cause.
BetterStatusPage lets you declare that monitor A depends on monitor B. When B goes down, A automatically shows Dependency Issue instead of Down — and its alert is suppressed, because B already fired it. Once B recovers, A returns to its normal status on the next check.
On the public status page, affected monitors display a "Caused by: Database" chip so visitors understand the hierarchy at a glance, not just a wall of red dots.
Configure it per monitor in the admin panel → monitor edit → Depends on tab.
Status changes propagate to both the admin dashboard and the public page instantly via Server-Sent Events. The moment a monitor flips from 🟢 to 🔴, everyone sees it. No polling, no page refresh, no "wait, is this stale?"
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌──────────────────┐ ┌──────────────────────────┐ │
│ │ Admin UI │ │ Public Status Page │ │
│ │ React 19 │ │ React 19 │ │
│ │ Vite · RQ · DnD│ │ SSE · i18n · dark mode │ │
│ └────────┬─────────┘ └────────────┬─────────────┘ │
└────────────┼───────────────────────────────┼────────────────┘
│ REST + SSE │ REST + SSE
┌────────────▼───────────────────────────────▼────────────────┐
│ Fastify 5 (Node.js 26+) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Admin API │ │ Public API │ │ Webhook API │ │
│ │ Auth + RBAC │ │ open/session │ │ token auth │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Background Workers │ │
│ │ │ │
│ │ Scheduler Notifier │ │
│ │ ├── HTTPS checker Vault resolver │ │
│ │ ├── Ping / TCP Result purger (daily) │ │
│ │ ├── DNS resolver │ │
│ │ └── DB checkers (SQL, PostgreSQL, MySQL, Mongo) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Drizzle ORM · SQLite (WAL mode) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
SQLite instead of Postgres — A status page for most teams doesn't need a database server. SQLite in WAL mode handles concurrent reads and writes without breaking a sweat, has zero ops overhead, and your entire database is a single file you can back up with cp. If your status page grows to the point where SQLite is a bottleneck, you have much bigger problems (and probably a dedicated ops team).
SSE instead of WebSockets — Status updates only ever flow server → client. SSE handles that perfectly, uses plain HTTP, needs nothing from a reverse proxy beyond turning buffering off, and auto-reconnects. Why bring a bazooka to a knife fight?
Monorepo with @bsp/shared — The API and both frontends share one TypeScript package for all domain types. Change a type in one place, the compiler yells at you everywhere it matters. No "oh we forgot to update the frontend types" post-mortems.
AES-256-GCM vault in-process — A random 12-byte IV per secret, authentication tag verification on decrypt, secrets never logged. Everything a small team needs for encrypted secrets without standing up another service.
| Layer | Technology | Version |
|---|---|---|
| Runtime | Node.js | 26+ |
| Language | TypeScript | 5.7 |
| Backend | Fastify | 5.x |
| Database | SQLite (node:sqlite) | built-in |
| ORM | Drizzle | 0.45 |
| Frontend | React | 19 |
| Build | Vite | 6.x |
| Styling | Tailwind CSS | 3.x |
| Data fetching | TanStack Query | 5.x |
| State | Zustand | 5.x |
| Drag & drop | dnd-kit | 6.x |
| Auth | Server-side sessions, HttpOnly JWT cookies, CSRF, TOTP, bcrypt, OpenID Connect (PKCE) | — |
| Nodemailer | 9.x | |
| SQL Server | mssql | 11.x |
| PostgreSQL | pg | 8.x |
| MySQL / MariaDB | mysql2 | 3.x |
| MongoDB | mongodb | 7.x |
| Scheduler | node-cron | 3.x |
Docker Compose with the published GHCR image is the recommended deployment path. No clone is needed — the server only needs docker-compose.yml and .env.
On a Linux server with Docker and Docker Compose v2:
curl -fsSL https://betterstatuspage.dev/install.sh | sh
The script downloads docker-compose.yml and .env.example, generates JWT_SECRET and VAULT_ENCRYPTION_KEY, pulls the image and starts the container on 127.0.0.1:3000. It installs into /opt/bsp as root and ~/bsp otherwise (override with BSP_DIR), and is safe to run again: an existing .env is never overwritten. Keep a copy of VAULT_ENCRYPTION_KEY — backups do not include it. Prefer to see every step? Do it manually:
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
Edit .env and set at least:
BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0
JWT_SECRET=<random 32+ char secret>
VAULT_ENCRYPTION_KEY=<64-char hex key>
Generate safe values (run twice, one per secret):
openssl rand -hex 32
Start the application:
docker compose pull
docker compose up -d
Open http://your-server:3000/admin for first-run setup. For internet-facing deployments, put Nginx/SSL in front and keep the application port private. See docs.betterstatuspage.dev/deployment.
node:sqlite module, so no ancient runtimesgit clone https://github.com/BElluu/BetterStatusPage.git
cd BetterStatusPage
npm install
npm run dev
Three things start:
| App | URL |
|---|---|
| API | http://localhost:3000 |
| Admin | http://localhost:5173 |
| Status page | http://localhost:5174 |
Open the admin URL and you'll be greeted by a setup wizard. Create your admin account, and you're in.
npm run lint # ESLint for API, shared types, and both React apps
npm test # API integration tests + frontend component tests
npm run test:api:coverage # API tests with enforced coverage thresholds
npm run test:coverage # frontend coverage report with enforced thresholds
npm run build # strict TypeScript and production builds for all workspaces
npm run backup # create a consistent database + uploads backup (after build)
Browser smoke tests use Playwright. Install Chromium once, then run the suite:
npx playwright install chromium
npm run test:e2e
The E2E suite starts from an empty instance every run (runtime data lives under .e2e/ and is wiped first), walks through the setup wizard, and then covers monitors, incidents, maintenance, email subscriptions, the page builder's drag and drop, role-based access and a private status page with a viewer account. GitHub Actions runs lint, tests, coverage, builds, Playwright E2E, and a production Docker smoke test on pull requests and pushes to main.
npm run build # builds the API and both frontends
npm start # starts the API, which serves them as static files
One process. One port. That's it.
Copy .env.example to .env:
PORT=3000
NODE_ENV=production
BSP_IMAGE=ghcr.io/belluu/better-status-page:0.4.0
BSP_BIND_ADDRESS=127.0.0.1
JWT_SECRET=something-long-random-and-secret
VAULT_ENCRYPTION_KEY=64-char-hex-string # see below
DATABASE_PATH=./data/db.sqlite
UPLOAD_DIR=./data/uploads
# Comma-separated list of allowed origins for CORS
ALLOWED_ORIGINS=https://status.example.com,https://admin.example.com
# Set only when port 3000 is reachable exclusively through one trusted reverse proxy
TRUST_PROXY=1
# Optional scheduler tuning
SCHEDULER_TICK_SECONDS=10
MONITOR_CHECK_CONCURRENCY=20
MONITOR_RESULT_RETENTION_DAYS=90
MONITOR_RESULT_PURGE_CRON=0 2 * * *
# Public URL of the status page — required for email and webhook subscriptions; all subscriber links use it,
# and SSO sign-ins started on a private page's sign-in screen return to it
PUBLIC_URL=https://status.example.com
# Optional: let subscriber webhooks reach internal addresses (intranet status pages only)
SUBSCRIBER_WEBHOOK_ALLOW_PRIVATE=false
🔑 Generate a vault encryption key:
openssl rand -hex 32Keep this somewhere safe. If it changes, all stored secrets become unreadable. Yes, all of them.
| Role | What they can do |
|---|---|
| admin | Everything, including users, single sign-on, vaults, audit log, backups and API tokens |
| operator | Monitors, incidents, maintenance, notifications, import, page builder, branding, localization, and settings |
| branding | Page builder, branding, localization, and account settings |
| viewer | Views a private status page; no access to the admin console |
Administrator sessions are stored server-side and authenticated with an HttpOnly, SameSite=Strict cookie. State-changing browser requests require a matching CSRF token. Users of the admin console can enable TOTP two-factor authentication from Settings and receive eight single-use recovery codes; it applies to password and SSO sign-ins alike. Administrators can also enable OpenID Connect single sign-on from Users → Single sign-on. Sensitive actions (sign-in settings, 2FA, password changes) are confirmed the way the session signed in: with the current password, or by signing in again at the identity provider in a pop-up.
Scripts and CI/CD can call the admin API with a token from Administration → API tokens, for example to open an incident from a pipeline. A token carries only the permissions you tick (read or write for monitors, notification channels, incidents, maintenance, subscribers and the page's appearance, read for reports, the audit log and system health, and a separate one for using vault secrets) and expires after 90 days unless you choose otherwise. It is shown once, and cannot manage users, single sign-on, status page access, vaults, backups or other tokens.
See docs.betterstatuspage.dev/api for the token rules and
curlexamples.
Every monitor, notification channel and action can be written as a YAML document with a kind, identified by a key instead of a numeric id and with the known secret fields masked, for version control and review. A YAML button on a monitor, a channel and an action shows that object; Import in the left menu (and the API) creates or updates objects from pasted or uploaded documents, with a preview before anything is written. Documents are checked completely, applied in one transaction, and applying them twice changes nothing. An import never deletes anything.
See docs.betterstatuspage.dev/configuration-as-code for the format and what is included.
server {
listen 443 ssl;
server_name status.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# SSE requires these — don't skip them
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
npm i -g pm2
pm2 start npm --name "bsp" -- start
pm2 save && pm2 startup
Download docker-compose.yml and .env.example (saved as .env) as shown in Quick production start, set BSP_IMAGE, JWT_SECRET and VAULT_ENCRYPTION_KEY in .env, then:
docker compose pull
docker compose up -d
Data (SQLite database + uploads) is stored in a named Docker volume (bsp_data) and survives container rebuilds, restarts, and image upgrades. It is only deleted if you explicitly run docker compose down -v.
To build the image from source instead, clone the repository and use the local override so the production Compose file continues to use GHCR:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --build
See docs.betterstatuspage.dev/deployment for the full deployment guide, including Nginx + SSL setup, bare-metal install, PM2, backups, and firewall configuration.
Before making an instance public:
NODE_ENV=production.JWT_SECRET with a random non-default value.VAULT_ENCRYPTION_KEY to a random 64-character hex value and store it outside the app.TRUST_PROXY=1 only when the app is reachable exclusively through your reverse proxy.BetterStatusPage/
├── apps/
│ ├── api/ # Fastify backend
│ │ └── src/
│ │ ├── db/ # Schema, migrations, seed
│ │ ├── routes/ # Endpoint handlers
│ │ ├── workers/ # Scheduler, checkers, notifier
│ │ ├── crypto/ # AES-256-GCM vault encryption
│ │ └── services/ # SSE broadcaster
│ ├── admin/ # Admin dashboard (React)
│ └── status/ # Public status page (React)
└── packages/
└── shared/ # Shared TypeScript types (@bsp/shared)
Found a bug? Have an idea? PRs are welcome — just open an issue first for anything bigger than a typo fix so we can discuss the approach.
git checkout -b feature/your-ideanpm run lint && npm test && npm run buildMIT — do whatever you want. Just please don't set the status page font to Comic Sans. 😅
BetterStatusPage is completely free and open source. If you find it useful and want to support continued development, voluntary donations are appreciated but never required!
Your support helps keep the project alive and motivates continued development — but using BetterStatusPage, testing it, and sharing feedback is support enough!