DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Go CLI Tool with Cobra and Configuration Management

Most CLI tools in Go start the same way: a handful of flag.String calls and an os.Args slice you're hand-parsing two weeks later. It works until you need subcommands, persistent flags, or any real configuration — then it collapses. Cobra solves this, and Viper handles the configuration side, but combining them correctly takes a bit of setup you won't find in a single README.

Why Cobra (and Not Just flag)

The standard flag package is fine for simple tools, but it has no concept of subcommands. The moment your CLI needs myapp serve, myapp migrate, and myapp config set, you're implementing a command router by hand.

Cobra gives you:

  • Nested subcommands with inherited flags
  • Automatic --help generation
  • Shell completion (bash, zsh, fish, PowerShell)
  • Consistent error handling across commands

Get started:

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

The canonical structure for a Cobra project:

myapp/
├── cmd/
│   ├── root.go
│   ├── serve.go
│   └── config.go
├── internal/
│   └── config/
│       └── config.go
└── main.go
Enter fullscreen mode Exit fullscreen mode

main.go stays thin — just calls cmd.Execute().

// main.go
package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}
Enter fullscreen mode Exit fullscreen mode

Structuring Root and Subcommands

root.go defines the top-level command and persistent flags — flags available to every subcommand:

// cmd/root.go
package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var cfgFile string

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "A production-ready CLI tool",
    Long:  `myapp is a CLI tool that demonstrates Cobra + Viper 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: $HOME/.myapp.yaml)")
    rootCmd.PersistentFlags().String("log-level", "info", "log level: debug, info, warn, error")
    viper.BindPFlag("log_level", rootCmd.PersistentFlags().Lookup("log-level"))
}

func initConfig() {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, err := os.UserHomeDir()
        cobra.CheckErr(err)
        viper.AddConfigPath(home)
        viper.SetConfigType("yaml")
        viper.SetConfigName(".myapp")
    }

    viper.AutomaticEnv()
    viper.SetEnvPrefix("MYAPP")

    if err := viper.ReadInConfig(); err == nil {
        fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  • cobra.OnInitialize runs initConfig before any command executes — this is how Viper gets loaded before your command logic runs.
  • viper.SetEnvPrefix("MYAPP") means MYAPP_LOG_LEVEL maps to log_level in Viper's key space.
  • viper.AutomaticEnv() enables environment variable lookup automatically.

Now add a subcommand:

// cmd/serve.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
    "github.com/spf13/viper"
)

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the HTTP server",
    RunE:  runServe,
}

func init() {
    rootCmd.AddCommand(serveCmd)
    serveCmd.Flags().Int("port", 8080, "port to listen on")
    serveCmd.Flags().String("host", "0.0.0.0", "host to bind")
    viper.BindPFlag("server.port", serveCmd.Flags().Lookup("port"))
    viper.BindPFlag("server.host", serveCmd.Flags().Lookup("host"))
}

func runServe(cmd *cobra.Command, args []string) error {
    port := viper.GetInt("server.port")
    host := viper.GetString("server.host")
    logLevel := viper.GetString("log_level")

    fmt.Printf("Starting server on %s:%d (log level: %s)\n", host, port, logLevel)
    // actual server startup here
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Note RunE instead of Run — RunE lets you return an error, which Cobra will print and exit non-zero. Use RunE everywhere; Run swallows errors silently.

Configuration Priority: Flags > Env > File > Defaults

One of Viper's biggest wins is its precedence chain. When you call viper.GetInt("server.port"), Viper checks:

  1. --port flag (if bound and set explicitly)
  2. MYAPP_SERVER_PORT environment variable
  3. server.port in the config file
  4. Default value from viper.SetDefault

You get this for free — no conditional logic in your command code. To set defaults explicitly:

func init() {
    viper.SetDefault("server.port", 8080)
    viper.SetDefault("server.host", "0.0.0.0")
    viper.SetDefault("log_level", "info")
}
Enter fullscreen mode Exit fullscreen mode

A matching ~/.myapp.yaml config file:

log_level: debug

server:
  port: 9090
  host: 127.0.0.1

database:
  dsn: "postgres://user:pass@localhost/myapp"
  max_connections: 25
Enter fullscreen mode Exit fullscreen mode

With this setup, MYAPP_SERVER_PORT=3000 myapp serve overrides the file's 9090, and myapp serve --port 4000 overrides the env var.

Validating Configuration at Startup

Don't defer config errors to runtime. Validate required values in PersistentPreRunE:

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "A production-ready CLI tool",
    PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
        if viper.GetString("database.dsn") == "" {
            return fmt.Errorf("database.dsn is required (set via config file or MYAPP_DATABASE_DSN)")
        }
        return nil
    },
}
Enter fullscreen mode Exit fullscreen mode

PersistentPreRunE runs before every subcommand and after initConfig, so Viper is already populated. This is the right place to check required values, initialize loggers, or connect to a database.

One caveat: if a subcommand also defines PreRunE, Cobra does not chain them by default. You need to call the parent's PersistentPreRunE explicitly, or use a helper:

func chainPreRunE(parent, child func(*cobra.Command, []string) error) func(*cobra.Command, []string) error {
    return func(cmd *cobra.Command, args []string) error {
        if err := parent(cmd, args); err != nil {
            return err
        }
        return child(cmd, args)
    }
}
Enter fullscreen mode Exit fullscreen mode

This trips up a lot of Go CLI developers who expect automatic chaining — it isn't there by design.

Handling Sensitive Configuration Securely

Config files stored in home directories often end up in Git or shared filesystems. For credentials, you have two reasonable options:

Option 1: Accept secrets only via environment variables and document this clearly. Never write them to the YAML file.

Option 2: Integrate with a secrets backend. Viper supports remote config via viper.AddRemoteProvider (Consul, etcd). For Vault, fetch secrets at startup and inject them into Viper manually:

func loadVaultSecrets() {
    if dsn := os.Getenv("VAULT_INJECTED_DSN"); dsn != "" {
        viper.Set("database.dsn", dsn)
    }
}
Enter fullscreen mode Exit fullscreen mode

If you're shipping CLI tools to production environments, reviewing your secrets handling against a security hardening checklist before deploying saves more time than it costs.

The Takeaway

The Cobra + Viper combination is the Go CLI stack worth learning. The flag precedence chain (flag > env > file > default) eliminates an entire class of config bugs. The main things to get right:

  • Use RunE, not Run
  • Call cobra.OnInitialize for config loading — order matters
  • Bind flags to Viper keys explicitly with BindPFlag
  • Handle PersistentPreRunE chaining manually when subcommands need it
  • Keep credentials out of YAML config files entirely

The structure above scales from a 200-line tool to a 20-command CLI without rewrites.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)