Self-hosting
Environment variables, reverse proxy setup, backups, upgrades, and data retention.
PingBoard is one container with one SQLite file. Everything it needs lives under a single volume mounted at /data.
Environment variables
All optional — no env vars are required to boot.
| Variable | Default | What it does |
|---|---|---|
PORT | 3000 | HTTP port |
DATA_DIR | /data | SQLite database location |
PINGBOARD_BASE_URL | (auto) | Used in alert links — set to your public URL when behind a reverse proxy |
PINGBOARD_TRUST_PROXY | off | Set to true behind a reverse proxy so rate limiting keys on X-Forwarded-For instead of the proxy's address |
PINGBOARD_PUBLIC_RATE_LIMIT | 60 | Public API requests per minute per client IP |
LOG_LEVEL | info | debug / info / warn / error |
Two more variables exist but are managed by the Docker image (PINGBOARD_STATIC_DIR, PINGBOARD_MIGRATIONS_DIR) — don't set them yourself.
The container exposes port 3000, declares a volume at /data, and ships a healthcheck against GET /_health (returns ok) — useful for Docker's own health status or an external watchdog.
Reverse proxy
PingBoard does not handle TLS itself. Put it behind Caddy, nginx, or Traefik with a normal HTTP-to-app proxy, and set PINGBOARD_BASE_URL to the public origin so alert links and status-page share previews point at the right host.
Caddy:
status.example.com {
reverse_proxy pingboard:3000
}nginx:
server {
listen 443 ssl;
server_name status.example.com;
location / {
proxy_pass http://pingboard:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Live updates use Server-Sent Events — don't buffer them.
proxy_buffering off;
}
}When the app sits behind a proxy, set PINGBOARD_TRUST_PROXY=true and make sure the proxy forwards X-Forwarded-For (the nginx example above does). The public API is rate-limited per client IP, and PingBoard only trusts the header when you tell it to — otherwise every visitor looks like the proxy and shares one rate-limit bucket. To serve a status page on a dedicated hostname, see Status pages.
Network access
Monitors and webhooks make outbound requests to whatever URLs you configure — including internal addresses like 192.168.x.x or 169.254.169.254, and redirects are followed. For a self-hosted monitor that's a feature (watching intranet services is half the point), but treat admin access accordingly: anyone who can create a monitor or alert channel can make the server reach anything on your network.
Backups
Everything is in /data: pingboard.db plus its WAL file. Stop the container, copy the files, restart:
docker stop pingboard
cp -r /var/lib/docker/volumes/pingboard /backups/pingboard-$(date +%F)
docker start pingboardCopy both pingboard.db and pingboard.db-wal if present — the WAL holds recent writes. Restoring is the reverse: put the files back in the volume and start the container.
Upgrades
Pull the new image and recreate the container. Database migrations run automatically at boot:
docker pull ghcr.io/steiner-co/pingboard:latest
docker stop pingboard && docker rm pingboard
docker run -d --restart=always \
-p 3000:3000 \
-v pingboard:/data \
--name pingboard \
ghcr.io/steiner-co/pingboard:latestWith Compose: docker compose pull && docker compose up -d. Releases are tagged on GHCR as x.y.z, x.y, x, and latest — pin to a minor (ghcr.io/steiner-co/pingboard:1.2) if you'd rather upgrade deliberately.
Data retention
Raw heartbeats don't accumulate forever. A background job (runs hourly, plus a sweep shortly after boot) rolls each completed day up into per-day statistics — uptime percentage and average response time — and deletes raw rows once they pass the retention window. Uptime history effectively lives forever as daily rollups; only the per-check granularity expires.
The window is configurable in Settings: 7, 30 (default), 60, 90, 180, or 365 days. The settings screen also reports the database size, the oldest raw heartbeat on file, and row counts, so you can see retention doing its job.
