Every developer eventually writes a small script to automate something: rename a batch of files, ping a list of servers, generate a report from a log file. Bash gets you started, but once the script needs subcommands, flags, a config file, and decent error messages, it turns into an unmaintainable pile of case statements. This is exactly the gap Go fills. It compiles to a single static binary with no runtime to install, and its standard library plus a couple of well-known packages make building a proper CLI tool almost as fast as writing the Bash version.
This tutorial walks you through building a real command-line tool in Go using two libraries nearly every serious Go CLI relies on: Cobra, which handles commands, subcommands, and flags, and Viper, which handles configuration from files, environment variables, and flags in one unified way. By the end, you will have a working tool called healthcheck, a small utility that pings a list of HTTP endpoints and reports which ones are up, configured through a YAML file or environment variables, and compiled into a single binary you can drop on any Ubuntu server.
This guide is for developers who are comfortable with the Linux command line and have written some Go before (basic syntax, functions, structs) but have not built a CLI tool with Cobra or Viper yet. No prior experience with either library is required.
Conceptual Overview
Before writing code, it helps to understand what problem each library solves.
Cobra is a framework for building CLI applications with a command tree. Instead of manually parsing os.Args, you define a root command (the name of your binary) and attach subcommands to it, the same pattern used by tools like git, docker, and kubectl. Running git commit -m "message" maps to the commit subcommand of the git root command, with a -m flag attached to it. Cobra gives you this structure, automatic --help output, and shell autocompletion generation, without you writing any argument-parsing code by hand.
Viper is a configuration library that reads settings from multiple sources and merges them with a clear priority order: command-line flags override environment variables, which override a config file, which overrides hardcoded defaults. This matters in practice because you usually want different behavior in different environments. A default timeout baked into the binary is fine for local testing, a config file is convenient for a server you control, and an environment variable is what you actually want to set in a container or CI pipeline. Viper lets all three coexist without you writing separate parsing logic for each.
Why not just use flag from the standard library? The standard flag package works fine for a single-command tool with a handful of options. It has no concept of subcommands, no built-in way to read a YAML config file, and no environment variable binding. Cobra and Viper are not replacing the standard library, they are filling in exactly what it leaves out once your tool grows past a single command.
A quick note on naming: Cobra and Viper are separate libraries maintained by the same organization (spf13), and while they are commonly used together, Cobra does not require Viper. You could use Cobra alone for the command structure and read config yourself, but wiring them together is the standard pattern you will see in almost every production Go CLI.
Prerequisites
To follow along you will need:
- An Ubuntu 22.04 or 24.04 machine (a local VM or a cloud instance both work)
- Go 1.21 or newer installed
- Basic familiarity with Go syntax (functions, structs, error handling)
- A terminal with
sudoaccess to install packages
If Go is not installed yet, install it from the official tarball rather than the older apt package, which tends to lag behind:
cd /tmp
wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
Add Go to your PATH if it is not already there:
echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc
source ~/.bashrc
Confirm the install:
go version
You should see output like go version go1.22.5 linux/amd64.
Step-by-Step Hands-On Tutorial
Step 1: Scaffold the project
Create a directory for the project and initialize a Go module. The module path does not need to be a real, publicly reachable URL for local development, but using a realistic one keeps import paths sane if you ever push it to GitHub.
mkdir -p ~/projects/healthcheck
cd ~/projects/healthcheck
go mod init github.com/facsiaginsa/healthcheck
This creates a go.mod file that tracks your module name and dependencies. Every go get or go build from here on reads and updates this file.
Step 2: Install Cobra and Viper
go get github.com/spf13/cobra@latest
go get github.com/spf13/viper@latest
Cobra also ships a companion CLI generator, but for a small project it is just as easy, and more instructive, to wire the commands by hand, which is what this tutorial does.
Step 3: Build the root command
Create the directory structure Cobra projects typically use, with a cmd package holding all command definitions and main.go staying a thin entry point:
mkdir cmd
Create 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: "healthcheck pings a list of HTTP endpoints and reports their status",
Long: `healthcheck is a small CLI tool that reads a list of URLs from a
config file or environment variable, sends an HTTP GET to each one,
and prints whether it responded successfully.`,
}
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 ./healthcheck.yaml)")
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("healthcheck")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME/.config/healthcheck")
}
viper.SetEnvPrefix("HEALTHCHECK")
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Println("Using config file:", viper.ConfigFileUsed())
}
}
A few things are happening here worth explaining. rootCmd is the base command, the one invoked when you just run healthcheck with no subcommand. PersistentFlags() registers a flag that is inherited by every subcommand, which is exactly what you want for something like --config that should apply everywhere. cobra.OnInitialize(initConfig) registers a function that Cobra runs before any command executes, which is where Viper gets configured: it looks for healthcheck.yaml in the current directory or in ~/.config/healthcheck, and viper.AutomaticEnv() tells Viper to also check environment variables prefixed with HEALTHCHECK_.
Step 4: Add the check subcommand
This is where the actual work happens. Create cmd/check.go:
package cmd
import (
"fmt"
"net/http"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var timeoutSeconds int
var checkCmd = &cobra.Command{
Use: "check",
Short: "Check the status of all configured endpoints",
RunE: func(cmd *cobra.Command, args []string) error {
urls := viper.GetStringSlice("urls")
if len(urls) == 0 {
return fmt.Errorf("no URLs configured, set them in healthcheck.yaml or HEALTHCHECK_URLS")
}
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
for _, url := range urls {
start := time.Now()
resp, err := client.Get(url)
elapsed := time.Since(start).Round(time.Millisecond)
if err != nil {
fmt.Printf("[FAIL] %-45s error: %v\n", url, err)
continue
}
resp.Body.Close()
status := "OK"
if resp.StatusCode >= 400 {
status = "FAIL"
}
fmt.Printf("[%s] %-45s status=%d time=%s\n", status, url, resp.StatusCode, elapsed)
}
return nil
},
}
func init() {
checkCmd.Flags().IntVar(&timeoutSeconds, "timeout", 5, "request timeout in seconds")
rootCmd.AddCommand(checkCmd)
}
RunE (instead of Run) lets the command return an error, which Cobra prints and turns into a non-zero exit code automatically, useful for scripting and CI. viper.GetStringSlice("urls") reads the urls key from wherever it was configured, whether that is the YAML file, the HEALTHCHECK_URLS environment variable, or a default you set later. The --timeout flag is local to check, not persistent, since it does not make sense on other subcommands you might add later.
Step 5: Wire up main.go
At the project root, create main.go:
package main
import "github.com/facsiaginsa/healthcheck/cmd"
func main() {
cmd.Execute()
}
Keeping main.go this thin is a common Go convention: all real logic lives in packages that can be tested independently, main just wires things together.
Step 6: Create a config file
cat > healthcheck.yaml <<'EOF'
urls:
- https://api.example.com/health
- https://app.example.com/status
- https://cdn.example.com/ping
EOF
Step 7: Build and run
go build -o healthcheck .
./healthcheck check
You should see output similar to:
Using config file: /home/ubuntu/projects/healthcheck/healthcheck.yaml
[OK] https://api.example.com/health status=200 time=182ms
[FAIL] https://app.example.com/status status=503 time=94ms
[FAIL] https://cdn.example.com/ping error: dial tcp: lookup cdn.example.com: no such host
Try it with a custom timeout:
./healthcheck check --timeout 2
And try overriding the URL list with an environment variable instead of the config file, which is the pattern you would use in a container where mounting a YAML file is inconvenient:
unset urls 2>/dev/null
HEALTHCHECK_URLS="https://example.com" ./healthcheck check
Viper treats environment variables as comma-separated when binding to a string slice key like urls, so HEALTHCHECK_URLS="https://a.com,https://b.com" works as a way to pass multiple endpoints without a file at all.
Step 8: Cross-compile for deployment
One of Go’s strongest features for CLI tools is trivial cross-compilation, no separate toolchain or Docker image needed. To build a Linux binary from any machine, or explicitly target a different architecture:
GOOS=linux GOARCH=amd64 go build -o healthcheck-linux-amd64 .
GOOS=linux GOARCH=arm64 go build -o healthcheck-linux-arm64 .
GOOS and GOARCH are environment variables the Go compiler reads to pick the target platform. This means you can build a binary for an ARM-based server (like an AWS Graviton instance) directly from an x86 laptop, with no emulation required. Copy the resulting binary to a server and run it, there is no runtime, no pip install, no node_modules to manage.
scp healthcheck-linux-amd64 [email protected]:/usr/local/bin/healthcheck
ssh [email protected] "chmod +x /usr/local/bin/healthcheck && healthcheck check"
Common Mistakes & Troubleshooting
“no URLs configured” even though healthcheck.yaml exists. This almost always means the binary is being run from a different directory than the config file. Viper’s AddConfigPath(".") looks in the current working directory, not the directory the binary lives in. Either cd into the directory holding the config, use --config /full/path/to/healthcheck.yaml, or place the file in ~/.config/healthcheck/healthcheck.yaml since that path was also registered.
Flags defined but values not showing up in Viper. A flag registered with cmd.Flags().StringVar(...) populates a plain Go variable directly, it is not automatically visible to viper.Get() unless you explicitly bind it with viper.BindPFlag("timeout", checkCmd.Flags().Lookup("timeout")). In this tutorial the timeout is read straight from the bound Go variable, which is simpler for a single value, but if you want flags to participate in Viper’s full override chain (flag over env over file over default), you need that explicit binding.
Editing go.mod by hand and it stops matching reality. Never hand-edit the require lines in go.mod. Run go get <package>@<version> to add or change a dependency, and go mod tidy to remove ones you no longer import. Hand-editing frequently leaves go.sum out of sync, which causes confusing build failures about checksum mismatches.
Binary works locally but fails with “exec format error” on the server. This means you copied a binary built for the wrong architecture, commonly building on an Apple Silicon Mac (darwin/arm64) and deploying to a standard cloud VM (linux/amd64). Always set GOOS and GOARCH explicitly when building for a target that differs from your development machine.
Best Practices
- Keep
main.gominimal. All logic should live in testable packages. Amainfunction that only callscmd.Execute()keeps your command logic unit-testable without spinning up a process. - Use
RunEinstead ofRuneverywhere. Returning errors instead of callingos.Exitdeep inside a command keeps functions testable and lets Cobra handle consistent error formatting and exit codes. - Set sane defaults with
viper.SetDefault. Do not require users to write a config file just to run the tool with reasonable behavior; set defaults in code and let the file or environment override them only when needed. - Version your binary. Embed a version string at build time with
-ldflags "-X main.version=1.2.0"sohealthcheck --versionreports something meaningful instead of a placeholder, which matters once you are distributing binaries to more than one server. - Validate configuration early. Fail fast with a clear error message if a required config value is missing, rather than letting a nil slice or empty string cause a confusing failure three function calls later. This tool’s check for an empty
urlsslice in Step 4 is a small example of that principle. If your tool also needs to verify file integrity as part of its workflow, the same project structure works well for wrapping the techniques covered in Password Hashing in Golang with Bcrypt and Argon2 into a subcommand of its own. - Generate shell completion. Cobra can generate Bash, Zsh, and Fish completion scripts with a single built-in command (
rootCmd.AddCommand(completionCmd)using Cobra’s helper), which is a small addition that makes a CLI tool feel finished.
Conclusion
You built a real, working CLI tool in Go: a root command with a persistent --config flag, a check subcommand with its own local flag, and configuration that can come from a YAML file or environment variables through Viper, with a clear override order between them. You also compiled it into portable, dependency-free binaries for different architectures and deployed one to a remote server with nothing more than scp.
From here, natural next steps are adding more subcommands (a version command, a list command that just prints configured URLs without checking them), writing unit tests for the command logic by extracting it into testable functions, and setting up a GitHub Actions workflow that cross-compiles and attaches binaries to every tagged release. The pattern you used here, Cobra for structure and Viper for configuration, scales from a two-command personal tool all the way up to CLIs as large as kubectl, so what you learned in this small project transfers directly to much bigger ones.