Skip to content

Lightweight, self-hosted status page written in Go

License

Notifications You must be signed in to change notification settings

msxdan/statusly

Repository files navigation

Statusly

Lightweight, self-hosted status page written in Go. Monitors HTTP endpoints, stores results as daily JSON files, and renders a clean status page inspired by Better Stack.

Release Docker Platforms Go License

Statusly screenshot

Features

  • HTTP health checks with configurable interval, timeout, and expected status code
  • 90-day uptime history displayed as a pill-shaped bar chart per endpoint
  • Sections to group endpoints by region or environment
  • Retry mechanism — on failure, retries up to 3 times at 5-second intervals before marking down
  • Degraded detection — services that recover on retry or exceed latency thresholds are marked degraded (yellow), not down
  • Timeout detection distinguishes timeouts from hard failures in logs
  • Status logic: green (all ok), yellow (degraded), red (down after all retries)
  • JSON file persistence — one file per day, atomic writes, no database required
  • Embedded assets — single binary with templates and CSS baked in via embed.FS
  • Page caching with automatic invalidation on new check results
  • Graceful shutdown on SIGINT/SIGTERM
  • TLS skip verify per endpoint for self-signed or incomplete certificate chains
  • Custom slugs to decouple display names from data storage keys
  • Structured logging with [UP], [DOWN], [TIMEOUT] prefixes
  • /health API endpoint — JSON endpoint for programmatic monitoring of all endpoints
  • Webhook notifications — alerts on state changes (up→down, down→up) with native Slack, Discord, and generic HTTP POST support
  • Multiple webhook URLs — send notifications to several webhooks simultaneously (e.g. Slack + Discord)
  • SVG status badges — embeddable shields.io-style badges per endpoint (/badge/{slug}.svg)
  • Latency display — current response time shown next to each endpoint's uptime percentage
  • Auto-refresh — page reloads every 30 seconds via meta refresh
  • Automatic data cleanup — JSON files older than 90 days are deleted on startup
  • Custom logo — display your own logo in the header from a local image file
  • Dark mode toggle — switch between light and dark themes, preference saved in localStorage
  • Hardened Docker image — multi-stage build with UPX compression, runs as nobody, read-only filesystem

Quick Start

Binary

cd src
go build -o statusly .
./statusly                    # uses ./config.yaml
./statusly /path/to/config.yaml

Docker Compose (recommended)

docker compose up -d --build

The app will be available at http://localhost:8080.

Configuration

Create a config.yaml in the project root:

title: "Statusly"
port: 8080
data_dir: "data"

sections:
  - name: "Production"
    endpoints:
      - name: "API"
        url: "https://api.example.com/health"
        interval: 30s
        timeout: 10s
        expected_status: 200
        latency_threshold: 2s

      - name: "Web App"
        url: "https://app.example.com"
        interval: 60s
        timeout: 10s
        expected_status: 200

  - name: "Staging"
    endpoints:
      - name: "API Staging"
        slug: "api-staging"
        url: "https://staging-api.example.com/health"
        interval: 30s
        timeout: 10s
        expected_status: 200
        skip_tls_verify: true

Global Options

Field Default Description
title "Statusly" Page title shown in the header
logo Path to a logo image file (replaces title text in header)
port 8080 HTTP server port
data_dir "data" Directory for JSON persistence files

The logo is loaded into memory at startup and served at /logo. In Docker, mount the file as a volume:

volumes:
  - ./logo.png:/logo.png:ro

Endpoint Options

Field Default Description
name Display name shown on the status page
slug Data storage key (auto-generated from name if empty)
url URL to check
interval 60s Time between checks (Go duration: 30s, 5m, etc.)
timeout 10s Request timeout
expected_status 200 HTTP status code that counts as "up"
skip_tls_verify false Skip TLS certificate verification
latency_threshold Response time threshold for degraded status (Go duration: 500ms, 2s)

Notifications

Configure webhook notifications to receive alerts when an endpoint changes state (up→down or down→up):

notifications:
  # Single webhook
  webhook_url: "https://hooks.slack.com/services/T.../B.../xxx"

  # Or multiple webhooks
  webhook_urls:
    - "https://hooks.slack.com/services/T.../B.../xxx"
    - "https://discord.com/api/webhooks/123/abc"
    - "https://your-server.com/webhook"

Both webhook_url and webhook_urls can be used together — they are merged and deduplicated.

Supported webhook formats (auto-detected from URL):

  • Slackhooks.slack.com URLs. Sends rich message with color-coded attachment (green/red) and block layout.
  • Discorddiscord.com/api/webhooks URLs. Sends embed with color and timestamp.
  • Generic HTTP POST — any other URL. Sends JSON with endpoint, status, previous_status, http_status, latency_ms, and timestamp fields.
Field Description
webhook_url Single webhook URL. Leave empty to disable.
webhook_urls Array of webhook URLs for multi-target notifications.

Health API

The /health endpoint returns a JSON summary of all monitored endpoints:

curl http://localhost:8080/health
{
  "status": "healthy",
  "endpoints": {
    "api": { "up": true, "status": 200, "latency_ms": 42 },
    "web-app": { "up": false, "status": 500, "latency_ms": 300 }
  }
}
  • status: "healthy" if all endpoints are up (or no data yet), "degraded" if any endpoint is down
  • HTTP status is always 200 — consumers decide how to interpret the JSON
  • Endpoints with no check data yet are omitted from the response

Status Badges

Each endpoint gets an embeddable SVG badge at /badge/{slug}.svg:

https://status.example.com/badge/api.svg
https://status.example.com/badge/web-app.svg

Embed in your README or documentation:

![API Status](https://status.example.com/badge/api.svg)

The badge shows the endpoint slug and current status (up in green, down in red, unknown in gray). Badges include Cache-Control: no-cache headers to always reflect current state.

Sections

Endpoints are grouped into sections, rendered as separate cards on the page. Use them to organize by region, environment, or service tier.

If you use the legacy flat endpoints key (without sections), they will be auto-migrated into a single "Monitors" section.

Slugs

By default, the data storage key is derived from the endpoint name (lowercased, spaces replaced with hyphens, non-alphanumeric characters stripped). Set slug explicitly to rename an endpoint without losing historical data:

- name: "API v2 (new name)"
  slug: "api" # keeps using old data
  url: "https://api.example.com"

Status Logic

On each check, if the initial request fails (error or wrong status code), Statusly retries up to 2 more times at 5-second intervals:

  • Up — first attempt succeeds with expected status code
  • Degraded — first attempt fails but a retry succeeds, or response time exceeds latency_threshold
  • Down — all 3 attempts fail

Each day in the 90-day uptime bar is color-coded:

Color Condition Tooltip
Green All checks passed "Operational"
Yellow Degraded events (no down checks) "Degraded (N events)"
Red Any down checks (all retries failed) "Down for X"
Gray No data "No data"

Down duration is calculated from the number of failed checks multiplied by the check interval.

The global status banner shows:

  • "All services are online" when all endpoints with data are up
  • "Some services are experiencing issues" when any endpoint is down
  • Endpoints with no data yet do not affect the global status

Architecture

src/
├── main.go           # Entrypoint, signal handling, wiring
├── config.go         # YAML config parsing, defaults, validation
├── checker.go        # HTTP health checker with retries and degraded detection
├── store.go          # JSON file persistence, in-memory summaries
├── server.go         # HTTP server, template rendering, page cache, /health API, /badge SVG
├── notifier.go       # Webhook notifications (Slack, Discord, generic, multi-target)
├── templates/
│   └── index.html    # Go html/template with embedded functions
├── static/
│   ├── style.css     # Styles (Better Stack-inspired design)
│   └── favicon.svg   # SVG favicon (green check mark)
├── go.mod
└── go.sum

Three-Layer Caching

The system is designed so that serving an HTTP request never touches the disk. There are three layers:

1. Store (in-memory) — The source of truth at runtime. Holds three maps:

  • current — last check result per endpoint (1 entry each)
  • summaries — 90 daily summaries per endpoint (just 5 integers: date, total, up, degraded, timeout)
  • todayResults — raw check results for the current day only

JSON files on disk are write-only during normal operation — they exist purely as persistence so data survives restarts. The only time files are read is store.Load(90) at startup.

2. HTML cache — The fully rendered page is stored as a []byte. When a checker reports a new result, it calls InvalidateCache() which sets a dirty flag. The next HTTP request re-renders the template from in-memory data and caches the result. Subsequent requests serve the cached bytes directly — no template execution, no data access.

3. Disk (JSON files) — One file per day, written atomically (tmp + rename) after each check. Never re-read after startup. Old files are only read during the initial 90-day load.

Data flow:

Checker.check()
  → store.Record()        (updates memory + writes today's JSON)
  → srv.InvalidateCache() (marks HTML cache dirty)

handleIndex()
  → dirty?  → yes → buildPage() from memory → cache HTML
  → dirty?  → no  → serve cached []byte directly

Memory Usage

Only today's raw results are kept in memory. The other 89 days are compressed into summaries (4 integers per day per endpoint). Estimated RAM usage:

Component 10 endpoints 100 endpoints
current (1 result/endpoint) ~320 B ~3 KB
summaries (90 days × 64 B) ~56 KB ~560 KB
todayResults (2880 results/day at 30s) ~900 KB ~9 MB
HTML cache ~100 KB ~1 MB
Go runtime + goroutines ~10 MB ~15 MB
Total ~12 MB ~25-30 MB

At 30s intervals, each endpoint generates 2,880 check results per day (~32 bytes each). The dominant cost is today's raw results. Scaling to 100 endpoints still fits comfortably under 30 MB.

Data Storage

Check results are persisted as JSON files, one per day:

data/
├── 2026-02-26.json
├── 2026-02-27.json
└── 2026-02-28.json

Each file maps endpoint slugs to arrays of check results:

{
  "api": [
    { "ts": 1740700800, "status": 200, "latency_ms": 42, "up": true },
    { "ts": 1740700830, "status": 200, "latency_ms": 85, "up": true, "degraded": true },
    { "ts": 1740700860, "status": 500, "latency_ms": 300, "up": false }
  ],
  "web-app": [{ "ts": 1740700800, "status": 200, "latency_ms": 15, "up": true }]
}

On startup, the last 90 days of data are loaded into memory to build day summaries. Files older than 90 days are automatically deleted during startup to prevent unbounded disk growth.

Auto-Refresh

The status page automatically reloads every 30 seconds via <meta http-equiv="refresh">. No JavaScript polling or WebSocket connection required.

Docker

Dockerfile

Multi-stage build: golang:1.25-bookworm for compilation, alpine:3.21 for UPX binary compression (~72% size reduction), and dhi.io/alpine-base:3.23 for the production image. The final image includes only the compressed static binary and CA certificates. Supports multi-platform builds (amd64 and arm64).

docker-compose.yaml

services:
  statusly:
    build: .
    ports:
      - "8080:8080"
    environment:
      - TZ=UTC
    volumes:
      - ./config.yaml:/config.yaml:ro
      - ./data:/data
    restart: unless-stopped
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL

Security hardening:

  • Read-only filesystem (read_only: true)
  • No new privileges (no-new-privileges)
  • All capabilities dropped (cap_drop: ALL)
  • Runs as nobody:nobody
  • Config mounted read-only

Set TZ to your local timezone so daily JSON filenames match your expectations.

Development

Run tests

cd src
go test -v ./...

Test coverage

cd src
go test -cover ./...

Build locally

cd src
go build -o ../statusly .

Rebuild and restart Docker

docker compose down && docker compose up -d --build

Logging

All check results are logged to stdout with structured prefixes:

[UP]       API: status 200 (42ms)
[DEGRADED] API: recovered after 1 retries, status 200 (85ms)
[DEGRADED] API: status 200, high latency 520ms (threshold 500ms)
[DOWN]     Web App: status 500, expected 200 after 3 retries (300ms)
[TIMEOUT]  API Staging: timed out after 10042ms (3 retries)
[ERROR]    API: failed to record check — write error

About

Lightweight, self-hosted status page written in Go

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors