Every developer has that moment: you write a quick script to check whether your APIs are alive, and six months later the whole team depends on it. The script grows flags, gains config files, and suddenly you're maintaining a tool that deserves proper packaging. If you've been there, Go is arguably the best language to rebuild it in.
In this tutorial, we'll build healthcheck -- a CLI tool that monitors API health endpoints, reports status, and exits with appropriate codes for CI/CD integration. Along the way, we'll cover project scaffolding with Cobra, configuration management with Viper, testing strategies, and distribution through GoReleaser and Homebrew. By the end, you'll have a repeatable playbook for shipping any CLI tool to production.
Why Go for CLI Tools
Before writing a single line of code, it's worth understanding why Go has become the de facto language for CLI tools. Tools like kubectl, docker, gh, terraform, and hugo are all written in Go, and that's no coincidence.
Single binary distribution. Go compiles to a statically linked binary with no runtime dependencies. Ship one file. No pip install, no node_modules, no JVM. Your users download it and run it.
Cross-compilation is trivial. Setting GOOS=linux GOARCH=arm64 before go build produces a Linux ARM binary from your Mac. No cross-compilation toolchains, no Docker gymnastics.
Fast startup. Go binaries start in milliseconds. Compare this to Python or Java CLI tools that spend hundreds of milliseconds just loading the interpreter or JVM. For a tool users invoke dozens of times a day, this matters.
Strong standard library. HTTP clients, JSON parsing, file I/O, concurrency primitives -- Go's standard library covers most of what a CLI tool needs without pulling in external dependencies.
Project Setup
Let's initialize the project:
mkdir healthcheck && cd healthcheck
go mod init github.com/youruser/healthcheck
Install the two libraries we'll use throughout:
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
Create the following directory structure:
healthcheck/
├── cmd/
│ ├── root.go
│ ├── check.go
│ └── version.go
├── internal/
│ ├── checker/
│ │ ├── checker.go
│ │ └── checker_test.go
│ └── config/
│ └── config.go
├── main.go
├── go.mod
└── go.sum
Bootstrapping with Cobra
Cobra provides a consistent structure for commands, flags, and help text. Start with the entry point:
// main.go
package main
import "github.com/youruser/healthcheck/cmd"
func main() {
cmd.Execute()
}
Now define the root command:
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "healthcheck",
Short: "Monitor API health endpoints",
Long: `healthcheck is a CLI tool that monitors API health endpoints,
reports their status, and exits with appropriate codes for CI/CD integration.`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default is $HOME/.healthcheck.yaml)")
rootCmd.PersistentFlags().IntP("timeout", "t", 5,
"HTTP timeout in seconds")
rootCmd.PersistentFlags().BoolP("verbose", "v", false,
"enable verbose output")
viper.BindPFlag("timeout", rootCmd.PersistentFlags().Lookup("timeout"))
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".healthcheck")
}
viper.SetEnvPrefix("HEALTHCHECK")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
if viper.GetBool("verbose") {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
}
A quick version command is always worth adding early. It gives users a way to report bugs with context, and we'll wire it into GoReleaser later:
// cmd/version.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// These are set at build time via ldflags.
var (
version = "dev"
commit = "none"
date = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("healthcheck %s (commit: %s, built: %s)\n", version, commit, date)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
Configuration with Viper
Viper shines when your tool needs to read configuration from multiple sources. It merges values from config files, environment variables, and flags with a clear precedence: flags override env vars, which override the config file.
Define a config structure in internal/config/config.go:
// internal/config/config.go
package config
import "github.com/spf13/viper"
type Endpoint struct {
Name string `mapstructure:"name"`
URL string `mapstructure:"url"`
Method string `mapstructure:"method"`
ExpectCode int `mapstructure:"expect_code"`
ExpectBody string `mapstructure:"expect_body"`
}
type Config struct {
Timeout int `mapstructure:"timeout"`
Verbose bool `mapstructure:"verbose"`
Endpoints []Endpoint `mapstructure:"endpoints"`
}
func Load() (*Config, error) {
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, err
}
// Apply defaults for endpoints.
for i := range cfg.Endpoints {
if cfg.Endpoints[i].Method == "" {
cfg.Endpoints[i].Method = "GET"
}
if cfg.Endpoints[i].ExpectCode == 0 {
cfg.Endpoints[i].ExpectCode = 200
}
}
return &cfg, nil
}
Here's a sample .healthcheck.yaml config file:
timeout: 10
verbose: false
endpoints:
- name: Production API
url: https://api.example.com/health
expect_code: 200
expect_body: '"status":"ok"'
- name: Staging API
url: https://staging-api.example.com/health
expect_code: 200
- name: Auth Service
url: https://auth.example.com/ping
method: HEAD
expect_code: 204
Gotcha: Viper's Unmarshal uses mapstructure tags, not json or yaml tags. This trips up nearly everyone the first time. If your fields aren't populating, check your struct tags.
Implementing the Health Checker
The core logic lives in internal/checker/checker.go. We'll make HTTP requests concurrently and collect results:
// internal/checker/checker.go
package checker
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
type Endpoint struct {
Name string
URL string
Method string
ExpectCode int
ExpectBody string
}
type Result struct {
Endpoint Endpoint
Status int
Duration time.Duration
Body string
Err error
Healthy bool
}
type Checker struct {
client *http.Client
verbose bool
}
func New(timeout time.Duration, verbose bool) *Checker {
return &Checker{
client: &http.Client{
Timeout: timeout,
},
verbose: verbose,
}
}
func (c *Checker) CheckAll(ctx context.Context, endpoints []Endpoint) []Result {
results := make([]Result, len(endpoints))
var wg sync.WaitGroup
for i, ep := range endpoints {
wg.Add(1)
go func(idx int, endpoint Endpoint) {
defer wg.Done()
results[idx] = c.check(ctx, endpoint)
}(i, ep)
}
wg.Wait()
return results
}
func (c *Checker) check(ctx context.Context, ep Endpoint) Result {
result := Result{Endpoint: ep}
req, err := http.NewRequestWithContext(ctx, ep.Method, ep.URL, nil)
if err != nil {
result.Err = fmt.Errorf("creating request: %w", err)
return result
}
req.Header.Set("User-Agent", "healthcheck-cli/1.0")
start := time.Now()
resp, err := c.client.Do(req)
result.Duration = time.Since(start)
if err != nil {
result.Err = fmt.Errorf("request failed: %w", err)
return result
}
defer resp.Body.Close()
result.Status = resp.StatusCode
// Read body (limit to 1MB to avoid memory issues).
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
result.Err = fmt.Errorf("reading body: %w", err)
return result
}
result.Body = string(bodyBytes)
// Evaluate health.
result.Healthy = result.Status == ep.ExpectCode
if ep.ExpectBody != "" && !strings.Contains(result.Body, ep.ExpectBody) {
result.Healthy = false
}
return result
}
func (r *Result) Summary() string {
icon := "OK"
if !r.Healthy {
icon = "FAIL"
}
if r.Err != nil {
return fmt.Sprintf("[%s] %-25s error: %v", "ERR ", r.Endpoint.Name, r.Err)
}
return fmt.Sprintf("[%s] %-25s status=%d time=%dms",
icon, r.Endpoint.Name, r.Status, r.Duration.Milliseconds())
}
Now wire it into the check command:
// cmd/check.go
package cmd
import (
"context"
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/youruser/healthcheck/internal/checker"
"github.com/youruser/healthcheck/internal/config"
)
var checkCmd = &cobra.Command{
Use: "check",
Short: "Check all configured health endpoints",
Long: `Sends HTTP requests to all configured endpoints and reports their health status.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if len(cfg.Endpoints) == 0 {
return fmt.Errorf("no endpoints configured; create a .healthcheck.yaml file or use --config")
}
timeout := time.Duration(cfg.Timeout) * time.Second
c := checker.New(timeout, cfg.Verbose)
// Convert config endpoints to checker endpoints.
endpoints := make([]checker.Endpoint, len(cfg.Endpoints))
for i, ep := range cfg.Endpoints {
endpoints[i] = checker.Endpoint{
Name: ep.Name,
URL: ep.URL,
Method: ep.Method,
ExpectCode: ep.ExpectCode,
ExpectBody: ep.ExpectBody,
}
}
ctx, cancel := context.WithTimeout(context.Background(), timeout+5*time.Second)
defer cancel()
results := c.CheckAll(ctx, endpoints)
// Print results.
unhealthy := 0
for _, r := range results {
fmt.Println(r.Summary())
if !r.Healthy {
unhealthy++
}
}
fmt.Printf("\n%d/%d endpoints healthy\n", len(results)-unhealthy, len(results))
if unhealthy > 0 {
os.Exit(1)
}
return nil
},
}
func init() {
rootCmd.AddCommand(checkCmd)
checkCmd.Flags().StringSliceP("url", "u", nil,
"check a single URL (can be repeated)")
viper.BindPFlag("urls", checkCmd.Flags().Lookup("url"))
}
You can now run:
go run . check --config .healthcheck.yaml
Output looks like:
[OK] Production API status=200 time=142ms
[FAIL] Staging API status=503 time=89ms
[OK] Auth Service status=204 time=34ms
2/3 endpoints healthy
The tool exits with code 1 if any endpoint is unhealthy, making it trivial to integrate into CI pipelines or shell scripts.
Adding Tests
A CLI tool without tests is a liability. Let's test the checker at two levels: unit tests with an HTTP test server, and an integration-style test for the result evaluation logic.
// internal/checker/checker_test.go
package checker
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestCheck_HealthyEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}))
defer server.Close()
c := New(5*time.Second, false)
ep := Endpoint{
Name: "Test API",
URL: server.URL,
Method: "GET",
ExpectCode: 200,
ExpectBody: `"status":"ok"`,
}
result := c.check(context.Background(), ep)
if result.Err != nil {
t.Fatalf("unexpected error: %v", result.Err)
}
if !result.Healthy {
t.Errorf("expected healthy, got unhealthy (status=%d)", result.Status)
}
if result.Duration == 0 {
t.Error("expected non-zero duration")
}
}
func TestCheck_UnhealthyStatusCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
c := New(5*time.Second, false)
ep := Endpoint{
Name: "Failing API",
URL: server.URL,
Method: "GET",
ExpectCode: 200,
}
result := c.check(context.Background(), ep)
if result.Err != nil {
t.Fatalf("unexpected error: %v", result.Err)
}
if result.Healthy {
t.Error("expected unhealthy, got healthy")
}
}
func TestCheck_BodyMismatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"degraded"}`))
}))
defer server.Close()
c := New(5*time.Second, false)
ep := Endpoint{
Name: "Degraded API",
URL: server.URL,
Method: "GET",
ExpectCode: 200,
ExpectBody: `"status":"ok"`,
}
result := c.check(context.Background(), ep)
if result.Healthy {
t.Error("expected unhealthy due to body mismatch")
}
}
func TestCheck_Timeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := New(100*time.Millisecond, false)
ep := Endpoint{
Name: "Slow API",
URL: server.URL,
Method: "GET",
ExpectCode: 200,
}
result := c.check(context.Background(), ep)
if result.Err == nil {
t.Error("expected timeout error")
}
}
func TestCheckAll_Concurrent(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := New(5*time.Second, false)
endpoints := []Endpoint{
{Name: "EP1", URL: server.URL, Method: "GET", ExpectCode: 200},
{Name: "EP2", URL: server.URL, Method: "GET", ExpectCode: 200},
{Name: "EP3", URL: server.URL, Method: "GET", ExpectCode: 200},
}
results := c.CheckAll(context.Background(), endpoints)
if len(results) != 3 {
t.Fatalf("expected 3 results, got %d", len(results))
}
for i, r := range results {
if !r.Healthy {
t.Errorf("endpoint %d: expected healthy", i)
}
}
}
func TestResult_Summary(t *testing.T) {
r := Result{
Endpoint: Endpoint{Name: "Test"},
Status: 200,
Duration: 150 * time.Millisecond,
Healthy: true,
}
summary := r.Summary()
if summary == "" {
t.Error("expected non-empty summary")
}
}
Run them with:
go test ./... -v -race
The -race flag is important for CLI tools. Since we're using goroutines in CheckAll, the race detector will catch data races that might only manifest under production load.
Tip: Always use httptest.NewServer for testing HTTP interactions in Go. It spins up a real HTTP server on localhost, which means your tests exercise the actual net/http stack -- including connection pooling, timeouts, and header parsing -- without hitting external services.
Cross-Platform Builds with GoReleaser
GoReleaser automates building binaries for every OS/architecture combination, creating GitHub releases, and generating checksums. Create a .goreleaser.yaml at the project root:
# .goreleaser.yaml
version: 2
project_name: healthcheck
before:
hooks:
- go mod tidy
- go test ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/youruser/healthcheck/cmd.version={{.Version}}
- -X github.com/youruser/healthcheck/cmd.commit={{.Commit}}
- -X github.com/youruser/healthcheck/cmd.date={{.Date}}
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^chore:"
brews:
- repository:
owner: youruser
name: homebrew-tap
directory: Formula
homepage: "https://github.com/youruser/healthcheck"
description: "CLI tool to monitor API health endpoints"
license: "MIT"
test: |
system "#{bin}/healthcheck", "version"
install: |
bin.install "healthcheck"
A few things worth noting in this configuration:
-
CGO_ENABLED=0ensures fully static binaries. Without this, Go might link against system C libraries, which breaks cross-compilation and causes "not found" errors on minimal Docker images. -
-s -wldflags strip debug info and DWARF symbols, reducing binary size by roughly 30%. -
The
ldflagsfor version inject build metadata at compile time, which is why ourversion.gousesvar version = "dev"-- GoReleaser replaces it with the actual git tag.
Test locally before pushing:
goreleaser release --snapshot --clean
This creates binaries in the dist/ directory without publishing anything.
When you're ready to release, tag and push:
git tag -a v1.0.0 -m "Initial release"
git push origin v1.0.0
If you have GoReleaser set up as a GitHub Action, the release is fully automatic. Here's the workflow file:
# .github/workflows/release.yaml
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Distribution via Homebrew
GoReleaser's brews section handles Homebrew formula generation automatically, but you need to set up the tap repository first.
- Create a public GitHub repository called
homebrew-tap. - Add a
GITHUB_TOKENsecret (or use a fine-grained PAT with write access to that repo) to your main project's repository secrets.
After GoReleaser runs, your users can install with:
brew tap youruser/tap
brew install healthcheck
Gotcha: The tap repository must be named homebrew-tap (with the homebrew- prefix) for brew tap youruser/tap to work. If you name it something else, users need to specify the full URL.
For non-Homebrew users, provide install instructions that download from GitHub releases:
# Linux/macOS one-liner
curl -sSL https://github.com/youruser/healthcheck/releases/latest/download/healthcheck_$(uname -s)_$(uname -m).tar.gz | tar xz
sudo mv healthcheck /usr/local/bin/
Practical Tips and Gotchas
Having built and shipped several Go CLI tools, here are the lessons that aren't in the documentation.
Use RunE, not Run. Cobra commands can use either Run (no error return) or RunE (returns an error). Always use RunE. It lets Cobra handle error formatting consistently, and you avoid scattered os.Exit calls that make testing harder.
Respect exit codes. Exit 0 for success, 1 for general failure, 2 for usage errors. CI tools, shell scripts, and monitoring systems depend on these. Cobra already exits 2 for unknown commands and bad flags -- don't override that behavior.
Support --output json. Even if you start with human-readable output, add JSON output early. It costs almost nothing to implement but makes your tool composable with jq, monitoring dashboards, and other automation:
type JSONOutput struct {
Results []ResultJSON `json:"results"`
Healthy int `json:"healthy"`
Unhealthy int `json:"unhealthy"`
Timestamp string `json:"timestamp"`
}
Don't ignore signals. Long-running checks should handle SIGINT and SIGTERM gracefully. Pass a context derived from signal.NotifyContext so that in-flight requests are cancelled cleanly:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Pin your CI Go version. Go's backwards compatibility is excellent, but subtle behavior changes between minor versions can cause flaky tests. Pin the version in your CI workflow and update deliberately.
Test your config loading. Config bugs are among the most common issues users report. Write tests that load config from a temp file and verify the merged result with flags and env vars.
Conclusion
We've gone from an empty directory to a production-ready CLI tool with proper configuration management, concurrent HTTP checks, comprehensive tests, automated cross-platform builds, and Homebrew distribution. The entire codebase is under 400 lines of Go.
This is the template I reach for every time I build a new CLI tool:
- Cobra for commands and flags
- Viper for configuration merging
-
internal/packages for business logic, decoupled from the CLI layer - Table-driven tests with
httptestservers - GoReleaser for builds and distribution
The beauty of this stack is that it scales. Whether your tool is a simple health checker or a complex deployment orchestrator, the structure remains the same. The internal/ package boundary keeps your logic testable and independent of the CLI framework, so you can swap Cobra for something else (or wrap the logic in an HTTP API) without rewriting your core code.
Go ahead and build something. Your team's next essential tool is one go mod init away.
Top comments (0)