Self-hosted uptime monitoring and status pages — configured entirely in TypeScript
Next.js · React · Bun · Drizzle ORM · SQLite/PostgreSQL
Quick Start · Agent Setup · 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
Paste this into your coding agent:
https://pongo.sh
Create a status page for Acme.
Your agent reads the agent instructions, asks which services to monitor and their URLs, then configures Pongo and verifies the running page. Supply the URLs in your prompt to skip that question:
https://pongo.sh
Create a status page for Acme monitoring https://acme.example and
https://api.acme.example/health. Check every minute and run it locally.
The default is a local SQLite setup with a public status page and a separate scheduler. Request Vercel, Fly.io, Docker, or your own server when you want it deployed. Hosting access and a database are required for deployment; pongo.sh does not host new instances.
See the agent setup guide for follow-up questions and optional installation of the create-pongo skill.
Local review data shown for illustration.
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.
Monitoring also requires the scheduler described below. The bundled public status page is at http://localhost:3000/shared/status.
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
cron: "*/5 * * * *" // cron expression (use instead of interval)Attach 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 };Registry object keys are the monitor and dashboard IDs. Dashboard monitors entries must match the keys in pongo/monitors/index.ts. A file is loaded only after it is registered. Restart the scheduler after changing configuration; rebuild the web app for 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]/incidents |
Public incident history |
/shared/[slug]/maintenance |
Public maintenance schedule |
/shared/[slug]/announcements |
Public updates |
/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)
Use a remote PostgreSQL or libSQL database. The included vercel.json invokes /api/cron every 30 minutes; a monitor's shorter interval does not make cron run more often. Choose a provider-supported faster schedule or an external scheduler if needed. Set the build command to the selected migration command followed by bunx next build (see Database).
Replace the pongo-production app name in fly.toml with your own app name before deploying. The commands below use PostgreSQL; for a single-host SQLite instance, use DATABASE_URL=file:/data/pongo.db on the included /data volume. Run the selected database migration before starting the app.
fly launch
fly secrets set DATABASE_URL="postgres://..." ACCESS_CODE="secret" SCHEDULER_ENABLED=true
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.
Run the selected database migration in a one-off container before starting the app and scheduler. The Docker entrypoint does not apply migrations. Both services must use the same database; SQLite requires a shared persistent volume.
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.
Overview puts affected monitors and firing alerts before performance charts. Search and filter monitors by status; the URL preserves those filters when reloading or sharing the page. Expand Performance for the response, availability, error, latency, throughput and status-distribution charts.
Public status pages keep Status, Incidents, Maintenance and Updates in the header. Availability bars support pointer inspection and arrow-key navigation, with Home and End moving to either end. Long histories group adjacent periods so bars remain visible on phones; tooltips show the covered intervals and measured checks. The sparkline retains every interval. Missing measurements display an em dash instead of assuming 100% availability or zero response time.
Settings controls theme, content width and view mode. The header’s More menu (hamburger icon) also provides width and zen controls. Mobile status history also provides an inspector with a period slider and Previous/Next controls. Width applies to the header, content and footer together. Press Escape or use Exit zen to restore navigation. Preferences are saved in the browser.
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 |
| Remote libSQL | libsql://your-db.turso.io |
Set TURSO_AUTH_TOKEN; use a turso migration config |
Run migrations explicitly for the selected backend:
bun run db:pg:migrate # PostgreSQL
bun run db:sqlite:migrate # SQLite
bun run db:pg:studio # Open Drizzle StudioThe current bun run build script chooses SQLite migrations only for a DATABASE_URL starting with sqlite; it incorrectly chooses PostgreSQL for file:/libsql: URLs or an unset URL. After running the correct migration, build with bunx next build and start with bun start. Docker also needs an explicit migration before startup.
For remote libSQL, the bundled SQLite migration config does not pass TURSO_AUTH_TOKEN. Use a Drizzle config with dialect: "turso", the SQLite schema and migrations directory, and dbCredentials containing url and authToken; see the setup skill for the configuration and command.
By 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/*, /llms.txt, /skills/create-pongo/SKILL.md. API endpoints enforce their own authentication where required.
| 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 (header, metadata, page titles, footer) | pongo.sh |
SHOW_HOW_IT_WORKS |
Show the README tab and page; set true on the Pongo Vercel production deployment, or false to hide it in development |
true in development; false in production |
NEXT_PUBLIC_HEADER_CAPTION |
Secondary line beneath the header brand | Demo deployment |
NEXT_PUBLIC_GITHUB_URL |
Repository URL shown as a GitHub link in the footer; omitted when unset | - |
NEXT_PUBLIC_HIDE_GITHUB |
Set to true to hide the configured footer GitHub link |
- |
NEXT_PUBLIC_HIDE_DOCS |
Hide Documentation in the header More menu | - |
NEXT_PUBLIC_HIDE_SUPPORT |
Hide Support in the header More menu | - |
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 |
bunx next build |
Build for production after explicit database migrations |
bun run build |
Migrate and build; see the database-selection limitation above |
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.

