Build a Concurrent Uptime Monitor in Go
Build a concurrent HTTP uptime monitor with SQLite and a live status page.
Introduction
30 Second Summary
Ever signed up for a web service, only to find it randomly down with no explanation? Uptime monitors are the behind-the-scenes tools that catch those outages the second they happen.
In this project, you will build a self-hosted uptime monitor in Go that concurrently probes HTTP endpoints and serves a live status page showing which services are up or down.
What You'll Build
You'll run a single command, watch your monitor probe multiple URLs at once, then open a browser to a live dashboard with green and red status indicators, response times, and uptime percentages you can show off to anyone.
By the end of this project, you'll have:
- A concurrent worker pool that checks multiple endpoints simultaneously using Go's goroutines and channels, finishing in milliseconds instead of waiting for each check one by one.
- A SQLite database that remembers every check result across restarts, so you can see uptime trends over time.
- A live status page served over HTTP that you can open in a browser and demo to anyone, complete with uptime percentages and response times.
- Secret Mission: Extend the monitor with TCP port checks using Go interfaces for polymorphic checker types.
Are there any prerequisites?
You need basic programming experience (variables, functions, loops). No prior Go experience is required.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're building a self-hosted uptime monitor called kreg that watches HTTP endpoints concurrently, stores every check result in SQLite, and serves a live status page you can open in a browser.
Set Up the Go Project
Before you can write any monitoring logic, you need a properly structured Go project. Clean architecture starts at the file system level. Separating concerns into distinct packages from the beginning makes your code easier to test, extend, and reason about.
In this step, you will initialize a Go module, create the directory layout for your clean architecture, and add the SQLite dependency your monitor will use for storage.
In this step, get ready to:
- Initialize the Go module and create the directory structure.
- Add the SQLite dependency.
- Create the entry point with a startup message.
Initialize the Go module and create the directory structure
Every Go project starts with a module. The module path acts as the root import path for all your packages.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir kreg
cd kreg
- Initialize the Go module by running:
go mod init github.com/your-username/kreg
You will see a short confirmation that the module was created. This generates a go.mod file declaring your module path and Go version.
- Verify the module was created by running:
cat go.mod
You should see your module path github.com/your-username/kreg and a go 1.26 directive.
What is a Go module?
A Go module is a collection of packages with a go.mod file at its root. The module path (github.com/your-username/kreg) becomes the prefix for every import in your project.
You do not need to push this to GitHub. The path is just a naming convention that keeps your imports unique.
Now you need the directory structure. This project uses clean architecture with five internal packages, each with a single responsibility.
- Create all the directories by running:
mkdir -p cmd/kreg internal/checker internal/domain internal/scheduler internal/storage internal/server
- Confirm the structure exists by running:
ls cmd internal
You should see kreg listed under cmd, and checker, domain, scheduler, server, storage listed under internal.
Why this directory layout?
The cmd/ directory holds your executable entry points. The internal/ directory is special in Go. Packages inside it can only be imported by code within this module, not by external projects.
Each package has one job: domain defines types, checker handles HTTP probing, storage manages the database, scheduler orchestrates workers, and server serves the status page.
Seeing 'mkdir: cannot create directory'?
Make sure you are inside the kreg folder. Run pwd to check your current location. You should see a path ending in /kreg.
On Windows without Git Bash, use mkdir cmd\kreg and create each directory individually without the -p flag.
help me fix my directory creation error
Add the SQLite dependency
Your monitor will store check results in SQLite so data persists across restarts. The modernc.org/sqlite driver is a pure-Go implementation. It requires no C compiler, which makes it easy to build on any platform.
- Download the SQLite dependency by running:
go get modernc.org/sqlite@v1.54.0
This takes a moment as Go downloads the package and its dependencies into your module cache. Your go.mod and a new go.sum file are updated automatically.
- Confirm the dependency landed in your module by running:
cat go.mod
You should see a require block containing modernc.org/sqlite v1.54.0 (plus several indirect dependencies).
Why modernc.org/sqlite instead of mattn/go-sqlite3?
The mattn/go-sqlite3 package uses CGo and requires a C compiler installed on your machine. The modernc.org/sqlite package is a pure-Go port. It compiles anywhere Go compiles, with zero extra tooling.
Seeing a timeout or network error?
The go get command needs internet access to download packages. Check your connection and try again.
If you are behind a corporate proxy, you may need to configure the GOPROXY environment variable.
help me fix my go get network error
Create the entry point main.go
Every Go program needs a main package with a main() function as its entry point. You will create a minimal one that prints a startup message to prove the project compiles.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Cursor (or VS Code) and press Enter to open your code editor.
- Click File in the top menu bar.
- Click Open Folder.
- Select the kreg folder on your Desktop, then click Open.
- In the Explorer sidebar, expand the cmd/kreg folder.
- Right-click inside cmd/kreg and select New File.
- Name the file main.go and press Enter.
- Paste the following code into main.go:
package main
import "fmt"
func main() {
fmt.Println("kreg - uptime monitor starting...")
}
What does this code do?
package main declares this file as an executable entry point (not a library).
fmt.Println prints a line to the terminal. This startup message confirms your binary runs before you add real logic.
- Save main.go with Cmd+S (macOS) or Ctrl+S (Windows).
Before you run this, what do you think you will see printed in the terminal?
- Switch back to your terminal and run the program:
go run ./cmd/kreg/
You should see:
kreg - uptime monitor starting...
Seeing 'no Go files' or 'cannot find package'?
Make sure you are in the kreg root folder (not inside cmd/kreg). Run pwd to check. The path should end in /kreg, not /kreg/cmd/kreg.
Also confirm the file is named exactly main.go (not main.go.txt) and that it starts with package main.
help me fix my Go build error
Now run the full project build to confirm everything compiles cleanly, including the SQLite dependency:
- Build all packages by running:
go build ./...
No output means success. The ./... pattern tells Go to compile every package in your module. If there were errors, they would appear here.
✔️ Awesome, I've got everything!
Great. Double check you have saved cmd/kreg/main.go and that go build ./... runs without errors.
ⓧ I'd like to double check the full code
Here is cmd/kreg/main.go:
package main
import "fmt"
func main() {
fmt.Println("kreg - uptime monitor starting...")
}
Your go.mod should contain these key lines (you may also see indirect dependencies):
module github.com/your-username/kreg
go 1.26
require modernc.org/sqlite v1.54.0
Your project skeleton is ready with a clean architecture layout, SQLite wired in, and a compiling entry point. Next up, you will teach this entry point to actually reach out and check a URL.
Probe Your First Endpoint
Your project compiles, but it does nothing useful yet. An uptime monitor has one core job: reach out to a URL and report whether it responded.
Before building any infrastructure (databases, workers, web servers), you need to see the monitor actually check a URL. In this step, you will define your data types, write an HTTP checker function, and wire it into main.go so you can see live results in the terminal.
In this step, get ready to:
- Define the domain types (Monitor and CheckResult) in the domain layer.
- Write an HTTP checker function that probes a URL and returns its status, latency, and any error.
- Wire it into main.go to check 3 hardcoded URLs and print the results.
Define the domain types
In clean architecture, the domain layer holds your core data types with zero dependencies on external packages. Everything else (HTTP, databases, servers) depends on the domain, never the reverse.
- Create a new file at internal/domain/domain.go and add the following code:
package domain
import "time"
// Monitor represents a single endpoint we want to watch
type Monitor struct {
ID string
Name string
URL string
Interval time.Duration
}
// CheckResult holds the outcome of one probe attempt
type CheckResult struct {
MonitorID string
Status int
Latency time.Duration
Error string
CheckedAt time.Time
IsUp bool
}
What does this code do?
You defined two structs that model the entire monitoring domain.
- Monitor describes what to check: a URL, a human-readable name, and how often to probe it.
- CheckResult describes what happened: the HTTP status code, how long it took, whether the endpoint is up, and when the check ran.
- Save internal/domain/domain.go.
- Confirm the file compiles by running:
go build ./...
You should see no output, which means zero errors.
Seeing a compile error?
Check that the file is saved at exactly internal/domain/domain.go (not inside an extra subfolder). The package declaration must be package domain.
Help me debug a Go compile error in my domain package.
Write the HTTP checker
The checker's job is simple: given a monitor, make an HTTP GET request and return a CheckResult with the status code, latency, and whether the endpoint is up.
- Create a new file at internal/checker/http.go and add the struct and constructor:
package checker
import (
"context"
"io"
"net/http"
"time"
"github.com/your-username/kreg/internal/domain"
)
// HTTPChecker probes URLs using a configured HTTP client
type HTTPChecker struct {
client *http.Client
}
// NewHTTPChecker creates a checker with the given timeout per request
func NewHTTPChecker(timeout time.Duration) *HTTPChecker {
return &HTTPChecker{
client: &http.Client{Timeout: timeout},
}
}
What does this code do?
- HTTPChecker wraps a standard library http.Client with a timeout so requests never hang forever.
- NewHTTPChecker is a constructor that lets you configure the timeout when creating the checker.
- Still in internal/checker/http.go, add the Check method below the constructor. This is the function that actually probes a URL:
func (c *HTTPChecker) Check(ctx context.Context, monitor domain.Monitor) domain.CheckResult {
// Record the start time to measure latency
start := time.Now()
// Build a context-aware request so it can be cancelled later
req, err := http.NewRequestWithContext(ctx, http.MethodGet, monitor.URL, nil)
if err != nil {
return domain.CheckResult{
MonitorID: monitor.ID,
Status: 0,
Latency: time.Since(start),
Error: err.Error(),
CheckedAt: time.Now(),
IsUp: false,
}
}
// Send the request and measure how long it takes
resp, err := c.client.Do(req)
latency := time.Since(start)
- Continue adding code directly below the previous block (still inside the same Check function):
if err != nil {
return domain.CheckResult{
MonitorID: monitor.ID,
Status: 0,
Latency: latency,
Error: err.Error(),
CheckedAt: time.Now(),
IsUp: false,
}
}
// Drain and close the body to free the connection
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return domain.CheckResult{
MonitorID: monitor.ID,
Status: resp.StatusCode,
Latency: latency,
Error: "",
CheckedAt: time.Now(),
IsUp: resp.StatusCode >= 200 && resp.StatusCode < 400,
}
}
What does the Check method do?
- It uses http.NewRequestWithContext to build a request that respects cancellation. This matters later when you add graceful shutdown.
- It measures latency by recording time.Now() before and after the request.
- It drains and closes the response body (io.Copy(io.Discard, resp.Body)) so the underlying TCP connection can be reused.
- It considers any status code between 200 and 399 as "up".
- Save internal/checker/http.go.
- Confirm it compiles by running:
go build ./...
No output means success.
Seeing an import error?
Make sure the import path matches your module name exactly: github.com/your-username/kreg/internal/domain. The module name in go.mod must match the prefix of your import paths.
Help me fix my Go import path error.
Wire it into main.go and run your first check
Time to connect the pieces. You will replace the placeholder in main.go with code that defines 3 monitors and checks them sequentially.
- Open cmd/kreg/main.go and replace its entire contents with the following imports and monitor list:
package main
import (
"context"
"fmt"
"time"
"github.com/your-username/kreg/internal/checker"
"github.com/your-username/kreg/internal/domain"
)
func main() {
fmt.Println("kreg - uptime monitor starting...")
// Define the endpoints to monitor
monitors := []domain.Monitor{
{ID: "1", Name: "HTTPBin OK", URL: "https://httpbin.org/status/200", Interval: 30 * time.Second},
{ID: "2", Name: "HTTPBin Fail", URL: "https://httpbin.org/status/500", Interval: 30 * time.Second},
{ID: "3", Name: "Go Website", URL: "https://go.dev", Interval: 30 * time.Second},
}
- Directly below the monitors slice (still inside the main function), add the checker and the loop that probes each URL:
// Create a checker with a 5-second timeout per request
httpChecker := checker.NewHTTPChecker(5 * time.Second)
ctx := context.Background()
// Check each monitor sequentially and print the result
for _, m := range monitors {
result := httpChecker.Check(ctx, m)
status := "DOWN"
if result.IsUp {
status = "UP"
}
fmt.Printf("%s %d %s %s\n", status, result.Status, result.Latency.Round(time.Millisecond), m.URL)
}
}
What does this code do?
- You hardcode 3 monitors: one that always returns 200 (UP), one that always returns 500 (DOWN), and one real website.
- context.Background() provides a top-level context with no deadline. Later steps will swap this for a cancellable context.
- The for loop checks each monitor one at a time and prints its status, HTTP code, latency, and URL.
- Save cmd/kreg/main.go.
Before you run the program, what do you expect to see for the three URLs? One should be UP, one should be DOWN, and one should be UP with a real response time.
- Run the program by executing:
go run ./cmd/kreg/
You should see output like this (latency values will vary):
kreg - uptime monitor starting...
UP 200 142ms https://httpbin.org/status/200
DOWN 500 138ms https://httpbin.org/status/500
UP 200 85ms https://go.dev
The first URL returns 200 (UP). The second returns 500 (DOWN). The third is a live website that responds with 200 (UP). Your monitor just probed three real endpoints from the internet.
Seeing a timeout or connection error?
Make sure you have an active internet connection. The checker reaches out to httpbin.org and go.dev over HTTPS.
If you see a compile error about missing packages, run go mod tidy to clean up your module dependencies.
Help me debug my Go uptime checker not connecting.
✔️ Awesome, I've got everything!
Great. Double check you have saved both internal/domain/domain.go, internal/checker/http.go, and cmd/kreg/main.go.
ⓧ I'd like to double check the full code
Here is the full internal/domain/domain.go:
package domain
import "time"
type Monitor struct {
ID string
Name string
URL string
Interval time.Duration
}
type CheckResult struct {
MonitorID string
Status int
Latency time.Duration
Error string
CheckedAt time.Time
IsUp bool
}
Here is the full cmd/kreg/main.go:
package main
import (
"context"
"fmt"
"time"
"github.com/your-username/kreg/internal/checker"
"github.com/your-username/kreg/internal/domain"
)
func main() {
fmt.Println("kreg - uptime monitor starting...")
monitors := []domain.Monitor{
{ID: "1", Name: "HTTPBin OK", URL: "https://httpbin.org/status/200", Interval: 30 * time.Second},
{ID: "2", Name: "HTTPBin Fail", URL: "https://httpbin.org/status/500", Interval: 30 * time.Second},
{ID: "3", Name: "Go Website", URL: "https://go.dev", Interval: 30 * time.Second},
}
And the rest of main.go (still inside the main function):
httpChecker := checker.NewHTTPChecker(5 * time.Second)
ctx := context.Background()
for _, m := range monitors {
result := httpChecker.Check(ctx, m)
status := "DOWN"
if result.IsUp {
status = "UP"
}
fmt.Printf("%s %d %s %s\n", status, result.Status, result.Latency.Round(time.Millisecond), m.URL)
}
}
Scroll up to the HTTP checker substep and confirm your internal/checker/http.go contains the struct, constructor, and full Check method in order.
Your monitor can now probe endpoints and tell you if they are alive. But notice something: the checks run one after another. If you had 10 slow endpoints, you would wait for each one to finish before starting the next. Next up, you will build a concurrent worker pool that checks them all at the same time.
Add the Concurrent Worker Pool
In the previous step, your monitor checked 3 URLs one after the other. If each request takes 500ms, that is 1.5 seconds per cycle. Scale that to 10 endpoints and you are waiting 5 seconds before the loop even finishes.
A worker pool fixes this by fanning work out to multiple goroutines. You will build a scheduler that pushes monitors into a channel, and a configurable number of workers will pull from that channel and check URLs concurrently.
In this step, get ready to:
- Build the worker pool with a jobs channel and configurable worker count.
- Add a scheduler that ticks on an interval and dispatches all monitors each cycle.
- Wire context cancellation so Ctrl+C stops the scheduler and drains workers cleanly.
Build the worker pool with a jobs channel and configurable worker count
The scheduler needs a place to store check results. You will build the full storage layer in the next step, but the scheduler file needs to import it now. A stub with empty methods lets everything compile.
- Create the file internal/storage/sqlite.go by running:
mkdir -p internal/storage && touch internal/storage/sqlite.go
- Open internal/storage/sqlite.go and paste this stub:
package storage
import (
"github.com/your-username/kreg/internal/domain"
)
type Store struct{}
func New(path string) (*Store, error) {
return &Store{}, nil
}
func (s *Store) Close() error {
return nil
}
func (s *Store) SaveResult(r domain.CheckResult) error {
return nil
}
What does this code do?
This is a placeholder. Store is an empty struct with New, Close, and SaveResult methods that do nothing. It exists so the scheduler package can reference *storage.Store without a compile error. You will fill this in with real SQLite logic in the next step.
- Save internal/storage/sqlite.go.
Now create the scheduler itself. This file defines the worker pool types and constructor.
- Create internal/scheduler/scheduler.go by running:
mkdir -p internal/scheduler && touch internal/scheduler/scheduler.go
- Open internal/scheduler/scheduler.go and paste the package declaration, imports, types, and constructor:
package scheduler
import (
"context"
"fmt"
"sync"
"time"
"github.com/your-username/kreg/internal/domain"
"github.com/your-username/kreg/internal/storage"
)
// Checker is an interface so the scheduler doesn't depend on a concrete HTTP implementation
type Checker interface {
Check(ctx context.Context, monitor domain.Monitor) domain.CheckResult
}
// Scheduler orchestrates concurrent monitoring cycles
type Scheduler struct {
monitors []domain.Monitor
checker Checker
store *storage.Store
workers int
}
// New creates a scheduler with the given worker count
func New(monitors []domain.Monitor, checker Checker, store *storage.Store, workers int) *Scheduler {
return &Scheduler{
monitors: monitors,
checker: checker,
store: store,
workers: workers,
}
}
What does this code do?
The Checker interface decouples the scheduler from the specific HTTP implementation. Any type with a Check method satisfies it. Your HTTPChecker from Step 2 already has the right signature.
The Scheduler struct holds the monitors to check, the checker to use, a store for results, and a workers count controlling how many goroutines run simultaneously.
- Verify both new files compile by running:
go build ./...
You should see no output. No errors means the types are valid.
Seeing an import error?
Make sure the import path in scheduler.go matches your module name in go.mod exactly. Check that internal/storage/sqlite.go exists and starts with package storage.
help me fix import errors in my scheduler package
Now add the core of the worker pool. The runCycle method spawns worker goroutines that pull monitors from a jobs channel and check them concurrently.
- In internal/scheduler/scheduler.go, paste the first half of the runCycle method below the New function:
// runCycle dispatches all monitors to worker goroutines
func (s *Scheduler) runCycle(ctx context.Context) {
// Buffered channel holds all jobs without blocking the sender
jobs := make(chan domain.Monitor, len(s.monitors))
var wg sync.WaitGroup
// Spawn worker goroutines that pull from the jobs channel
for i := 0; i < s.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for monitor := range jobs {
select {
case <-ctx.Done():
return
default:
}
result := s.checker.Check(ctx, monitor)
status := "DOWN"
if result.IsUp {
status = "UP"
}
fmt.Printf("[%s] %s %s %d %s\n", status, monitor.Name, monitor.URL, result.Status, result.Latency.Round(time.Millisecond))
if err := s.store.SaveResult(result); err != nil {
fmt.Printf("error saving result: %v\n", err)
}
}
}()
}
- Immediately below that worker loop (still inside the same runCycle function), paste the dispatch logic:
// Send all monitors into the jobs channel
for _, m := range s.monitors {
jobs <- m
}
close(jobs)
wg.Wait()
fmt.Println("--- cycle complete ---")
}
How does the worker pool work?
The jobs channel is a buffered queue. Workers block on range jobs until a monitor appears, check it, then loop back for the next one. When close(jobs) is called, the range loop exits and each worker finishes.
The select with ctx.Done() lets each worker bail out early if the program is shutting down. wg.Wait() blocks until every worker has finished, so the cycle only prints "complete" when all checks are done.
- Save internal/scheduler/scheduler.go and verify it compiles by running:
go build ./...
No output means success. The worker pool compiles but is not wired into the program yet.
Seeing a syntax error?
Make sure the closing brace } of runCycle appears after fmt.Println("--- cycle complete ---"). Also check that the worker loop's }() and outer } are both present.
help me fix brace-matching errors in my Go scheduler
Add a scheduler that ticks on an interval and dispatches all monitors each cycle
The worker pool knows how to run one cycle. Now you need a loop that triggers a cycle immediately on startup, then repeats every 30 seconds.
- In internal/scheduler/scheduler.go, paste the Start method between the New function and the runCycle function:
// Start runs an immediate cycle then repeats on a 30-second ticker
func (s *Scheduler) Start(ctx context.Context) {
// Run one cycle immediately so the user sees output right away
s.runCycle(ctx)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.runCycle(ctx)
}
}
}
What does this code do?
Start fires runCycle once immediately so you see results the moment the program launches. Then it enters a select loop that either ticks every 30 seconds (triggering another cycle) or exits when ctx.Done() fires.
This pattern is idiomatic Go for a cancellable background loop. The defer ticker.Stop() cleans up the timer resource when the context is cancelled.
- Save internal/scheduler/scheduler.go and confirm it compiles:
go build ./...
No output means the scheduler package is complete and ready to be wired into the program.
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/scheduler/scheduler.go.
ⓧ I'd like to double check the full code
Your internal/scheduler/scheduler.go is over 30 lines, so confirm the following sections appear in order by scrolling through your file:
- Package declaration and imports (context, fmt, sync, time, plus the domain and storage packages).
- The Checker interface with a single Check method.
- The Scheduler struct with four fields: monitors, checker, store, workers.
- The New constructor function.
- The Start method with runCycle call, ticker, and select loop.
- The runCycle method with jobs channel, worker goroutines, dispatch loop, wg.Wait(), and "--- cycle complete ---" print.
Wire context cancellation so Ctrl+C stops the scheduler cleanly
The scheduler runs in a goroutine. Your main function needs to wait for an interrupt signal, then cancel the context so all workers shut down gracefully.
- Open cmd/kreg/main.go and replace its entire content. Start with the package declaration, imports, and monitors slice:
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/your-username/kreg/internal/checker"
"github.com/your-username/kreg/internal/domain"
"github.com/your-username/kreg/internal/scheduler"
"github.com/your-username/kreg/internal/storage"
)
func main() {
fmt.Println("kreg - uptime monitor starting...")
monitors := []domain.Monitor{
{ID: "1", Name: "HTTPBin OK", URL: "https://httpbin.org/status/200", Interval: 30 * time.Second},
{ID: "2", Name: "HTTPBin Fail", URL: "https://httpbin.org/status/500", Interval: 30 * time.Second},
{ID: "3", Name: "Go Website", URL: "https://go.dev", Interval: 30 * time.Second},
}
- Immediately below the monitors slice, paste the store setup, scheduler creation, and signal handling:
// Open the storage stub (real SQLite comes next step)
store, err := storage.New("kreg.db")
if err != nil {
log.Fatalf("failed to open database: %v", err)
}
defer store.Close()
// Create the scheduler with 3 concurrent workers
httpChecker := checker.NewHTTPChecker(5 * time.Second)
sched := scheduler.New(monitors, httpChecker, store, 3)
// Cancellable context lets us shut down workers cleanly
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Run the scheduler in a background goroutine
go sched.Start(ctx)
// Block until the user presses Ctrl+C
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nshutting down...")
cancel()
fmt.Println("goodbye!")
}
How does the shutdown work?
signal.Notify routes SIGINT (Ctrl+C) and SIGTERM into a channel. The main goroutine blocks on <-sigCh until one of those signals arrives.
When it does, cancel() fires. That closes the context, which causes the scheduler's select to hit ctx.Done() and return. Workers also see ctx.Done() and stop picking up new jobs. No orphaned goroutines.
- Save cmd/kreg/main.go.
Before you run this, think about what you expect to see. In Step 2, the 3 results printed one after another with a visible pause between each. What should happen now that 3 workers check simultaneously?
- Run the program:
go run ./cmd/kreg
All 3 check results appear nearly at the same time (within ~500ms total instead of ~1.5s). You will see output like:
kreg - uptime monitor starting...
[UP] HTTPBin OK https://httpbin.org/status/200 200 142ms
[DOWN] HTTPBin Fail https://httpbin.org/status/500 500 138ms
[UP] Go Website https://go.dev 200 87ms
--- cycle complete ---
What should I see?
The three lines print almost simultaneously because the workers check all URLs in parallel. The order may vary between runs. After 30 seconds, another cycle fires. The program keeps running until you stop it.
- Press Ctrl+C to stop the program.
You should see "shutting down..." followed by "goodbye!" confirming the context cancellation worked.
Results still appearing one at a time?
Check that scheduler.New receives 3 as the last argument (worker count). If you passed 1, only one goroutine runs so checks are sequential.
Also confirm you are calling go sched.Start(ctx) with the go keyword. Without it, Start blocks forever and the signal handler never runs.
help me debug why my worker pool is not running concurrently
✔️ Awesome, I've got everything!
Great. Confirm you have saved cmd/kreg/main.go and the program shuts down cleanly on Ctrl+C.
ⓧ I'd like to double check the full code
Your cmd/kreg/main.go should contain these sections in order:
- Imports: context, fmt, log, os, os/signal, syscall, time, plus checker, domain, scheduler, and storage packages.
- The monitors slice with 3 entries (unchanged from Step 2).
- storage.New("kreg.db") with error handling and defer store.Close().
- checker.NewHTTPChecker and scheduler.New with 3 workers.
- context.WithCancel and go sched.Start(ctx).
- Signal channel blocking on SIGINT/SIGTERM, then cancel() and goodbye message.
Your monitor now checks all endpoints in parallel and shuts down cleanly. Next up, you will replace the storage stub with real SQLite so check results persist across restarts.
Persist Results in SQLite
Right now, your monitor prints check results to the terminal. Stop the program with Ctrl+C and all that history vanishes.
You cannot show uptime percentages or trends without stored data. In this step, you will add SQLite persistence so every check result survives restarts. SQLite gives you a single-file database with zero external dependencies.
In this step, get ready to:
- Create the SQLite repository with table creation and result insertion.
- Run the monitor and verify results persist in the database file.
- Add query methods for uptime percentage and latest result lookups.
Create the SQLite repository
Your project already has a stub storage package that compiles but does nothing. You will replace it with a real implementation that opens a SQLite database, creates a table, and writes check results.
- Open internal/storage/sqlite.go in your editor.
- Select all the existing stub code and replace it with the package header below:
package storage
import (
"database/sql"
"time"
_ "modernc.org/sqlite"
"github.com/your-username/kreg/internal/domain"
)
type Store struct {
db *sql.DB
}
What does this code do?
The blank import _ "modernc.org/sqlite" registers the SQLite driver with Go's database/sql package. The underscore means you only need its side effects (driver registration), not any exported names.
The Store struct wraps a standard *sql.DB connection. Every method on Store uses this connection to talk to the database.
- Add the constructor and close method below the struct by pasting this code:
func New(path string) (*Store, error) {
db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, err
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS check_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
monitor_id TEXT NOT NULL,
status INTEGER NOT NULL,
latency_ms INTEGER NOT NULL,
error TEXT,
is_up BOOLEAN NOT NULL,
checked_at DATETIME NOT NULL
)
`)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func (s *Store) Close() error {
return s.db.Close()
}
What does this code do?
The New function opens (or creates) a SQLite database file at the given path. The DSN pragma journal_mode(wal) enables Write-Ahead Logging so your status page can read while the scheduler writes.
The busy_timeout(5000) pragma tells SQLite to wait up to 5 seconds if the database is locked, instead of immediately failing. The CREATE TABLE IF NOT EXISTS statement ensures the table exists on first run without crashing on subsequent runs.
- Add the SaveResult method below the Close function:
func (s *Store) SaveResult(r domain.CheckResult) error {
_, err := s.db.Exec(
"INSERT INTO check_results (monitor_id, status, latency_ms, error, is_up, checked_at) VALUES (?, ?, ?, ?, ?, ?)",
r.MonitorID, r.Status, r.Latency.Milliseconds(), r.Error, r.IsUp, r.CheckedAt,
)
return err
}
What does this code do?
Each time the scheduler finishes checking a URL, it calls SaveResult with the CheckResult. This method inserts one row into the check_results table.
The Latency.Milliseconds() call converts Go's time.Duration into an integer for clean storage. You will convert it back when reading results later.
- Save internal/storage/sqlite.go.
- Verify the project still compiles by running:
go build ./...
You should see no output and no errors. This confirms your new storage implementation satisfies the interface the scheduler expects.
Seeing a compile error?
If you see an error about SaveResult or Close not being defined, make sure you added all three sections (struct, New function, Close, and SaveResult) in order. The scheduler already calls these methods.
If you see an import error for modernc.org/sqlite, run go mod tidy to download the dependency.
Help me debug this
Run the monitor and verify persistence
The scheduler already calls store.SaveResult in its worker loop. Now that the method actually writes to SQLite, running the program should create a kreg.db file and fill it with rows.
- Start the monitor by running:
go run ./cmd/kreg/
You should see check results printing as before. Wait for the second cycle to complete (you will see two sets of results followed by two "--- cycle complete ---" lines).
- Press Ctrl+C to stop the program. You should see the "shutting down..." and "goodbye!" messages.
Before you run the next command, what do you think the count will be? You monitored 3 URLs across 2 cycles.
- Query the database to confirm results were persisted by running:
sqlite3 kreg.db "SELECT COUNT(*) FROM check_results;"
You should see 6 (or more if you waited for extra cycles). That confirms check results now survive program restarts.
Seeing 0 or command not found?
If the count is 0, make sure you waited for at least one full "--- cycle complete ---" message before pressing Ctrl+C. Results are only saved after each check finishes.
If sqlite3 is not found, you can verify the file exists with ls kreg.db instead. The file's existence confirms SQLite is working. You can install the sqlite3 CLI separately if you want to run queries.
Help me verify my persistence setup
Add query methods for uptime and latest results
Saving results is only half the story. The status page (coming in the next step) needs to display uptime percentages and the most recent check for each monitor. You will add two query methods to support that.
- In internal/storage/sqlite.go, add the GetUptimePercent method below SaveResult:
func (s *Store) GetUptimePercent(monitorID string) (float64, error) {
var total, up int
err := s.db.QueryRow(
"SELECT COUNT(*), COALESCE(SUM(CASE WHEN is_up THEN 1 ELSE 0 END), 0) FROM check_results WHERE monitor_id = ?",
monitorID,
).Scan(&total, &up)
if err != nil || total == 0 {
return 100.0, err
}
return float64(up) / float64(total) * 100.0, nil
}
What does this code do?
This method counts all check results for a given monitor, then counts how many were "up". Dividing the two gives an uptime percentage. The COALESCE prevents a NULL result when there are no rows yet.
If there are zero results, it returns 100.0 as a safe default. You cannot divide by zero, and showing 0% uptime before any checks have run would be misleading.
- Add the GetLatestResult method below GetUptimePercent:
func (s *Store) GetLatestResult(monitorID string) (*domain.CheckResult, error) {
row := s.db.QueryRow(
"SELECT monitor_id, status, latency_ms, error, is_up, checked_at FROM check_results WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT 1",
monitorID,
)
var r domain.CheckResult
var latencyMs int64
var errStr sql.NullString
err := row.Scan(&r.MonitorID, &r.Status, &latencyMs, &errStr, &r.IsUp, &r.CheckedAt)
if err != nil {
return nil, err
}
r.Latency = time.Duration(latencyMs) * time.Millisecond
if errStr.Valid {
r.Error = errStr.String
}
return &r, nil
}
What does this code do?
This fetches the single most recent check result for a monitor by ordering by checked_at DESC and limiting to 1 row. It converts the stored latency_ms integer back into a Go time.Duration for the rest of the application to use.
The sql.NullString type handles the nullable error column. When a check succeeds, the error column is empty. Using NullString prevents a scan error on those NULL values.
- Save internal/storage/sqlite.go.
- Confirm everything still compiles by running:
go build ./...
No output means success. Your storage layer now has everything the status page will need in the next step.
Build failing after adding query methods?
Check that your import block at the top of the file includes "time". The GetLatestResult method uses time.Duration which requires that import.
Also verify that "database/sql" is imported. The sql.NullString type comes from that package.
Help me fix my build
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/storage/sqlite.go and that go build ./... passes cleanly.
ⓧ I'd like to double check the full code
Here is the complete internal/storage/sqlite.go file. Confirm yours matches by checking each section is present in order:
- Package declaration and imports (database/sql, time, modernc.org/sqlite, domain).
- The Store struct wrapping *sql.DB.
- The New constructor opening SQLite with WAL mode and creating the check_results table.
- The Close method.
- The SaveResult method inserting a row with 6 columns.
- The GetUptimePercent method using COUNT and SUM with CASE WHEN.
- The GetLatestResult method selecting the most recent row and scanning into a CheckResult.
Scroll up through this step and confirm each chunk is present in your file, in order.
Your monitor now has a memory. Check results persist in kreg.db across restarts, and you have query methods ready for the status page. Next up, you will serve a live HTML dashboard that pulls from this database and shows uptime at a glance.
Serve the Status Page
Your monitor collects data and stores it in SQLite, but nobody else can see what it knows. Right now, the only way to check status is to read terminal output.
An HTTP server turns your background process into something you can open in a browser. In this step, you will build a JSON API endpoint and an HTML status page that shows uptime percentages and response times for every monitored URL.
In this step, get ready to:
- Add an HTTP server with a JSON API endpoint returning all monitors and their latest status.
- Build an HTML template that renders the status page with uptime bars and response times.
- Start the HTTP server alongside the scheduler in main.go using goroutines.
Add an HTTP server with a JSON API endpoint
The server needs two things: data structures that describe what to send to the browser, and lifecycle methods that start and stop the HTTP listener.
- Create the file internal/server/server.go by running:
touch internal/server/server.go
- Confirm the file exists by running:
ls internal/server/
You should see server.go listed.
- Open internal/server/server.go in your editor and add the package declaration, imports, and struct definitions:
package server
import (
"context"
"encoding/json"
"html/template"
"net/http"
"time"
"github.com/your-username/kreg/internal/domain"
"github.com/your-username/kreg/internal/storage"
)
// Server holds the HTTP server and its dependencies
type Server struct {
store *storage.Store
monitors []domain.Monitor
srv *http.Server
}
// MonitorStatus is the JSON shape sent to clients
type MonitorStatus struct {
Name string `json:"name"`
URL string `json:"url"`
IsUp bool `json:"is_up"`
Status int `json:"status"`
LatencyMs int64 `json:"latency_ms"`
Uptime float64 `json:"uptime_percent"`
Error string `json:"error,omitempty"`
}
What does this code do?
This sets up the server package with two core types.
- Server holds a reference to your Store (for reading check results), the list of monitors, and the underlying http.Server.
- MonitorStatus is the shape of each monitor's data when sent as JSON. The struct tags (json:"name" etc.) control how Go serializes fields for the API response.
- Below the MonitorStatus struct, add the constructor and lifecycle methods:
// New creates a server with routes registered
func New(store *storage.Store, monitors []domain.Monitor, addr string) *Server {
s := &Server{store: store, monitors: monitors}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleStatus)
mux.HandleFunc("/api/status", s.handleAPI)
s.srv = &http.Server{Addr: addr, Handler: mux}
return s
}
// Start begins listening for HTTP requests
func (s *Server) Start() error {
return s.srv.ListenAndServe()
}
// Shutdown gracefully stops the server
func (s *Server) Shutdown(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return s.srv.Shutdown(ctx)
}
What does this code do?
- New registers two routes: / for the HTML status page and /api/status for the JSON endpoint. It uses Go's standard library http.NewServeMux as the router.
- Start calls ListenAndServe which blocks until the server shuts down.
- Shutdown gives in-flight requests 5 seconds to complete before forcing a close. This prevents data loss on exit.
Build the status data helper and JSON handler
The JSON API needs a function that queries the store for every monitor's latest result and uptime percentage, then formats it into the MonitorStatus slice.
- Below the Shutdown method in server.go, add the data helper and JSON handler:
// getStatuses builds the current status for all monitors
func (s *Server) getStatuses() []MonitorStatus {
statuses := make([]MonitorStatus, 0, len(s.monitors))
for _, m := range s.monitors {
ms := MonitorStatus{Name: m.Name, URL: m.URL}
if result, err := s.store.GetLatestResult(m.ID); err == nil && result != nil {
ms.IsUp = result.IsUp
ms.Status = result.Status
ms.LatencyMs = result.Latency.Milliseconds()
ms.Error = result.Error
}
if uptime, err := s.store.GetUptimePercent(m.ID); err == nil {
ms.Uptime = uptime
}
statuses = append(statuses, ms)
}
return statuses
}
// handleAPI returns JSON status for all monitors
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(s.getStatuses())
}
What does this code do?
- getStatuses loops through every monitor, calls GetLatestResult and GetUptimePercent from your Store, and packs the data into the API-friendly struct.
- handleAPI sets the response content type to JSON and streams the slice directly to the client using json.NewEncoder.
Build an HTML template that renders the status page
The JSON API is great for machines. Humans need something prettier. Go's html/template package lets you embed an HTML template directly in your Go source, keeping the project as a single binary with no static files to manage.
- Below the handleAPI function in server.go, add the HTML handler and the template constant:
// handleStatus renders the HTML status page
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
tmpl := template.Must(template.New("status").Parse(statusHTML))
tmpl.Execute(w, s.getStatuses())
}
const statusHTML = `<!DOCTYPE html>
<html><head><title>kreg - status</title>
<style>
body{font-family:system-ui;max-width:800px;margin:40px auto;padding:0 20px;background:#1a1a2e;color:#eee}
h1{color:#0f3460}
.monitor{padding:16px;margin:12px 0;border-radius:8px;background:#16213e}
.up{border-left:4px solid #0cca4a}
.down{border-left:4px solid #e63946}
.name{font-weight:bold;font-size:1.1em}
.meta{color:#aaa;font-size:0.9em;margin-top:4px}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8em;font-weight:bold}
.badge-up{background:#0cca4a;color:#000}
.badge-down{background:#e63946;color:#fff}
</style></head><body>
<h1>kreg status</h1>
{{range .}}
<div class="monitor {{if .IsUp}}up{{else}}down{{end}}">
<div class="name">{{.Name}} <span class="badge {{if .IsUp}}badge-up{{else}}badge-down{{end}}">{{if .IsUp}}UP{{else}}DOWN{{end}}</span></div>
<div class="meta">{{.URL}} | {{.LatencyMs}}ms | {{printf "%.1f" .Uptime}}% uptime</div>
</div>
{{end}}
</body></html>`
What does this code do?
- handleStatus parses the embedded HTML template and executes it with the current monitor statuses. The browser receives a fully rendered page.
- statusHTML is a raw string literal containing the entire page. The {{range .}} directive loops over each MonitorStatus, rendering a card with UP/DOWN badges, response time, and uptime percentage.
- Save internal/server/server.go.
- Verify the new package compiles by running:
go build ./...
You should see no errors. The server package is ready to use.
Seeing an undefined error?
- Check that handleStatus and handleAPI are both defined as methods on *Server. The New function references them, so they must exist in the same file.
- Confirm the import path uses github.com/your-username/kreg/internal/domain with the exact module name from your go.mod.
Help me debug a Go build error in my server package.
✔️ Awesome, I've got everything!
Great. Double check you have saved internal/server/server.go and the build passes cleanly.
ⓧ I'd like to double check the full code
Your internal/server/server.go should contain these sections in order. Scroll up and confirm each is present:
- Package declaration and imports (context, encoding/json, html/template, net/http, time, plus domain and storage).
- Server struct and MonitorStatus struct with JSON tags.
- New constructor registering / and /api/status routes.
- Start and Shutdown lifecycle methods.
- getStatuses helper and handleAPI JSON handler.
- handleStatus HTML handler and statusHTML constant.
Start the HTTP server alongside the scheduler
The server and scheduler both need to run concurrently. You will launch each in its own goroutine so that one does not block the other.
- In cmd/kreg/main.go, find the import block that starts with:
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
- Add "net/http" after "log", and add "github.com/your-username/kreg/internal/server" after the scheduler import, so the full import block looks like this:
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/your-username/kreg/internal/checker"
"github.com/your-username/kreg/internal/domain"
"github.com/your-username/kreg/internal/scheduler"
"github.com/your-username/kreg/internal/server"
"github.com/your-username/kreg/internal/storage"
)
- Now find these lines near the bottom of main():
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go sched.Start(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nshutting down...")
cancel()
fmt.Println("goodbye!")
- Replace that entire section (from ctx, cancel := through fmt.Println("goodbye!")) with the following code that creates the server, launches both goroutines, and shuts down gracefully:
srv := server.New(store, monitors, ":8080")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the HTTP server in its own goroutine
go func() {
fmt.Println("status page: http://localhost:8080")
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
log.Printf("server error: %v", err)
}
}()
// Start the scheduler in its own goroutine
go sched.Start(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
fmt.Println("\nshutting down...")
cancel()
srv.Shutdown(context.Background())
fmt.Println("goodbye!")
What does this code do?
- server.New(store, monitors, ":8080") creates your server wired to the same store and monitor list.
- The go func() launches the HTTP listener without blocking main(). If the server exits with any error other than http.ErrServerClosed (which is expected during shutdown), it logs the problem.
- go sched.Start(ctx) runs the check cycle in a second goroutine. Both the server and scheduler now run concurrently.
- srv.Shutdown(context.Background()) is called after cancellation so the HTTP server drains in-flight requests before the program exits.
- Save cmd/kreg/main.go.
Before you run the program, what do you expect to see in the terminal output?
- Run the full program:
go run ./cmd/kreg
You should see the startup message, a line saying status page: http://localhost:8080, and then the check results printing as the first cycle runs.
- Open your browser and navigate to http://localhost:8080.
You should see a dark-themed status page listing each monitored URL with its current status (UP or DOWN), response time in milliseconds, and uptime percentage.
- Open http://localhost:8080/api/status to confirm the JSON endpoint returns an array of monitor objects with name, url, is_up, latency_ms, and uptime_percent fields.
- Press Ctrl+C in your terminal to stop the program.
Page not loading in the browser?
- Make sure the program is still running in your terminal. If you see goodbye! it already exited.
- Check that nothing else is using port 8080. If you see a "bind: address already in use" error, stop whatever is on that port or change ":8080" to ":8081" in main.go.
- Confirm the URL is exactly http://localhost:8080 (not https).
Help me debug why my kreg status page is not loading in the browser.
✔️ Awesome, I've got everything!
Great. Confirm you saved cmd/kreg/main.go and that both the HTML page and JSON endpoint load in your browser.
ⓧ I'd like to double check the full code
Your cmd/kreg/main.go should contain these sections in order:
- Import block with net/http and the server package added.
- The monitors slice, store creation, and httpChecker (unchanged from before).
- sched := scheduler.New(...) followed by srv := server.New(store, monitors, ":8080").
- Context creation, then go func() for srv.Start and go sched.Start(ctx).
- Signal handling, then cancel(), srv.Shutdown(context.Background()), and fmt.Println("goodbye!").
Your uptime monitor is now fully functional. It checks endpoints concurrently, stores results in SQLite, and serves a live status page anyone can open in a browser. Next up, you will extend the system to monitor more than just HTTP in the Secret Mission.
Secret mission
Add TCP Port Checks
Your monitor only speaks HTTP. But what about a database port? A mail server? A Redis instance? In this mission, you'll build a TCP checker that proves your architecture is truly extensible using Go interfaces.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The kreg project folder on your Desktop (contains all source code and Go module files).
- The kreg.db SQLite database file (inside the project folder, stores all check results).
Keep everything running
No action needed. Choose this if you're still actively building or want to keep monitoring your endpoints.
- Leave the binary running to continue monitoring. The database grows slowly (about 1KB per check result).
- If you stopped the process, restart it anytime from the kreg folder with go run ./cmd/kreg.
- Your check history persists in kreg.db across restarts.
Pause - I'll come back to this later
Shut down the running process to free up your terminal. Your data and code stay on disk for when you return.
- Press Ctrl+C in the terminal where kreg is running. This triggers the graceful shutdown you built.
- Your kreg.db file keeps all check history. When you restart, new results append to the same database.
- To resume later, open a terminal in the kreg folder and run go run ./cmd/kreg.
Delete - I don't want to use this again
Remove all project files and start fresh. There are no cloud resources to clean up.
- If kreg is still running, press Ctrl+C to stop it.
- Delete the entire project folder by running the command for your operating system.
macOS
cd ~/Desktop
rm -rf kreg
Windows
cd ~\Desktop
Remove-Item -Recurse -Force kreg
This removes all source code, the compiled binary (if any), and the kreg.db database file.
Nice Work!
Nice Work!
Nice work! You just built a production-style concurrent uptime monitor in Go from scratch.
You've learned how to:
- Build a bounded worker pool using goroutines, channels, and context cancellation to probe multiple HTTP endpoints simultaneously.
- Persist check results in SQLite using Go's database/sql interface with a pure-Go driver that requires no CGo.
- Serve a live status page over HTTP with uptime percentages and response times, structured using clean architecture with separate domain, storage, and handler layers.
- Secret Mission: Extended the system with TCP port checks using Go interfaces for polymorphic checker types.
Ready to quiz yourself?