Indie Status Page — Technical Design Document
1. Architecture Overview
Indie Status Page is a self-hosted, lightweight status page tool built for indie SaaS developers. It follows a minimal monolithic architecture with three concerns:
- REST API — FastAPI endpoints for programmatic incident & service management
- Public Status Page — Jinja2-rendered HTML pages (SSR, no JS framework)
- Background Workers — Uptime monitor (HTTP checks) and notification dispatcher
┌──────────────────────────────────────────────┐
│ FastAPI App │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ REST API │ │ Status │ │ Background│ │
│ │Endpoints │ │ Pages │ │ Scheduler │ │
│ │ /api/v1 │ │ /status/ │ │ (APScheduler)│
│ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────┴──────────────┴──────────────┴──────┐ │
│ │ SQLAlchemy + SQLite │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
│ │ │
HTTP clients Visitors SMTP / Webhooks
(API/CLI) (public) (notifications)
Design Principles
- Single binary, single process — FastAPI + APScheduler in one uvicorn process
- SQLite as the only store — zero-config, file-based, easy backups
- Server-rendered pages — Jinja2 templates with minimal CSS, no build step
- No paid dependencies — all OSS libraries, SMTP for email, SQLite for data
- Docker-first deployment — single container, volume-mount the DB file
2. Database Schema (SQLite via SQLAlchemy)
2.1 services
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
Unique service identifier |
| name |
VARCHAR(100) |
NOT NULL |
Display name (e.g. "API") |
| slug |
VARCHAR(50) |
NOT NULL, UNIQUE |
URL slug (e.g. "api") |
| description |
TEXT |
|
Optional one-liner |
| group_name |
VARCHAR(50) |
|
Grouping label (e.g. "Core") |
| position |
INTEGER |
DEFAULT 0 |
Sort order on status page |
| is_visible |
BOOLEAN |
DEFAULT TRUE |
Show on public page? |
| created_at |
DATETIME |
DEFAULT NOW |
|
| updated_at |
DATETIME |
DEFAULT NOW, on update |
|
2.2 incidents
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| service_id |
UUID |
FK → services.id |
Affected service |
| title |
VARCHAR(200) |
NOT NULL |
Incident title |
| status |
VARCHAR(20) |
NOT NULL |
investigating, identified, monitoring, resolved |
| severity |
VARCHAR(20) |
NOT NULL |
minor, major, outage |
| started_at |
DATETIME |
NOT NULL |
When incident began |
| resolved_at |
DATETIME |
NULLABLE |
When resolved |
| created_at |
DATETIME |
DEFAULT NOW |
|
| updated_at |
DATETIME |
DEFAULT NOW, on update |
|
2.3 incident_updates
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| incident_id |
UUID |
FK → incidents.id |
Parent incident |
| status |
VARCHAR(20) |
NOT NULL |
Status at time of update |
| body |
TEXT |
NOT NULL |
Update content (markdown) |
| created_at |
DATETIME |
DEFAULT NOW |
|
2.4 monitors
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| service_id |
UUID |
FK → services.id |
Monitored service |
| url |
VARCHAR(500) |
NOT NULL |
URL to check |
| method |
VARCHAR(10) |
DEFAULT "GET" |
HTTP method |
| expected_status |
INTEGER |
DEFAULT 200 |
Expected HTTP status |
| timeout_seconds |
INTEGER |
DEFAULT 10 |
Request timeout |
| interval_seconds |
INTEGER |
DEFAULT 60 |
Check interval |
| is_active |
BOOLEAN |
DEFAULT TRUE |
Enabled? |
| created_at |
DATETIME |
DEFAULT NOW |
|
| updated_at |
DATETIME |
DEFAULT NOW, on update |
|
2.5 monitor_results
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| monitor_id |
UUID |
FK → monitors.id |
|
| status |
VARCHAR(20) |
NOT NULL |
up, down, degraded |
| response_time_ms |
INTEGER |
|
Latency in ms |
| status_code |
INTEGER |
|
HTTP response code |
| error_message |
TEXT |
|
Error if failed |
| checked_at |
DATETIME |
NOT NULL |
When check ran |
2.6 subscribers
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| email |
VARCHAR(255) |
NOT NULL, UNIQUE |
Subscriber email |
| is_confirmed |
BOOLEAN |
DEFAULT FALSE |
Double-opt-in? |
| confirm_token |
VARCHAR(100) |
|
Email confirmation token |
| created_at |
DATETIME |
DEFAULT NOW |
|
2.7 notification_logs
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| incident_id |
UUID |
FK → incidents.id |
|
| subscriber_id |
UUID |
FK → subscribers.id |
|
| channel |
VARCHAR(20) |
NOT NULL |
email, webhook |
| status |
VARCHAR(20) |
NOT NULL |
sent, failed |
| created_at |
DATETIME |
DEFAULT NOW |
|
2.8 site_settings
| Column |
Type |
Constraints |
Description |
| id |
UUID |
PK, default=uuid4 |
|
| key |
VARCHAR(50) |
NOT NULL, UNIQUE |
Setting name |
| value |
TEXT |
|
Setting value (JSON-serializable) |
| updated_at |
DATETIME |
DEFAULT NOW, on update |
|
Pre-seeded settings: site_name, site_url, logo_url, accent_color, smtp_host, smtp_port, smtp_user, smtp_pass, notify_from, webhook_url.
3. API Endpoints
All API endpoints are versioned under /api/v1. Authentication uses a simple API key via X-API-Key header (stored in site_settings as admin_api_key).
3.1 Services
| Method |
Endpoint |
Description |
| GET |
/api/v1/services |
List all services |
| POST |
/api/v1/services |
Create a service |
| GET |
/api/v1/services/{id} |
Get a service |
| PATCH |
/api/v1/services/{id} |
Update a service |
| DELETE |
/api/v1/services/{id} |
Delete a service |
3.2 Incidents
| Method |
Endpoint |
Description |
| GET |
/api/v1/incidents |
List incidents (filterable) |
| POST |
/api/v1/incidents |
Create an incident |
| GET |
/api/v1/incidents/{id} |
Get incident + updates |
| PATCH |
/api/v1/incidents/{id} |
Update incident status |
| DELETE |
/api/v1/incidents/{id} |
Delete incident |
| POST |
/api/v1/incidents/{id}/updates |
Add an update |
3.3 Monitors
| Method |
Endpoint |
Description |
| GET |
/api/v1/monitors |
List all monitors |
| POST |
/api/v1/monitors |
Create a monitor |
| GET |
/api/v1/monitors/{id} |
Get monitor + recent results |
| PATCH |
/api/v1/monitors/{id} |
Update monitor |
| DELETE |
/api/v1/monitors/{id} |
Delete monitor |
| POST |
/api/v1/monitors/{id}/check |
Trigger manual check |
3.4 Subscribers
| Method |
Endpoint |
Description |
| GET |
/api/v1/subscribers |
List subscribers |
| POST |
/api/v1/subscribers |
Add subscriber |
| DELETE |
/api/v1/subscribers/{id} |
Remove subscriber |
| POST |
/api/v1/subscribers/{id}/confirm |
Confirm subscription |
3.5 Site Settings
| Method |
Endpoint |
Description |
| GET |
/api/v1/settings |
List all settings |
| PATCH |
/api/v1/settings |
Update settings |
3.6 Public Status Page (HTML)
| Method |
Endpoint |
Description |
| GET |
/ |
Status page (HTML) |
| GET |
/incident/{id} |
Incident detail (HTML) |
| GET |
/subscribe |
Subscribe form (HTML) |
| POST |
/subscribe |
Handle subscription |
| GET |
/confirm/{token} |
Confirm email (HTML) |
3.7 Health
| Method |
Endpoint |
Description |
| GET |
/health |
Health check |
4. File/Folder Structure
indie-status-page/
├── pyproject.toml # Project config, deps, scripts
├── README.md # Quick-start guide
├── TECHNICAL_DESIGN.md # This document
├── Dockerfile # Production container
├── docker-compose.yml # Local dev setup
├── .env.example # Environment variable template
├── alembic.ini # DB migration config
├── migrations/
│ └── versions/ # Alembic migration scripts
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app factory + lifespan
│ ├── config.py # Pydantic settings from env
│ ├── database.py # SQLAlchemy engine, session, Base
│ ├── dependencies.py # FastAPI deps (DB session, auth)
│ ├── models/
│ │ ├── __init__.py # Re-exports all models
│ │ ├── service.py # Service model
│ │ ├── incident.py # Incident + IncidentUpdate models
│ │ ├── monitor.py # Monitor + MonitorResult models
│ │ ├── subscriber.py # Subscriber model
│ │ ├── notification.py # NotificationLog model
│ │ └── settings.py # SiteSettings model
│ ├── api/
│ │ ├── __init__.py
│ │ ├── router.py # v1 router aggregation
│ │ ├── services.py # Service CRUD endpoints
│ │ ├── incidents.py # Incident CRUD + updates
│ │ ├── monitors.py # Monitor CRUD + manual check
│ │ ├── subscribers.py # Subscriber management
│ │ └── settings.py # Settings endpoints
│ ├── services/
│ │ ├── __init__.py
│ │ ├── uptime.py # HTTP check logic
│ │ ├── notifier.py # Email (SMTP) + webhook dispatch
│ │ └── scheduler.py # APScheduler job registration
│ ├── templates/
│ │ ├── base.html # Layout template
│ │ ├── status.html # Public status page
│ │ ├── incident.html # Incident detail page
│ │ ├── subscribe.html # Subscribe form
│ │ └── confirm.html # Confirmation page
│ └── static/
│ ├── css/
│ │ └── style.css # Minimal responsive styles
│ └── js/
│ └── status.js # Auto-refresh (optional)
├── tests/
│ ├── __init__.py
│ ├── conftest.py # Fixtures, test DB
│ ├── test_api_services.py
│ ├── test_api_incidents.py
│ ├── test_api_monitors.py
│ ├── test_api_subscribers.py
│ ├── test_health.py
│ └── test_services_uptime.py
└── scripts/
└── cli.py # CLI tool for managing incidents
5. Core Models (Pydantic Schemas)
Service
class ServiceCreate(BaseModel):
name: str = Field(..., max_length=100)
slug: str = Field(..., max_length=50, pattern=r"^[a-z0-9-]+$")
description: str | None = None
group_name: str | None = Field(None, max_length=50)
position: int = 0
is_visible: bool = True
class ServiceRead(ServiceCreate):
id: UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
Incident
class IncidentCreate(BaseModel):
service_id: UUID
title: str = Field(..., max_length=200)
status: Literal["investigating", "identified", "monitoring", "resolved"]
severity: Literal["minor", "major", "outage"]
started_at: datetime | None = None # defaults to now
class IncidentUpdate(BaseModel):
status: Literal["investigating", "identified", "monitoring", "resolved"]
body: str
Monitor
class MonitorCreate(BaseModel):
service_id: UUID
url: HttpUrl
method: Literal["GET", "POST", "HEAD"] = "GET"
expected_status: int = 200
timeout_seconds: int = Field(10, ge=1, le=60)
interval_seconds: int = Field(60, ge=30, le=3600)
Subscriber
class SubscriberCreate(BaseModel):
email: EmailStr
6. MVP Feature List (Prioritized)
P0 — Ship Day 1 (Must Have)
| # |
Feature |
Description |
| 1 |
Service CRUD |
Add/edit/delete services to track |
| 2 |
Incident CRUD + Updates |
Create incidents, post updates, resolve |
| 3 |
Public Status Page |
SSR status page with current service status |
| 4 |
Uptime Monitoring (HTTP) |
Periodic HTTP GET checks, store results |
| 5 |
Status Derivation |
Auto-derive service status from monitors |
| 6 |
Health Check Endpoint |
/health for container orchestration |
| 7 |
API Key Auth |
Simple X-API-Key header for protected routes |
| 8 |
Docker Setup |
Dockerfile + docker-compose |
P1 — Ship Day 3-4 (Should Have)
| # |
Feature |
Description |
| 9 |
Subscriber Signup + Confirmation |
Email opt-in with double confirmation |
| 10 |
Email Notifications (SMTP) |
Send incident updates to subscribers |
| 11 |
Webhook Notifications |
POST to external URL on incidents |
| 12 |
CLI Tool |
Terminal commands for incident management |
| 13 |
90-Day Uptime Calculator |
Uptime % from monitor_results for each service |
P2 — Post-MVP (Nice to Have)
| # |
Feature |
Description |
| 14 |
RSS Feed |
/feed.xml for incident history |
| 15 |
Custom Domain Support |
CNAME-friendly setup guide |
| 16 |
Status Badge |
SVG badge embed for READMEs |
| 17 |
Scheduled Maintenance Windows |
Planned downtime with auto-resolve |
| 17 |
Multi-language UI |
i18n via Jinja2 templates |
| 18 |
Slack/Discord Integration |
Bot-based notifications |
Technical Decisions & Rationale
| Decision |
Choice |
Rationale |
| Web Framework |
FastAPI |
Async support, auto-docs, type validation |
| ORM |
SQLAlchemy 2.0 |
Mature, async-capable, SQLite-friendly |
| Database |
SQLite |
Zero-config, single file, perfect for MVP |
| Migrations |
Alembic |
Standard for SQLAlchemy, easy schema evolution |
| Task Scheduler |
APScheduler |
In-process, no Redis/Celery needed |
| Templates |
Jinja2 |
Built into FastAPI, no build step |
| Email |
stdlib smtplib |
No paid service, any SMTP relay works |
| Settings |
pydantic-settings |
Env-based config, type validation, .env support |
| Testing |
pytest + httpx |
Standard Python testing with async ASGI client |
| CLI |
Typer |
Intuitive CLI with auto-help, same dep as FastAPI |