Self-hosted uptime monitoring and status pages — configured entirely in TypeScript
Next.js · React · Bun · Drizzle ORM · SQLite/PostgreSQL
Quick Start · Monitors · Alerts · Dashboards · Deployment
Define monitors, dashboards, alerts, incidents, and announcements as TypeScript files and Markdown — version-controlled alongside your code. No UI forms. Just code.
- ⚡ Uptime monitoring with configurable intervals and cron schedules
- 📊 Public status pages with RSS and Atom feeds
- 📉 Response time charts, uptime bars, and latency percentiles
- 📈 SLA tracking and status distribution
- 🕐 Incident timelines and announcements
- 🗄️ S3 archival to Parquet with day-based partitioning
- 🔔 Smart alerting with flap detection and auto-resolve
- 🌍 Multi-region monitoring with region-aware alert thresholds
git clone https://github.com/TimMikeladze/pongo.git
cd pongo
cp .env.example .env # configure environment variables
bun install
bun run db:sqlite:migrate # create tables
bun devOpen http://localhost:3000.
pongo.sh uses SQLite by default, but you can use PostgreSQL instead by setting the DATABASE_URL environment variable:
# .env
DATABASE_URL=postgres://user:password@localhost:5432/pongoThen run the PostgreSQL migration and start the dev server:
bun run db:pg:migrate
bun devThe database driver is auto-detected from the connection string. See the Database section for all supported backends.
Start the scheduler in a second terminal:
bun schedulerpongo/ # Your configuration (version-controlled)
├── monitors/ # Monitor definitions (*.ts)
├── dashboards/ # Dashboard configs (*.ts)
├── channels.ts # Webhook notification channels
├── announcements/ # Status announcements (*.md)
└── incidents/ # Incident reports (*.md)
src/
├── app/ # Next.js App Router
├── scheduler/ # Standalone monitor runner (Hono HTTP API)
├── archiver/ # S3 data archival service
├── components/ # React UI (Radix UI + Tailwind + Recharts)
├── db/ # Drizzle ORM schema (SQLite/PostgreSQL)
├── lib/ # Core business logic, types, loaders
└── proxy.ts # iron-session auth middleware
Create TypeScript files in pongo/monitors/. Each exports a monitor() config.
// pongo/monitors/api.ts
import { monitor } from "../../src/lib/config-types";
export default monitor({
name: "API Health",
interval: "5m",
timeout: "30s",
async handler() {
const start = Date.now();
const res = await fetch("https://api.example.com/health");
return {
status: res.ok ? "up" : "down",
responseTime: Date.now() - start,
statusCode: res.status,
};
},
});export default monitor({
name: "Latency Sensitive",
interval: "1m",
timeout: "10s",
async handler() {
const start = Date.now();
const res = await fetch("https://api.example.com");
const responseTime = Date.now() - start;
return {
status: !res.ok ? "down" : responseTime > 2000 ? "degraded" : "up",
responseTime,
statusCode: res.status,
};
},
});export default monitor({
name: "Vercel Status",
interval: "15m",
timeout: "30s",
async handler() {
const start = Date.now();
const res = await fetch("https://www.vercelstatus.com/api/v2/status.json");
const data = await res.json() as { status: { indicator: string } };
const indicator = data.status.indicator;
return {
status: indicator === "none" ? "up" : indicator === "minor" ? "degraded" : "down",
responseTime: Date.now() - start,
statusCode: res.status,
};
},
});Register monitors in pongo/monitors/index.ts:
import api from "./api";
import vercel from "./vercel";
export default { api, vercel };| Field | Type | Required | Description |
|---|---|---|---|
status |
"up" | "down" | "degraded" |
yes | Current service status |
responseTime |
number |
yes | Milliseconds |
statusCode |
number |
no | HTTP status code |
message |
string |
no | Additional context |
interval: "30s" // every 30 seconds
interval: "5m" // every 5 minutes
interval: "1h" // every hour
interval: "*/5 * * * *" // cron expressionAttach alerts to any monitor. Alerts evaluate conditions, fire webhooks to channels, and auto-resolve.
export default monitor({
name: "API",
interval: "1m",
alerts: [
{
id: "api-down",
name: "API Down",
condition: { consecutiveFailures: 3 },
channels: ["slack"],
severity: "critical", // "critical" | "warning" | "info"
regionThreshold: "majority", // "any" | "all" | "majority" | number
escalateAfterMs: 300_000, // re-notify after 5 min if still firing
},
],
async handler() { /* ... */ },
});Declarative:
{ consecutiveFailures: 3 }
{ consecutiveSuccesses: 2 }
{ latencyAboveMs: 1000, forChecks: 5 }
{ status: "down", forChecks: 3 }
{ downForMs: 60000 }
{ upForMs: 30000 }Callback — full access to check history:
condition: (result, history) =>
history.filter(r => r.status === "down").length >= 3If an alert toggles 3+ times in 10 minutes, notifications are suppressed until the state stabilizes.
Silence alerts from the dashboard UI or programmatically via silenceAlert(alertId, until) / unsilenceAlert(alertId).
{
event: "alert.fired" | "alert.resolved",
alert: { id, name, monitorId, monitorName, severity },
timestamp: string, // ISO 8601
snapshot: {
consecutiveFailures: number,
consecutiveSuccesses: number,
lastStatus: string,
lastResponseTimeMs: number | null,
lastMessage: string | null,
},
checkResult: { id, status, responseTimeMs, message, checkedAt },
region?: string,
firingRegions?: string[],
healthyRegions?: string[],
}Webhooks retry with exponential backoff on failure.
Define webhook endpoints in pongo/channels.ts:
import { channels } from "../src/lib/config-types";
export default channels({
slack: {
type: "webhook",
url: process.env.SLACK_WEBHOOK_URL!,
},
pagerduty: {
type: "webhook",
url: "https://events.pagerduty.com/v2/enqueue",
headers: { Authorization: "Token token=..." },
},
});Define dashboards in pongo/dashboards/. Group monitors, set SLA targets, and optionally expose as public status pages.
// pongo/dashboards/production.ts
import type { DashboardConfig } from "@/lib/config-types";
export default {
name: "Production",
slug: "production",
public: true,
slaTarget: 99.9,
monitors: ["api", "database", "cdn"],
} satisfies DashboardConfig;Register in pongo/dashboards/index.ts:
import production from "./production";
export default { production };Public dashboards are accessible without authentication. Each includes:
- Response time, uptime, and error rate charts
- Latency percentiles (P50, P95, P99)
- Uptime bars and status distribution
- Incident timeline and announcements
- RSS (
/shared/[slug]/feed.xml) and Atom (/shared/[slug]/feed.atom) feeds
Markdown files in pongo/announcements/:
---
dashboard: production
title: Scheduled Maintenance
type: maintenance
expiresAt: 2025-12-08T15:00:00Z
---
Database maintenance from 2-3 PM UTC.Markdown files in pongo/incidents/:
---
title: Payment Gateway Outage
severity: critical
status: resolved
startedAt: 2025-12-01T10:00:00Z
resolvedAt: 2025-12-01T11:30:00Z
---
Root cause: upstream provider network partition.| Route | Description |
|---|---|
/ |
Overview dashboard |
/monitors |
Monitor list with filters |
/alerts |
Alert management and history |
/dashboards/[id] |
Dashboard detail (monitors, incidents, announcements) |
/shared/[slug] |
Public status page (no auth) |
/shared/[slug]/feed.xml |
RSS feed |
/shared/[slug]/feed.atom |
Atom feed |
/settings |
Application settings |
/api/status.json |
System status JSON |
/api/cron |
Vercel cron endpoint |
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Next.js App │────>│ Database │<────│ Scheduler(s) │
│ (Dashboard UI) │ │ (SQLite/PG) │ │ (Monitor Runner) │
└─────────────────┘ └──────────────┘ └─────────────────┘
All services share one database. No message queues or service mesh required.
| Scenario | Dashboard | Scheduler | Best for |
|---|---|---|---|
| Vercel | Vercel | Vercel Cron | Serverless, auto-scaling |
| Fly.io | Fly.io | Fly.io | Persistent VMs, multi-region |
| Hybrid | Vercel | VPS/Docker | Global CDN + flexible scheduling |
| Self-hosted | Docker | Docker | Full control |
- Click "Deploy with Vercel" above
- Set
DATABASE_URLandCRON_SECRET(openssl rand -base64 32)
fly launch
fly secrets set DATABASE_URL="postgres://..." ACCESS_CODE="secret"
fly deployThe included Dockerfile and fly.toml handle everything. The Docker entrypoint auto-starts the scheduler and archiver when SCHEDULER_ENABLED=true and ARCHIVAL_ENABLED=true.
services:
pongo:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgres://postgres:password@db:5432/pongo
ACCESS_CODE: your-password
depends_on: [db]
scheduler:
build: .
command: ["bun", "scheduler"]
environment:
DATABASE_URL: postgres://postgres:password@db:5432/pongo
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: pongo
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Deploy schedulers in different regions pointing at the same database:
PONGO_REGION=us-east bun scheduler # Region 1
PONGO_REGION=eu-west bun scheduler # Region 2Configure regionThreshold on alerts to control when they fire across regions.
Standalone Bun process. Runs monitors on schedule, evaluates alert conditions, dispatches webhooks with retry logic.
bun schedulerHTTP API (default port 3001):
| Endpoint | Method | Description |
|---|---|---|
/health |
GET | Health check with region info |
/monitors |
GET | List monitors with state |
/monitors/:id |
GET | Single monitor state |
/monitors/:id/trigger |
POST | Trigger single monitor |
/monitors/trigger |
POST | Trigger all monitors |
Retries failed checks with exponential backoff (configurable via SCHEDULER_MAX_RETRIES, SCHEDULER_RETRY_DELAY_MS).
Archives old check results to S3 as Parquet files with day-based partitioning (year=YYYY/month=MM/day=DD/).
bun archiverConfigure with ARCHIVAL_RETENTION_DAYS (default 30), ARCHIVAL_CRON (default 0 3 * * *), ARCHIVAL_BATCH_SIZE (default 10000).
Auto-detected from DATABASE_URL:
| Backend | DATABASE_URL |
Notes |
|---|---|---|
| SQLite | file:./pongo/pongo.db (default) |
WAL mode, zero config |
| PostgreSQL | postgres://user:pass@host:5432/pongo |
Production recommended |
Migrations run automatically on build. Manual:
bun run db:pg:migrate # PostgreSQL
bun run db:sqlite:migrate # SQLite
bun run db:pg:studio # Open Drizzle StudioBy default, the dashboard is open to anyone who can reach it. To password-protect it, set the ACCESS_CODE environment variable:
# .env or your hosting provider's environment settings
ACCESS_CODE=your-secret-passwordWhen ACCESS_CODE is set:
- All dashboard routes require authentication
- Visitors are redirected to
/loginand must enter the access code - Sessions are stored as encrypted cookies (iron-session) and last 7 days by default
- Adjust session duration with
EXPIRY_DAYS
Public routes (always accessible, even with auth enabled): /, /shared/*, /login, /api/*
| Variable | Description | Default |
|---|---|---|
DATABASE_URL |
Database connection string | file:./pongo/pongo.db |
DB_DRIVER |
sqlite or pg (auto-detected) |
sqlite |
ACCESS_CODE |
Dashboard login password | - (no auth) |
EXPIRY_DAYS |
Session TTL in days | 7 |
PONGO_REGION |
Region identifier | default |
SCHEDULER_PORT |
Scheduler API port | 3001 |
SCHEDULER_MAX_CONCURRENCY |
Parallel monitor executions | 10 |
SCHEDULER_MAX_RETRIES |
Retries on check failure | 3 |
SCHEDULER_RETRY_DELAY_MS |
Base retry delay (ms) | 5000 |
SCHEDULER_URL |
Scheduler URL (enables manual runs from UI) | - |
SCHEDULER_ENABLED |
Auto-start scheduler in Docker | false |
ENABLE_MANUAL_RUN |
Show manual run button in dashboard | false |
CRON_SECRET |
Auth token for Vercel cron endpoint | - |
ARCHIVAL_ENABLED |
Enable data archival | false |
ARCHIVAL_RETENTION_DAYS |
Days before archiving | 30 |
ARCHIVAL_CRON |
Archival schedule | 0 3 * * * |
ARCHIVAL_BATCH_SIZE |
Rows per batch | 10000 |
ARCHIVAL_LOCAL_PATH |
Local archive path | ./archives |
ARCHIVER_PORT |
Archiver API port | 3002 |
S3_BUCKET |
S3 bucket for archives | - |
S3_REGION |
S3 region | - |
S3_ACCESS_KEY_ID |
S3 access key | - |
S3_SECRET_ACCESS_KEY |
S3 secret key | - |
S3_PREFIX |
S3 key prefix | - |
NEXT_PUBLIC_URL |
Public URL for SEO/metadata | - |
NEXT_PUBLIC_SITE_NAME |
Custom site name (metadata, page titles, footer) | pongo.sh |
NEXT_PUBLIC_HIDE_GITHUB |
Hide the GitHub icon in the footer | - |
NEXT_PUBLIC_HIDE_DOCS |
Hide the docs link in the appbar | - |
NEXT_PUBLIC_HIDE_SUPPORT |
Hide the support icon in the footer | - |
NEXT_PUBLIC_FAVICON |
Custom favicon image URL (e.g. /custom-favicon.png) |
/logo.png |
NEXT_PUBLIC_FOOTER_LOGO |
Custom footer logo image URL (e.g. /custom-logo.png) |
pongo logo |
NEXT_PUBLIC_FOOTER_TITLE |
Custom footer title text | pongo.sh |
NEXT_PUBLIC_FOOTER_CAPTION |
Custom footer caption text | open-source uptime monitoring |
NEXT_PUBLIC_TIME_RANGE_PRESETS |
Comma-separated time range presets for the dashboard picker | 1h,24h,7d,30d,90d,180d,360d |
NEXT_PUBLIC_INTERVAL_OPTIONS |
Comma-separated interval options for chart data aggregation | 15m,30m,1h,24h,3d,7d,30d |
NEXT_PUBLIC_DEFAULT_PRESET |
Default time range preset | 24h |
NEXT_PUBLIC_DEFAULT_INTERVAL |
Default chart aggregation interval | 15m |
| Command | Description |
|---|---|
bun dev |
Start Next.js dev server |
bun run build |
Build for production (auto-runs migrations) |
bun start |
Start production server |
bun scheduler |
Start scheduler service |
bun archiver |
Start archiver service |
bun test |
Run tests |
bun test:watch |
Run tests in watch mode |
bun run lint |
Lint with Biome |
bun run lint:fix |
Lint and auto-fix |
bun run check |
Lint + typecheck + test |
bun run db:pg:studio |
Open Drizzle Studio (PostgreSQL) |
bun run db:sqlite:studio |
Open Drizzle Studio (SQLite) |
Next.js, React, Bun, Drizzle ORM, Tailwind CSS, Radix UI, Recharts, Croner, Hono, iron-session, Zod, Biome.