DEV Community

Young Gao
Young Gao

Posted on

Building a Production-Ready CLI Tool with Go: From Zero to Distribution

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
Enter fullscreen mode Exit fullscreen mode

Install the two libraries we'll use throughout:

go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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"))
}
Enter fullscreen mode Exit fullscreen mode

You can now run:

go run . check --config .healthcheck.yaml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

Run them with:

go test ./... -v -race
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

A few things worth noting in this configuration:

  • CGO_ENABLED=0 ensures 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 -w ldflags strip debug info and DWARF symbols, reducing binary size by roughly 30%.
  • The ldflags for version inject build metadata at compile time, which is why our version.go uses var version = "dev" -- GoReleaser replaces it with the actual git tag.

Test locally before pushing:

goreleaser release --snapshot --clean
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

Distribution via Homebrew

GoReleaser's brews section handles Homebrew formula generation automatically, but you need to set up the tap repository first.

  1. Create a public GitHub repository called homebrew-tap.
  2. Add a GITHUB_TOKEN secret (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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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"`
}
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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:

  1. Cobra for commands and flags
  2. Viper for configuration merging
  3. internal/ packages for business logic, decoupled from the CLI layer
  4. Table-driven tests with httptest servers
  5. 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.


If this was helpful, you can support my work at ko-fi.com/nopkt


If this article helped you, consider buying me a coffee on Ko-fi! Follow me for more production backend patterns.

Top comments (0)