Build an Uptime Monitoring Service with Go and PostgreSQL

Build and deploy a practical uptime monitoring service with Go, PostgreSQL, Docker and alert webhooks: monitors, scheduled checks, incident state and a minimal dashboard API.

Build a small UptimeRobot-style service that checks HTTP endpoints, records response times, tracks incidents and sends webhook alerts. The stack is deliberately boring: one Go application, PostgreSQL and Docker Compose. Verified 2026-08-21.

What we’re building

The finished service has:

  • HTTP monitors with an interval and timeout.
  • A worker that claims due monitors safely from PostgreSQL.
  • Response status, latency and error history.
  • Incident open/recovery transitions.
  • Generic webhook notifications.
  • JSON endpoints suitable for a dashboard or CLI.
  • A health endpoint and Docker deployment.

This is an MVP, not a replacement for Prometheus. It is useful when you need external checks for a few websites, APIs or customer endpoints.

flowchart LR
    Client --> API[Go API]
    API --> DB[(PostgreSQL)]
    Worker[Check worker] --> DB
    Worker --> Target[HTTP target]
    Worker --> Webhook[Alert webhook]

What you’ll learn

  • How to build an HTTP API with Go’s standard library.
  • How to use parameterized PostgreSQL queries with pgx.
  • How to claim scheduled work without duplicate checks.
  • How to model incidents as state transitions.
  • How to deploy the service with Docker Compose and Caddy.

Prerequisites

  • Go 1.24.
  • Docker Engine and Compose v2.
  • A PostgreSQL 17 instance, supplied locally by Compose or remotely.
  • A VPS with 1 GB RAM and a public DNS record for production.

Project overview

The API creates monitors and lists their latest status. A background worker wakes every five seconds, claims monitors whose next_check_at is due, performs an outbound request, then stores the result. PostgreSQL is the source of truth; no Redis or message broker is required.

The worker uses FOR UPDATE SKIP LOCKED. Multiple application replicas can therefore claim different monitors without checking the same row at the same time.

Project structure

uptime/
├── cmd/server/main.go
├── internal/checker/checker.go
├── internal/db/db.go
├── migrations/0001_init.sql
├── Dockerfile
├── Caddyfile
├── docker-compose.yml
├── go.mod
└── .env.example

1. Create the project

mkdir uptime && cd uptime
go mod init example.com/uptime
go get github.com/jackc/pgx/v5
mkdir -p cmd/server internal/checker internal/db migrations

Replace go.mod with:

module example.com/uptime

go 1.24

require github.com/jackc/pgx/v5 v5.7.4

2. Create the database schema

Add migrations/0001_init.sql:

CREATE TABLE IF NOT EXISTS monitors (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL CHECK (length(name) BETWEEN 1 AND 120),
    url TEXT NOT NULL,
    interval_seconds INTEGER NOT NULL DEFAULT 60 CHECK (interval_seconds BETWEEN 10 AND 86400),
    timeout_seconds INTEGER NOT NULL DEFAULT 10 CHECK (timeout_seconds BETWEEN 1 AND 60),
    enabled BOOLEAN NOT NULL DEFAULT TRUE,
    state TEXT NOT NULL DEFAULT 'unknown' CHECK (state IN ('unknown', 'up', 'down')),
    next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS checks (
    id BIGSERIAL PRIMARY KEY,
    monitor_id BIGINT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
    checked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    ok BOOLEAN NOT NULL,
    status_code INTEGER,
    latency_ms INTEGER,
    error TEXT
);

CREATE INDEX IF NOT EXISTS checks_monitor_checked_idx ON checks (monitor_id, checked_at DESC);
CREATE INDEX IF NOT EXISTS monitors_due_idx ON monitors (next_check_at) WHERE enabled;

CREATE TABLE IF NOT EXISTS incidents (
    id BIGSERIAL PRIMARY KEY,
    monitor_id BIGINT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
    started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    recovered_at TIMESTAMPTZ,
    UNIQUE (monitor_id, started_at)
);

Apply it locally:

psql "$DATABASE_URL" -f migrations/0001_init.sql

Production migrations should run as a reviewed release step, before the new application starts accepting traffic. Never reset the production database to apply this migration.

3. Configure PostgreSQL

Add .env.example:

DATABASE_URL=postgres://uptime:change_me@db:5432/uptime?sslmode=disable
PORT=8080
WEBHOOK_URL=
CHECK_USER_AGENT=buildframe-uptime/1.0

Copy it for local development and replace change_me:

cp .env.example .env

WEBHOOK_URL is optional. When set, the worker sends JSON to it on incident open and recovery. Use a webhook URL from a service you control or an alert provider’s official integration page; do not commit it.

4. Connect to PostgreSQL

Add internal/db/db.go:

package db

import (
    "context"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

func Open(ctx context.Context) (*pgxpool.Pool, error) {
    url := os.Getenv("DATABASE_URL")
    if url == "" {
        return nil, fmt.Errorf("DATABASE_URL is required")
    }
    pool, err := pgxpool.New(ctx, url)
    if err != nil {
        return nil, err
    }
    if err := pool.Ping(ctx); err != nil {
        pool.Close()
        return nil, err
    }
    return pool, nil
}

Add the missing import to the same file:

import "fmt"

The query code below always binds user-controlled values as parameters. URLs are validated before insertion and are never concatenated into SQL.

5. Implement the checker

Add internal/checker/checker.go:

package checker

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"

    "github.com/jackc/pgx/v5/pgxpool"
)

type Monitor struct {
    ID int64
    Name string
    URL string
    Interval int
    Timeout int
    State string
}

type Result struct {
    OK bool
    Status int
    Latency int
    Error string
}

func Run(ctx context.Context, pool *pgxpool.Pool, client *http.Client) error {
    var m Monitor
    err := pool.QueryRow(ctx, `UPDATE monitors SET next_check_at = now() + interval '1 second' * interval_seconds
        WHERE id = (SELECT id FROM monitors WHERE enabled AND next_check_at <= now()
        ORDER BY next_check_at FOR UPDATE SKIP LOCKED LIMIT 1)
        RETURNING id, name, url, interval_seconds, timeout_seconds, state`,).Scan(&m.ID, &m.Name, &m.URL, &m.Interval, &m.Timeout, &m.State)
    if err != nil {
        return nil
    }
    result := check(ctx, client, m)
    return save(ctx, pool, m, result)
}

func check(parent context.Context, client *http.Client, m Monitor) Result {
    ctx, cancel := context.WithTimeout(parent, time.Duration(m.Timeout)*time.Second)
    defer cancel()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.URL, nil)
    if err != nil { return Result{Error: err.Error()} }
    req.Header.Set("User-Agent", os.Getenv("CHECK_USER_AGENT"))
    started := time.Now()
    response, err := client.Do(req)
    latency := int(time.Since(started).Milliseconds())
    if err != nil { return Result{Latency: latency, Error: err.Error()} }
    response.Body.Close()
    return Result{OK: response.StatusCode >= 200 && response.StatusCode < 400, Status: response.StatusCode, Latency: latency}
}

func save(ctx context.Context, pool *pgxpool.Pool, m Monitor, r Result) error {
    tx, err := pool.Begin(ctx)
    if err != nil { return err }
    defer tx.Rollback(ctx)
    _, err = tx.Exec(ctx, `INSERT INTO checks (monitor_id, ok, status_code, latency_ms, error) VALUES ($1,$2,$3,$4,$5)`, m.ID, r.OK, nullable(r.Status), r.Latency, nullableText(r.Error))
    if err != nil { return err }
    next := "up"
    if !r.OK { next = "down" }
    if m.State != next {
        if next == "down" { _, err = tx.Exec(ctx, `INSERT INTO incidents (monitor_id) VALUES ($1)`, m.ID) } else { _, err = tx.Exec(ctx, `UPDATE incidents SET recovered_at = now() WHERE monitor_id = $1 AND recovered_at IS NULL`, m.ID) }
        if err != nil { return err }
        notify(m, next, r)
    }
    _, err = tx.Exec(ctx, `UPDATE monitors SET state = $1 WHERE id = $2`, next, m.ID)
    if err != nil { return err }
    return tx.Commit(ctx)
}

func notify(m Monitor, state string, r Result) {
    target := os.Getenv("WEBHOOK_URL")
    if target == "" { return }
    body, _ := json.Marshal(map[string]any{"monitor": m.Name, "url": m.URL, "state": state, "status": r.Status, "error": r.Error})
    request, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(body))
    if err != nil { return }
    request.Header.Set("Content-Type", "application/json")
    response, err := http.DefaultClient.Do(request)
    if err == nil { response.Body.Close() }
}

func nullable(value int) any { if value == 0 { return nil }; return value }
func nullableText(value string) any { if value == "" { return nil }; return value }
var _ = fmt.Sprintf

Add these imports to internal/checker/checker.go:

"bytes"

The fmt import and final var _ are unnecessary; remove both. The resulting import block must contain bytes, context, encoding/json, net/http, os and time.

6. Expose the API and worker

Add cmd/server/main.go:

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"

    "example.com/uptime/internal/checker"
    "example.com/uptime/internal/db"
)

func main() {
    ctx := context.Background()
    pool, err := db.Open(ctx)
    if err != nil { log.Fatal(err) }
    defer pool.Close()
    client := &http.Client{}
    go func() { for { if err := checker.Run(ctx, pool, client); err != nil { log.Printf("check failed: %v", err) }; time.Sleep(5 * time.Second) } }()
    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
    mux.HandleFunc("GET /api/monitors", func(w http.ResponseWriter, r *http.Request) { rows, err := pool.Query(r.Context(), `SELECT id,name,url,interval_seconds,timeout_seconds,enabled,state,next_check_at FROM monitors ORDER BY id`); if err != nil { http.Error(w, "database error", 500); return }; defer rows.Close(); type item struct { ID int64 `json:"id"`; Name string `json:"name"`; URL string `json:"url"`; Interval int `json:"interval_seconds"`; Timeout int `json:"timeout_seconds"`; Enabled bool `json:"enabled"`; State string `json:"state"`; Next time.Time `json:"next_check_at"` }; result := []item{}; for rows.Next() { var i item; if err := rows.Scan(&i.ID,&i.Name,&i.URL,&i.Interval,&i.Timeout,&i.Enabled,&i.State,&i.Next); err != nil { http.Error(w,"database error",500); return }; result = append(result,i) }; writeJSON(w,result) })
    mux.HandleFunc("POST /api/monitors", func(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"`; URL string `json:"url"`; Interval int `json:"interval_seconds"`; Timeout int `json:"timeout_seconds"` }; if json.NewDecoder(r.Body).Decode(&input) != nil || input.Name == "" || input.Interval < 10 || input.Timeout < 1 { http.Error(w,"invalid monitor",400); return }; parsed, err := url.ParseRequestURI(input.URL); if err != nil || parsed.Scheme != "http" && parsed.Scheme != "https" || parsed.Host == "" { http.Error(w,"invalid URL",400); return }; var id int64; err = pool.QueryRow(r.Context(), `INSERT INTO monitors (name,url,interval_seconds,timeout_seconds) VALUES ($1,$2,$3,$4) RETURNING id`, input.Name,input.URL,input.Interval,input.Timeout).Scan(&id); if err != nil { http.Error(w,"database error",500); return }; writeJSON(w,map[string]any{"id":id}) })
    port := os.Getenv("PORT"); if port == "" { port = "8080" }; log.Fatal(http.ListenAndServe(":"+port, mux))
}

func writeJSON(w http.ResponseWriter, value any) { w.Header().Set("Content-Type", "application/json"); json.NewEncoder(w).Encode(value) }
var _ = strconv.Itoa

Remove the unused strconv import and var _ line. Run gofmt -w cmd/server/main.go internal/checker/checker.go internal/db/db.go.

The API intentionally has no authentication in this minimal version. Put it behind a private network or add authentication before exposing monitor creation publicly.

7. Run locally

Add docker-compose.yml:

services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: uptime
      POSTGRES_PASSWORD: change_me
      POSTGRES_DB: uptime
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d:ro
  app:
    build: .
    environment:
      DATABASE_URL: postgres://uptime:change_me@db:5432/uptime?sslmode=disable
      PORT: 8080
      CHECK_USER_AGENT: buildframe-uptime/1.0
    ports:
      - "8080:8080"
    depends_on:
      - db
volumes:
  postgres_data:

Add Dockerfile:

FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/uptime ./cmd/server

FROM alpine:3.22
RUN adduser -D -u 10001 app
COPY --from=build /out/uptime /usr/local/bin/uptime
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/uptime"]

Start it:

docker compose up --build -d
curl -fsS http://localhost:8080/healthz
curl -fsS -X POST http://localhost:8080/api/monitors -H 'content-type: application/json' -d '{"name":"Example","url":"https://example.com","interval_seconds":60,"timeout_seconds":10}'
curl -fsS http://localhost:8080/api/monitors

Inspect a failure with docker compose logs -f app. To reset only local data, run docker compose down -v; never use that command against production.

Testing

go test ./...
go vet ./...

Test the state machine with a local HTTP server returning 200, then 503. The first failed check should create one open incident; subsequent failures should not create another. A later 200 should set recovered_at.

For production, add integration tests using a disposable PostgreSQL container and HTTP handler tests with httptest.NewServer before adding authentication or billing.

Production deployment

Create a VPS, install Docker, copy the repository, set a strong database password, and start the stack:

scp -r . deploy@your-vps:/opt/uptime
ssh deploy@your-vps
cd /opt/uptime
docker compose up --build -d

Add Caddyfile:

status.example.com {
    reverse_proxy app:8080
}

Add Caddy to Compose or run it on the host. Point DNS status.example.com to the VPS, then verify:

curl -fsS https://status.example.com/healthz

Do not publish PostgreSQL’s port. Restrict the API with authentication, firewall rules or a private admin network before accepting monitor definitions from untrusted users.

Security considerations

  • Validate URL scheme and host; do not allow file:// or arbitrary protocols.
  • Add authentication and per-user ownership before making this multi-tenant.
  • Add SSRF defenses before monitoring user-supplied private addresses. Resolve DNS and block loopback, link-local, private and metadata ranges.
  • Set outbound request timeouts and cap response-body reads if response bodies are added later.
  • Keep database credentials and webhook URLs in environment variables.
  • Rate-limit monitor creation and webhook delivery.
  • Use HTTPS for the API and webhook destination.

Backups and reliability

The database contains check history and incidents. Back it up daily:

docker compose exec -T db pg_dump -U uptime uptime | gzip > "backup-$(date +%F).sql.gz"

Copy backups off the VPS, test restoration monthly, and define retention. Prune old checks rows with a scheduled SQL job once the table grows:

DELETE FROM checks WHERE checked_at < now() - interval '90 days';

Monitoring

Monitor the monitor: alert when /healthz fails, when the worker logs repeated database errors, or when checks stop increasing. Add metrics for check duration, success rate, claim errors and webhook failures before operating at scale.

Cost

Item Monthly cost
1 GB VPS $4–$6
Domain $1–$2 equivalent
PostgreSQL Included on the VPS
TLS with Caddy $0
Webhook provider $0 at small volume
Total about $5–$8

At several thousand monitors, move PostgreSQL to managed storage, separate workers, add queueing and retain only aggregated history. Those changes increase cost and operational complexity.

Production checklist

  • Authentication protects monitor creation.
  • SSRF protections block private and metadata networks.
  • HTTPS is active.
  • PostgreSQL is not publicly exposed.
  • Daily off-site backups run.
  • Restore procedure has been tested.
  • Check history retention is configured.
  • Webhook failures are observable.
  • Go tests and go vet pass.

Common problems

DATABASE_URL is required

Load .env into the Compose environment or define DATABASE_URL explicitly. Docker Compose does not inject variables from .env into the container unless the service references them.

Every monitor stays unknown

Check docker compose logs app, verify the target is reachable from the container, and confirm next_check_at is due.

Checks work locally but fail in production

The target may block the VPS IP, require a specific User-Agent, or resolve differently from the server. Test from the VPS itself.

Duplicate incidents appear

Ensure all replicas use the SKIP LOCKED claim query and that the incident transition runs in the same transaction as the check insert.

Improvements

  • Add users, teams and ownership checks.
  • Add signed public status pages.
  • Replace polling with a dedicated worker pool.
  • Add DNS/TCP/TLS checks alongside HTTP.
  • Store daily uptime aggregates instead of every raw result forever.
  • Add Prometheus metrics and OpenTelemetry traces.

Environment variables

Variable Required Description
DATABASE_URL Yes PostgreSQL connection string
PORT No HTTP port; defaults to 8080
CHECK_USER_AGENT No User-Agent for outbound checks
WEBHOOK_URL No Incident and recovery webhook

Conclusion

This design keeps uptime monitoring understandable: PostgreSQL schedules work, Go performs bounded checks, and incidents are explicit state transitions. Start with one VPS, secure the API, back up the database, and add complexity only when monitor volume requires it.

Related guides