Why Go for CLI Tools
Every time I need a CLI tool, I ask myself: "Could this be a Go binary?" The answer is almost always yes. Here's why:
- Single binary distribution — no runtime dependencies, no Docker needed, just copy and run
- Cross-compilation — build for Linux, macOS, and Windows from one machine
- Fast startup — Go CLI tools start in milliseconds, no warm-up needed
- Great ecosystem — Cobra for CLI framework, rich standard library
I've built 20+ CLI tools in Go for DevOps tasks. Here's how I structure them.
Project Setup with Cobra
package main
import (
"os"
"github.com/spf13/cobra"
)
func main() {
rootCmd := &cobra.Command{
Use: "mytool",
Short: "DevOps automation toolkit",
Long: "A comprehensive toolkit for automating deployment, monitoring, and maintenance tasks.",
}
// Add subcommands
rootCmd.AddCommand(deployCmd())
rootCmd.AddCommand(statusCmd())
rootCmd.AddCommand(backupCmd())
rootCmd.AddCommand(migrateCmd())
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
Real Example: Database Backup Tool
Here's a production-ready backup tool I built for managing PostgreSQL backups across multiple servers:
var backupCmd = &cobra.Command{
Use: "backup",
Short: "Backup database to local or cloud storage",
RunE: func(cmd *cobra.Command, args []string) error {
dbHost, _ := cmd.Flags().GetString("host")
dbName, _ := cmd.Flags().GetString("database")
storage, _ := cmd.Flags().GetString("storage")
compress, _ := cmd.Flags().GetBool("compress")
// Generate filename with timestamp
filename := fmt.Sprintf("%s_%s.sql", dbName, time.Now().Format("2006-01-02_15-04-05"))
if compress {
filename += ".gz"
}
fmt.Printf("Starting backup: %s → %s\n", dbName, filename)
// Execute pg_dump
var cmd *exec.Cmd
if compress {
cmd = exec.Command("pg_dump", "-h", dbHost, "-d", dbName, "-Fc", "--no-owner")
} else {
cmd = exec.Command("pg_dump", "-h", dbHost, "-d", dbName, "-Fp", "--no-owner")
}
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("pg_dump failed: %w", err)
}
// Upload to storage
switch storage {
case "s3":
return uploadToS3(filename, output)
case "gcs":
return uploadToGCS(filename, output)
default:
return os.WriteFile(filename, output, 0644)
}
},
}
func init() {
backupCmd.Flags().StringP("host", "H", "localhost", "Database host")
backupCmd.Flags().StringP("database", "d", "", "Database name (required)")
backupCmd.Flags().StringP("storage", "s", "local", "Storage backend: local, s3, gcs")
backupCmd.Flags().BoolP("compress", "z", true, "Compress backup")
backupCmd.MarkFlagRequired("database")
}
Real Example: Deployment Tool
A deployment tool that handles SSH, Docker, and service management:
var deployCmd = &cobra.Command{
Use: "deploy [environment]",
Short: "Deploy application to target environment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
env := args[0]
version, _ := cmd.Flags().GetString("version")
// Load environment config
config, err := loadEnvConfig(env)
if err != nil {
return fmt.Errorf("load config for %s: %w", env, err)
}
// Connect via SSH
sshClient, err := ssh.Connect(config.Host, config.KeyPath)
if err != nil {
return fmt.Errorf("connect to %s: %w", config.Host, err)
}
defer sshClient.Close()
// Execute deployment steps
steps := []DeployStep{
{Name: "Pull image", Cmd: fmt.Sprintf("docker pull %s:%s", config.Image, version)},
{Name: "Stop old container", Cmd: fmt.Sprintf("docker stop %s", config.ContainerName)},
{Name: "Start new container", Cmd: fmt.Sprintf("docker run -d --name %s %s:%s", config.ContainerName, config.Image, version)},
{Name: "Health check", Cmd: "curl -f http://localhost:8080/health", Retries: 10, Delay: 2 * time.Second},
}
for _, step := range steps {
fmt.Printf("[deploy] %s... ", step.Name)
if err := executeWithRetry(sshClient, step); err != nil {
fmt.Println("FAILED")
return fmt.Errorf("step '%s': %w", step.Name, err)
}
fmt.Println("OK")
}
fmt.Printf("\nDeploy v%s to %s completed successfully!\n", version, env)
return nil
},
}
Cross-Compilation
Build for all platforms from one machine:
# Linux (most common for servers)
GOOS=linux GOARCH=amd64 go build -o mytool-linux-amd64 ./cmd/mytool
# macOS (ARM)
GOOS=darwin GOARCH=arm64 go build -o mytool-darwin-arm64 ./cmd/mytool
# Windows
GOOS=windows GOARCH=amd64 go build -o mytool.exe ./cmd/mytool
# All at once
for os in linux darwin windows; do
for arch in amd64 arm64; do
GOOS=$os GOARCH=$arch go build -o mytool-${os}-${arch} ./cmd/mytool
done
done
Testing CLI Tools
Use cobra's testing utilities plus os/exec for integration tests:
func TestBackupCommand(t *testing.T) {
// Set up test database
db := setupTestDB(t)
defer db.Close()
// Execute command
cmd := backupCmd()
cmd.SetArgs([]string{"--host", "localhost", "--database", "testdb", "--storage", "local"})
err := cmd.Execute()
if err != nil {
t.Fatalf("backup command failed: %v", err)
}
// Verify backup file exists
files, _ := filepath.Glob("testdb_*.sql.gz")
if len(files) == 0 {
t.Fatal("no backup file created")
}
// Clean up
for _, f := range files {
os.Remove(f)
}
}
Pro Tips
- Use colors and spinners. Libraries like lipgloss and pterm make CLIs beautiful and user-friendly.
- Support --dry-run. Always let users preview what a command will do before executing.
- Write good --help text. Your CLI's help is its documentation. Make it comprehensive.
- Use config files. Support YAML/TOML config files for repeated operations. Don't make users type long flag strings every time.
- Add --output flag. Support JSON output for scripting and piping to other tools.
The Takeaway
Go CLI tools are the Swiss Army knife of DevOps. Fast to build, trivial to deploy, and incredibly reliable. If you're still writing shell scripts for complex automation, it's time to graduate to Go.