Build a Health Checker CLI in Go

Build a concurrent HTTP health checker CLI tool using Go's standard library.

Introduction

30 Second Summary

Every team running microservices or APIs has the same question: are all my services still responding? Finding out one at a time is slow and painful.

In this project, you will build a command-line health checker tool in Go that pings multiple HTTP endpoints concurrently and prints a clear status report showing what is up and what is down.

What You'll Build

You type a single command, and within a second your terminal shows a full status report of every service you care about, with clear labels marking each one as healthy or failing.

By the end of this project, you'll have:

  • A working health checker CLI that you run with ./healthcheck and instantly see which of your configured services are up or down, complete with status codes.
  • Concurrent checks powered by goroutines that finish in under a second, instead of the 5+ seconds you would wait checking endpoints one by one.
  • A single compiled binary with zero runtime dependencies that you can copy to any Linux machine or integrate into a CI/CD pipeline using its exit codes.
  • Secret Mission: Add configurable per-endpoint timeouts and response time measurement for production-grade monitoring.

Are there any prerequisites?

You should have programming experience in a language like Python or JavaScript and be comfortable with the idea of HTTP requests and APIs.

You also need WSL Ubuntu 22.04 running on your machine. No prior Go experience is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Go and Your First Program

Go compiles your code into a single binary that runs without any runtime dependencies. Before you can write Go code, you need the Go toolchain installed on your system.

In this step, you will install Go 1.26.5, create your project directory, and write a minimal program that proves your entire toolchain works end to end.

In this step, get ready to:
  • Install Go 1.26.5 on WSL Ubuntu and verify it works.
  • Create a project directory and initialize a Go module.
  • Install the VS Code Go extension for syntax highlighting and autocompletion.
Download and install Go 1.26.5

Go is not pre-installed on WSL Ubuntu, so you need to download the official tarball from the Go team and extract it to /usr/local.

  • Remove any existing Go installation and download Go 1.26.5 by running these commands:
sudo rm -rf /usr/local/go
wget https://go.dev/dl/go1.26.5.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.5.linux-amd64.tar.gz
rm go1.26.5.linux-amd64.tar.gz

What do these commands do?

  • sudo rm -rf /usr/local/go removes any previous Go installation so the new one starts fresh.
  • wget downloads the official Go 1.26.5 tarball from go.dev.
  • sudo tar -C /usr/local extracts the tarball into /usr/local/go, which is Go's standard install location.
  • rm go1.26.5.linux-amd64.tar.gz cleans up the downloaded file since you no longer need it.
  • Add Go to your terminal's PATH so your shell can find the go command. Run these commands:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc

What does this do?

The first line permanently adds /usr/local/go/bin to your PATH by appending it to your ~/.bashrc file.

The second line reloads ~/.bashrc so the change takes effect immediately without opening a new terminal.

  • Verify Go is installed by running:
go version

What do you expect this command to print?

✔️ I see go1.26.5

You should see:

go version go1.26.5 linux/amd64

Go is installed and ready. Move on to the next substep.

ⓧ I see an older version

If you see a version number lower than 1.26.5, you likely have a system-installed Go from apt that is shadowing the new installation.

  • Remove the system Go by running:
sudo apt remove --purge golang-go golang -y
source ~/.bashrc
  • Run go version again to confirm you now see go version go1.26.5 linux/amd64.

ⓧ Command not found

If you see a "command not found" error, your PATH was not updated correctly.

  • Check that the Go binary exists by running:
ls /usr/local/go/bin/go
  • If that file exists, re-run the PATH command:
export PATH=$PATH:/usr/local/go/bin
  • Run go version again.
  • If the file does not exist, re-run the download and extract commands from the beginning of this substep.
Create your project and write your first Go program

Every Go project lives inside a module. A module is a directory with a go.mod file that declares the module's name and Go version.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a Go module by running these commands:
mkdir healthcheck
cd healthcheck
go mod init healthcheck

What does go mod init do?

go mod init healthcheck creates a go.mod file that declares this directory as a Go module named healthcheck. Every Go project needs this file so the Go toolchain knows how to build and manage dependencies.

  • Confirm the module was created by running:
cat go.mod

You should see output showing module healthcheck and go 1.26.5.

  • Open this project folder in VS Code by running:
code .

What does code . do in WSL?

This command launches VS Code and connects it to your WSL Ubuntu environment. The first time you run it, VS Code may download a small server component. After a moment, VS Code opens with your healthcheck folder as the workspace.

  • In VS Code, hover over the healthcheck folder in the Explorer sidebar and click the New File icon.
  • Name the file main.go and press Enter.
  • Paste the following code into main.go:
package main

import "fmt"

func main() {
	fmt.Println("Hello from healthcheck!")
}

What does this code do?

  • package main declares this file as part of the executable package. Every Go program starts here.
  • import "fmt" brings in Go's formatting package for printing to the terminal.
  • func main() is the entry point that runs when you execute the program.
  • Save main.go by pressing Ctrl+S.

Before you run this, what do you think you will see printed in the terminal?

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
  • Run your program:
go run main.go

You should see Hello from healthcheck! printed in the terminal. Your Go toolchain is working end to end.

Seeing an error instead of the greeting?

  • If you see go: cannot find main module, make sure your terminal is inside the healthcheck folder (run cd ~/Desktop/healthcheck).
  • If you see a syntax error, check that your main.go matches the code above exactly. Go is strict about braces and quotation marks.

help me fix my go run main.go error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and confirmed the greeting prints.

ⓧ I'd like to double check the full code

Here is what go.mod should look like:

module healthcheck

go 1.26.5

Here is what main.go should look like:

package main

import "fmt"

func main() {
	fmt.Println("Hello from healthcheck!")
}
Install the VS Code Go extension

The Go extension adds syntax highlighting, autocompletion, and error checking to VS Code as you write Go code.

  • In VS Code, click the Extensions icon in the left sidebar (or press Ctrl+Shift+X).
  • Type Go in the search bar.
  • Find the extension named Go published by Go Team at Google and click Install.

How do I know it is working?

After installing, click back on main.go in the Explorer sidebar. You should see the Go version displayed in the bottom-left status bar of VS Code. The extension may also prompt you to install gopls (the Go language server). Click Install All if prompted.

Extension not detecting Go?

  • Make sure your VS Code window title shows WSL: Ubuntu in the bottom-left corner. If it does not, press Ctrl+Shift+P and run WSL: Connect to WSL.
  • If the status bar does not show a Go version, press Ctrl+Shift+P and type Go: Install/Update Tools, then select all tools and click OK.

help me connect the VS Code Go extension to my WSL Go installation

Your Go toolchain is installed, your first program runs, and VS Code is ready with Go language support. Next up, you will use Go's standard library to make your first HTTP request and check whether a web endpoint is alive.

Check a Single Endpoint

Your Go toolchain is working and you have a running Hello World program. Now it is time to give your health checker its first real ability: pinging a URL and reporting whether the service behind it is alive.

Before tackling concurrency or config files, you need one solid HTTP check function. This step gets you a visible result immediately: your tool pings a URL and tells you if it is up or down.

In this step, get ready to:
  • Define data structures for endpoints and health check results.
  • Write a function that makes an HTTP request and returns a status.
  • Print a color-coded status line for a single endpoint.
Define your data structures

Every health check needs two pieces of information: what to check (an endpoint) and what happened when you checked it (a result). In Go, you model these as structs with named fields.

  • Switch back to main.go in VS Code.
  • Select all the existing code and replace it with the following:
package main

import (
	"fmt"
	"net/http"
	"time"
)

// Endpoint holds the name and URL of a service to check
type Endpoint struct {
	Name string
	URL  string
}

// Result holds the outcome of checking one endpoint
type Result struct {
	Endpoint   Endpoint
	Status     string
	StatusCode int
	Error      error
}

What does this code do?

You are importing three packages from Go's standard library:

  • fmt for formatted printing.
  • net/http for making HTTP requests.
  • time for setting request timeouts.

The Endpoint struct pairs a human-readable name with a URL. The Result struct captures everything about the check outcome: which endpoint, whether it is UP or DOWN, the HTTP status code, and any error that occurred.

Write the health check function

This function is the core of your tool. It creates an HTTP client, sends a GET request to the endpoint URL, and packages the response into a Result.

  • Below the Result struct (after the closing }), add the checkEndpoint function:
// checkEndpoint makes an HTTP GET request and returns the result
func checkEndpoint(ep Endpoint) Result {
	// Create a client with a 5-second timeout to avoid hanging forever
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	// Make the GET request
	resp, err := client.Get(ep.URL)
	if err != nil {
		return Result{
			Endpoint: ep,
			Status:   "DOWN",
			Error:    err,
		}
	}
	defer resp.Body.Close()

	// Treat any status code 400+ as DOWN
	status := "UP"
	if resp.StatusCode >= 400 {
		status = "DOWN"
	}

	return Result{
		Endpoint:   ep,
		Status:     status,
		StatusCode: resp.StatusCode,
	}
}

What does this function do?

  • http.Client with a 5-second Timeout means the request will give up and return an error if the server does not respond within 5 seconds.
  • If client.Get returns an error (network failure, timeout, DNS failure), the function immediately returns a DOWN result with the error attached.
  • defer resp.Body.Close() ensures the HTTP response body is cleaned up when the function exits, preventing resource leaks.
  • Any status code 400 or above (client errors and server errors) counts as DOWN.
Call the function and print the result

Now you need a main function that creates a test endpoint, calls checkEndpoint, and prints a formatted status line.

  • Below the checkEndpoint function, add the main function:
func main() {
	// Hardcode one endpoint for testing
	ep := Endpoint{Name: "HTTPBin OK", URL: "https://httpbin.org/status/200"}
	result := checkEndpoint(ep)

	// Print a formatted status line based on the result
	if result.Status == "UP" {
		fmt.Printf("[UP]   %s (%s) - status: %d\n", result.Endpoint.Name, result.Endpoint.URL, result.StatusCode)
	} else {
		if result.Error != nil {
			fmt.Printf("[DOWN] %s (%s) - error: %v\n", result.Endpoint.Name, result.Endpoint.URL, result.Error)
		} else {
			fmt.Printf("[DOWN] %s (%s) - status: %d\n", result.Endpoint.Name, result.Endpoint.URL, result.StatusCode)
		}
	}
}

What does this main function do?

  • It creates an Endpoint pointing at https://httpbin.org/status/200, a public test service that always returns a 200 OK response.
  • It calls checkEndpoint and branches on the Status field to print either an UP or DOWN line.
  • The output format uses [UP] or [DOWN] prefixes so you can spot problems at a glance.
  • Save main.go.

Before you run this, what do you expect to see? Will the endpoint be UP or DOWN?

  • Run your health checker by running this command:
go run main.go

You should see output like this:

[UP]   HTTPBin OK (https://httpbin.org/status/200) - status: 200

Your tool just made a real HTTP request, checked the response, and reported the result in a clean format.

Seeing an error instead?

  • If you see a compile error mentioning an undeclared name, check that your Endpoint and Result struct definitions appear above the checkEndpoint function in the file.
  • If you see imported and not used, make sure the checkEndpoint function and main function are both present. Go rejects unused imports.
  • If you see [DOWN] with a timeout error, your network may be blocking outbound requests. Try replacing the URL with https://www.google.com temporarily.

Help me debug my Go health checker compile error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and the output shows [UP].

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll up and confirm each one is present:

  • Package declaration and imports (fmt, net/http, time).
  • Endpoint struct with Name and URL string fields.
  • Result struct with Endpoint, Status, StatusCode, and Error fields.
  • checkEndpoint function that creates an http.Client with a 5-second timeout and returns a Result.
  • main function that hardcodes one endpoint and prints a formatted status line.

You have a working health check function that can tell you whether a single endpoint is alive. Next up, you will add multiple endpoints and discover what happens when one of them is slow.

Check Multiple Endpoints Sequentially

Your health checker works for a single endpoint. In a real-world scenario, you need to monitor multiple services at once.

In this step, you will add several endpoints to your tool, including one that is deliberately unreachable. When you run the checker sequentially, you will feel the tool freeze while it waits for that unreachable endpoint to time out. This pain is the reason Go developers reach for concurrency.

In this step, get ready to:
  • Add multiple hardcoded endpoints including a deliberately unreachable one.
  • Loop through endpoints sequentially and measure total elapsed time.
  • Observe the slow performance caused by blocking on timeouts.
Replace the single endpoint with a list of endpoints

Your current main function checks one endpoint. You will replace it with a slice of four endpoints that represent different real-world scenarios: a healthy service, a server error, a fast public site, and one that will never respond.

  • In main.go, find the func main() function.
  • Delete everything inside the curly braces of main (keep the func main() { line and the closing }).
  • Add this endpoint slice as the first thing inside main:
	endpoints := []Endpoint{
		{Name: "HTTPBin OK", URL: "https://httpbin.org/status/200"},
		{Name: "HTTPBin Server Error", URL: "https://httpbin.org/status/500"},
		{Name: "Google", URL: "https://www.google.com"},
		{Name: "Unreachable Service", URL: "http://192.0.2.1:9999"},
	}

What do these endpoints test?

  • HTTPBin OK returns a 200 status. This is your healthy baseline.
  • HTTPBin Server Error returns a 500 status. Your checker should report this as DOWN.
  • Google is a fast, reliable endpoint that responds quickly.
  • Unreachable Service uses IP 192.0.2.1 from the TEST-NET-1 range (RFC 5737). This address is guaranteed unreachable, so it will always time out after 5 seconds.
Add the sequential loop with timing

Now you need code that checks each endpoint one by one and measures how long the entire batch takes.

  • Add the following code directly below the endpoints slice you just pasted, still inside the main function:
	// Record the start time
	start := time.Now()

	// Check each endpoint one at a time
	var results []Result
	for _, ep := range endpoints {
		results = append(results, checkEndpoint(ep))
	}

	// Calculate total elapsed time
	elapsed := time.Since(start)

	// Print results for each endpoint
	for _, r := range results {
		if r.Status == "UP" {
			fmt.Printf("[UP]   %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
		} else {
			if r.Error != nil {
				fmt.Printf("[DOWN] %s (%s) - error: %v\n", r.Endpoint.Name, r.Endpoint.URL, r.Error)
			} else {
				fmt.Printf("[DOWN] %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
			}
		}
	}
	fmt.Printf("\nTotal elapsed: %s\n", elapsed.Round(time.Millisecond))

What does this code do?

  • time.Now() captures the moment before any checks begin. time.Since(start) calculates how much time passed since then.
  • The for _, ep := range endpoints loop calls checkEndpoint on each endpoint sequentially. The loop blocks on each call until it returns before moving to the next one.
  • elapsed.Round(time.Millisecond) rounds the duration to the nearest millisecond for a cleaner display.
  • Save main.go.

Seeing a compile error?

  • Make sure you deleted all the old code inside main before pasting the new code. Leftover lines from the previous version will cause conflicts.
  • Check that your imports still include "fmt", "net/http", and "time". If the Go extension removed one automatically, add it back.
  • help me fix a compile error in my Go health checker
Run the sequential checker and observe the delay

Your checker now pings four endpoints one after the other. Before you run it, think about how long you expect it to take. Three of the endpoints should respond quickly. One will never respond and will hit the 5-second timeout.

How long do you think the total run will take? Will the fast endpoints finish before the slow one starts?

  • Run your checker by executing this command:
go run main.go

Watch the terminal. You will see it pause for several seconds with no output while it waits for the unreachable endpoint to time out. The total elapsed time will be 5+ seconds.

Your output will look something like this:

[UP]   HTTPBin OK (https://httpbin.org/status/200) - status: 200
[DOWN] HTTPBin Server Error (https://httpbin.org/status/500) - status: 500
[UP]   Google (https://www.google.com) - status: 200
[DOWN] Unreachable Service (http://192.0.2.1:9999) - error: ...

Total elapsed: 5.2s

Why is this so slow?

The sequential loop processes endpoints one at a time. Each call to checkEndpoint must finish before the next one starts. The unreachable endpoint blocks the entire program for 5 seconds while the HTTP client waits for a response that will never come.

The three healthy endpoints only take a fraction of a second combined. One bad endpoint is holding the entire tool hostage. This is the exact problem that goroutines solve.

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.go and that your output shows all four endpoints plus a total elapsed time of 5+ seconds.

ⓧ I'd like to double check the full code

Here is the complete main.go at the end of this step. Make sure your file matches this exactly:

package main

import (
	"fmt"
	"net/http"
	"time"
)

type Endpoint struct {
	Name string
	URL  string
}

type Result struct {
	Endpoint   Endpoint
	Status     string
	StatusCode int
	Error      error
}
func checkEndpoint(ep Endpoint) Result {
	client := &http.Client{
		Timeout: 5 * time.Second,
	}

	resp, err := client.Get(ep.URL)
	if err != nil {
		return Result{
			Endpoint: ep,
			Status:   "DOWN",
			Error:    err,
		}
	}
	defer resp.Body.Close()

	status := "UP"
	if resp.StatusCode >= 400 {
		status = "DOWN"
	}

	return Result{
		Endpoint:   ep,
		Status:     status,
		StatusCode: resp.StatusCode,
	}
}
func main() {
	endpoints := []Endpoint{
		{Name: "HTTPBin OK", URL: "https://httpbin.org/status/200"},
		{Name: "HTTPBin Server Error", URL: "https://httpbin.org/status/500"},
		{Name: "Google", URL: "https://www.google.com"},
		{Name: "Unreachable Service", URL: "http://192.0.2.1:9999"},
	}

	start := time.Now()
	var results []Result
	for _, ep := range endpoints {
		results = append(results, checkEndpoint(ep))
	}
	elapsed := time.Since(start)

	for _, r := range results {
		if r.Status == "UP" {
			fmt.Printf("[UP]   %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
		} else {
			if r.Error != nil {
				fmt.Printf("[DOWN] %s (%s) - error: %v\n", r.Endpoint.Name, r.Endpoint.URL, r.Error)
			} else {
				fmt.Printf("[DOWN] %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
			}
		}
	}
	fmt.Printf("\nTotal elapsed: %s\n", elapsed.Round(time.Millisecond))
}

You have proven the problem. One unreachable endpoint makes your entire tool unacceptably slow. In the next step, you will fix this with goroutines to check all endpoints at the same time.

Add Goroutines for Concurrent Checks

In the previous step, you watched your tool crawl through each endpoint one at a time. A single timed-out endpoint held everything hostage for 5 seconds while the other checks sat idle.

Go solves this with goroutines. A goroutine is a lightweight thread managed by the Go runtime. You can fire thousands of them with almost no overhead. In this step, you will launch all health checks simultaneously so the total time drops to the duration of the single slowest request.

In this step, get ready to:
  • Replace the sequential loop with goroutines and a sync.WaitGroup to coordinate them.
  • Add a summary line and exit code so the tool is usable in CI/CD pipelines.
Replace the sequential loop with goroutines

The concurrent pattern needs two new packages from Go's standard library: sync for the WaitGroup and Mutex, and os for exit codes.

  • In main.go, find the import block at the top of the file:
import (
	"fmt"
	"net/http"
	"time"
)
  • Replace it with this updated import block that adds os and sync:
import (
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)

Now you will replace the sequential loop with a concurrent pattern. The idea: launch every check as its own goroutine, then wait for all of them to finish before printing results.

  • Inside the main function, find these lines (the sequential loop):
	var results []Result
	for _, ep := range endpoints {
		results = append(results, checkEndpoint(ep))
	}
	elapsed := time.Since(start)
  • Replace that entire block with the following concurrent version:
	// Pre-allocate a results slice with one slot per endpoint
	results := make([]Result, len(endpoints))
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i, ep := range endpoints {
		wg.Add(1)
		go func(index int, endpoint Endpoint) {
			defer wg.Done()
			result := checkEndpoint(endpoint)
			mu.Lock()
			results[index] = result
			mu.Unlock()
		}(i, ep)
	}

	wg.Wait()
	elapsed := time.Since(start)

What does this code do?

This is the classic Go concurrency pattern you will see in tools like Docker and Terraform:

  • wg.Add(1) tells the WaitGroup "one more goroutine is about to start."
  • go func(...){...}(i, ep) launches a new goroutine. Each one calls checkEndpoint independently.
  • defer wg.Done() decrements the counter when the goroutine finishes.
  • mu.Lock() and mu.Unlock() protect the shared results slice from race conditions when multiple goroutines write simultaneously.
  • wg.Wait() blocks until every goroutine has called Done(). Only then does the program continue to print results.

Why Add/Done instead of wg.Go()?

Go 1.25 introduced a convenience method called wg.Go(f func()) that wraps Add, the goroutine launch, and Done into one call. We use the explicit Add/Done pattern here because nearly every existing Go codebase (including Kubernetes and Docker source) uses it. Once you understand the mechanics, upgrading to wg.Go() is a one-line simplification.

Before you run this, what do you think will happen to the total elapsed time?

  • Save main.go and run the program:
go run main.go

The total elapsed time should now be roughly 5 seconds (the timeout for the unreachable endpoint) instead of the 20+ seconds you saw in the sequential version. All four checks ran at the same time.

Still seeing 20+ seconds?

  • Make sure you replaced the entire sequential block including the elapsed := time.Since(start) line. The new version has its own elapsed after wg.Wait().
  • Check that the go keyword appears before func(index int, endpoint Endpoint). Without it, the function runs sequentially.
  • Confirm your import block includes "sync". A missing import causes a compile error, not slow execution.

Help me debug my goroutines. My health checker is still running slowly after adding concurrent checks.

Add a summary line and exit code

Your concurrent checks work, but the output does not tell you at a glance how many endpoints are healthy. A summary count and a non-zero exit code make this tool usable in CI/CD pipelines where scripts check $? to decide whether to proceed.

  • In main.go, find the print loop and the final fmt.Printf line at the bottom of the main function:
	for _, r := range results {
		if r.Status == "UP" {
			fmt.Printf("[UP]   %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
		} else {
			if r.Error != nil {
				fmt.Printf("[DOWN] %s (%s) - error: %v\n", r.Endpoint.Name, r.Endpoint.URL, r.Error)
			} else {
				fmt.Printf("[DOWN] %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
			}
		}
	}
	fmt.Printf("\nTotal elapsed: %s\n", elapsed.Round(time.Millisecond))
  • Replace that entire section (from the for loop through the final Printf) with this version that counts healthy endpoints, prints a summary, and exits with code 1 if any are down:
	// Count healthy endpoints while printing results
	healthy := 0
	for _, r := range results {
		if r.Status == "UP" {
			fmt.Printf("[UP]   %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
			healthy++
		} else {
			if r.Error != nil {
				fmt.Printf("[DOWN] %s (%s) - error: %v\n", r.Endpoint.Name, r.Endpoint.URL, r.Error)
			} else {
				fmt.Printf("[DOWN] %s (%s) - status: %d\n", r.Endpoint.Name, r.Endpoint.URL, r.StatusCode)
			}
		}
	}

	fmt.Printf("\n%d/%d endpoints healthy (checked in %s)\n", healthy, len(endpoints), elapsed.Round(time.Millisecond))

	if healthy < len(endpoints) {
		os.Exit(1)
	}

What does this code do?

  • healthy starts at 0 and increments for every endpoint that responded with a 2xx status.
  • The summary line 2/4 endpoints healthy (checked in 5s) gives a quick pass/fail count at a glance.
  • os.Exit(1) fires only when at least one endpoint is down. CI/CD tools read this exit code to decide whether a deployment should proceed.

Before you run this, what exit code do you expect given your endpoint list includes a 500 and an unreachable service?

  • Save main.go and run the program:
go run main.go

You should see output showing 2 endpoints UP and 2 endpoints DOWN, a summary line like 2/4 endpoints healthy (checked in 5s), and the program exits with code 1.

  • Verify the exit code by running:
echo $?

You should see 1 printed, confirming the tool signals failure when endpoints are down.

Seeing a compile error?

  • If you see undefined: os, check that your import block at the top includes "os".
  • If you see healthy declared and not used, make sure the fmt.Printf summary line references healthy as the first format argument.
  • If you see duplicate elapsed errors, make sure you removed the old elapsed := time.Since(start) line when you replaced the sequential block.

Help me fix my compile error after adding the summary and exit code to my Go health checker.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that echo $? returned 1.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll through your file and confirm each is present:

  • Import block with fmt, net/http, os, sync, and time.
  • Endpoint struct with Name and URL fields.
  • Result struct with Endpoint, Status, StatusCode, and Error fields.
  • checkEndpoint function with a 5-second timeout HTTP client.
  • main function with 4 hardcoded endpoints, sync.WaitGroup + sync.Mutex concurrent loop, results printing with healthy counter, summary line, and os.Exit(1).

Your health checker now runs all checks in parallel. Next up, you will load endpoints from a JSON config file so you can reuse this tool across different projects without touching the code.

Load Endpoints from a JSON Config File

Your health checker fires all checks concurrently, proving that goroutines can slash wait time dramatically. But the endpoints are baked into the source code.

Hardcoded endpoints mean recompiling every time you add or remove a service. In this step, you'll load endpoints from a JSON config file, turning your tool into a reusable CLI you can point at any set of services just by swapping the config.

In this step, get ready to:
  • Create a JSON config file with your endpoint list.
  • Update main.go to read endpoints from the config file at runtime.
  • Compile the tool into a standalone binary and run it.
Create the JSON config file

Your config file defines the endpoints your tool checks. Each entry needs a name (for display) and a URL (for the HTTP request).

  • Create a new file called endpoints.json in your healthcheck project folder by running:
touch endpoints.json
  • Confirm the file was created by running:
ls

You should see endpoints.json, go.mod, and main.go listed.

  • Open endpoints.json in VS Code and paste the following content:
[
  {
    "name": "HTTPBin OK",
    "url": "https://httpbin.org/status/200"
  },
  {
    "name": "HTTPBin Server Error",
    "url": "https://httpbin.org/status/500"
  },
  {
    "name": "Google",
    "url": "https://www.google.com"
  },
  {
    "name": "Unreachable Service",
    "url": "http://192.0.2.1:9999"
  }
]

What does this config do?

Each object has a name field (what gets printed in the status report) and a url field (what your tool actually pings).

The array includes a mix: a healthy endpoint, a 500 error, a fast site, and an unreachable IP. This gives you a realistic range of results to validate against.

  • Save endpoints.json.
Update main.go to read from the config file

Now you need to teach your Go program how to read that JSON file instead of using hardcoded values. This requires three changes: adding the JSON import, adding struct tags so Go knows how to map JSON keys to struct fields, and replacing the hardcoded slice with file-reading logic.

  • In main.go, find your import block at the top of the file:
import (
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)
  • Add "encoding/json" as the first import, so the block looks like this:
import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)
  • Next, find the Endpoint struct:
type Endpoint struct {
	Name string
	URL  string
}
  • Add JSON struct tags so Go can map the lowercase JSON keys to your capitalized fields:
type Endpoint struct {
	Name string `json:"name"`
	URL  string `json:"url"`
}

What are struct tags?

Struct tags are metadata annotations in backticks after each field. They tell Go's encoding/json package how to map JSON keys (lowercase name, url) to struct fields (capitalized Name, URL).

Without these tags, json.Unmarshal would not correctly populate your struct from the JSON file.

  • Now find the beginning of your main function where the hardcoded endpoints are defined:
func main() {
	endpoints := []Endpoint{
		{Name: "HTTPBin OK", URL: "https://httpbin.org/status/200"},
		{Name: "HTTPBin Server Error", URL: "https://httpbin.org/status/500"},
		{Name: "Google", URL: "https://www.google.com"},
		{Name: "Unreachable Service", URL: "http://192.0.2.1:9999"},
	}

	start := time.Now()
  • Delete the entire hardcoded endpoints slice (from endpoints := []Endpoint{ through the closing }).
  • Replace it with this code that reads from a config file, keeping start := time.Now() on the line after:
	if len(os.Args) < 2 {
		fmt.Println("Usage: healthcheck <config-file>")
		fmt.Println("Example: healthcheck endpoints.json")
		os.Exit(1)
	}

	configFile := os.Args[1]

	data, err := os.ReadFile(configFile)
	if err != nil {
		fmt.Printf("Error reading config file: %v\n", err)
		os.Exit(1)
	}

	var endpoints []Endpoint
	err = json.Unmarshal(data, &endpoints)
	if err != nil {
		fmt.Printf("Error parsing config file: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Checking %d endpoints...\n\n", len(endpoints))

What does this code do?

  • os.Args[1] grabs the first command-line argument (the config filename). If no argument is provided, the tool prints a helpful usage message and exits.
  • os.ReadFile reads the entire file into a byte slice. If the file doesn't exist, the error is caught and printed.
  • json.Unmarshal parses the JSON byte slice into your []Endpoint slice. If the JSON is malformed, the error is caught and printed.
  • Save main.go.
  • Test the config-file loading by running:
go run main.go endpoints.json

You should see the same familiar output as before, but now the endpoints are loaded from your JSON file instead of hardcoded. Look for the new Checking 4 endpoints... line at the top.

Seeing an error?

  • If you see Error reading config file, make sure endpoints.json is in the same directory where you ran the command. Run ls to confirm.
  • If you see Error parsing config file, check for missing commas or extra trailing commas in your JSON. JSON does not allow trailing commas after the last item.
  • If all endpoints show as DOWN with empty names, make sure you added the struct tags (`json:"name"` and `json:"url"`) to the Endpoint struct.

Why isn't my health checker reading from the JSON config file?

  • Also try running without any argument to confirm your error handling works:
go run main.go

You should see the usage message: Usage: healthcheck <config-file>.

✔️ Awesome, I've got everything!

Great. Double check you saved main.go and endpoints.json before moving on.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll through your file and confirm each is present:

  • Import block with encoding/json, fmt, net/http, os, sync, time.
  • Endpoint struct with json:"name" and json:"url" struct tags.
  • Result struct (unchanged from before).
  • checkEndpoint function (unchanged from before).
  • main function starting with the os.Args check, then os.ReadFile, then json.Unmarshal, then the concurrent check logic and result printing.

Your endpoints.json should contain exactly this:

[
  {
    "name": "HTTPBin OK",
    "url": "https://httpbin.org/status/200"
  },
  {
    "name": "HTTPBin Server Error",
    "url": "https://httpbin.org/status/500"
  },
  {
    "name": "Google",
    "url": "https://www.google.com"
  },
  {
    "name": "Unreachable Service",
    "url": "http://192.0.2.1:9999"
  }
]
Build and run the standalone binary

One of Go's biggest advantages for DevOps tooling is that it compiles to a single binary with zero runtime dependencies. No interpreter, no virtual environment, no node_modules.

  • Compile your tool into a binary called healthcheck by running:
go build -o healthcheck
  • Confirm the binary was created by running:
ls -lh healthcheck

You should see the healthcheck file listed with a size around 6-7 MB. That single file contains everything needed to run your tool on any Linux machine.

Why is the binary several megabytes?

Go compiles statically by default, bundling the Go runtime and all standard library code your program uses into one file. The trade-off is a larger binary, but the benefit is zero deployment dependencies.

Before you run it, what do you expect to see? Will the output be any different from go run main.go endpoints.json?

  • Run the compiled binary by executing:
./healthcheck endpoints.json

The output is identical to go run. The difference is that this binary can be copied to any Linux machine and run without installing Go.

Permission denied?

  • If you see a permission error, make the binary executable by running chmod +x healthcheck and try again.
  • If you see Error reading config file, make sure you are running the command from the same directory that contains endpoints.json.

Why can't I run my compiled Go binary?

You now have a production-ready health checker CLI. It reads from a config file, checks all endpoints concurrently, and ships as a single portable binary. Next up, take on the Secret Mission to add response time measurement and configurable timeouts.

Secret mission

Add Timeouts and Response Time Measurement

Your health checker reports up or down, but a production tool needs more. How fast is each endpoint responding? Can you set different timeout thresholds for different services? In this secret mission, you will add per-endpoint configurable timeouts, millisecond-precision response time measurement, and a --verbose flag that reveals response headers.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on your WSL environment, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your files for future use or delete them entirely.

Resources you used:

  • Project directory on your Desktop containing main.go, endpoints.json, go.mod, and the compiled healthcheck binary.
  • Go 1.26.5 installation at /usr/local/go.
  • VS Code Go extension.

Keep everything running

No action needed. Choose this if you plan to keep using Go or want to extend your health checker tool further.

  • Your healthcheck binary is ready to use. Copy it to any Linux server or add it to a CI/CD pipeline script.
  • Go stays installed for future Go projects.
  • The VS Code Go extension continues providing syntax highlighting and autocompletion for any Go file you open.

Pause - I'll come back to this later

Nothing to shut down since there are no running processes or servers. Your files stay on disk until you need them again.

  • Your project directory and Go installation remain in place.
  • When you return, open a terminal in your project directory and run ./healthcheck endpoints.json to pick up where you left off.

Delete - I don't want to use this again

Remove all project files and optionally uninstall Go from your system.

  • Delete your project directory by running:
rm -rf ~/Desktop/healthcheck
  • To uninstall Go, remove the installation directory and clean up caches by running:
go clean -cache
go clean -modcache
sudo rm -rf /usr/local/go
  • Remove the Go PATH entry from your ~/.bashrc file. Open it in a text editor, find the line containing export PATH=$PATH:/usr/local/go/bin, and delete it. Then reload your shell by running:
source ~/.bashrc
  • To remove the VS Code Go extension, open VS Code, click the Extensions icon in the sidebar, search for Go, and click Uninstall.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-ready CLI health checker in Go, from a blank file to a compiled binary that monitors multiple services concurrently.

You learned how to:

  • Built a functional HTTP health checker using only Go's standard library, with no external dependencies.
  • Transformed a painfully slow sequential tool into a concurrent one using goroutines and sync.WaitGroup, experiencing the performance difference firsthand.
  • Compiled your tool to a single portable binary with zero runtime dependencies, ready to copy to any Linux machine or plug into a CI/CD pipeline.
  • Secret Mission: Added per-endpoint timeout configuration and response time measurement for production-grade monitoring.

Ready to quiz yourself?