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
--helpgeneration - 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
The canonical structure for a Cobra project:
myapp/
├── cmd/
│ ├── root.go
│ ├── serve.go
│ └── config.go
├── internal/
│ └── config/
│ └── config.go
└── main.go
main.go stays thin — just calls cmd.Execute().
// main.go
package main
import "myapp/cmd"
func main() {
cmd.Execute()
}
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())
}
}
A few things worth noting here:
-
cobra.OnInitializerunsinitConfigbefore any command executes — this is how Viper gets loaded before your command logic runs. -
viper.SetEnvPrefix("MYAPP")meansMYAPP_LOG_LEVELmaps tolog_levelin 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
}
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:
-
--portflag (if bound and set explicitly) -
MYAPP_SERVER_PORTenvironment variable -
server.portin the config file - 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")
}
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
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
},
}
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)
}
}
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)
}
}
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, notRun - Call
cobra.OnInitializefor config loading — order matters - Bind flags to Viper keys explicitly with
BindPFlag - Handle
PersistentPreRunEchaining 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)