Building a 12-Factor App with Viper Integration
Have you ever hardcoded a port number, an API key, or a file path into a tool? It works fine on your machine, but then you need to share it. Suddenly, you’re editing code to change a setting for a colleague or recompiling to run in a different environment. This approach is brittle and doesn’t scale.
The Twelve-Factor App methodology offers a solution with its third factor: Config. It advises storing configuration in the environment, completely separate from your application code. For a command-line interface (CLI), this means creating a tool that is flexible and easy for both humans and automation to use.
A professional CLI should allow configuration to be specified from multiple sources with a clear order of precedence. A user might set a default in a config file, a CI/CD pipeline might override it with an environment variable, and a developer might override it again for a single run with a command-line flag.
In this tutorial, you’ll learn a powerful and reusable pattern to achieve this. We’ll use two of the most popular libraries in the Go ecosystem:
- Cobra for building a powerful command structure and parsing flags.
- Viper for handling configuration from multiple sources.
Together, they allow us to build CLIs that are a pleasure to use and maintain.
The Goal: A Clear Precedence
We will build a simple server application that needs to know which port to run on. Our goal is to source this port
configuration with the following priority, from highest to lowest:
- Command-line flag (e.g.,
--port 3000
) - Environment variable (e.g.,
MYAPP_PORT=9000
) - Configuration file (e.g.,
port: 8081
inconfig.yaml
) - A sensible default (e.g.,
8080
)
This hierarchy ensures that ephemeral, specific settings (like a flag) always win, while persistent, general settings (like a config file) provide a convenient baseline.
Prerequisites
Before we start, you should have:
- Go version 1.20 or newer installed.
- Basic familiarity with Go and creating a simple application.
- A shell environment (the examples use a Unix-like shell).
Step 1: Laying the Foundation
First, let’s create a new Go project and initialize it as a Cobra application.
# Create a directory for our project
mkdir myapp && cd myapp
# Initialize a Go module
go mod init [example.com/myapp](https://www.google.com/search?q=https://example.com/myapp)
# Install the Cobra generator
go install [github.com/spf13/cobra-cli@latest](https://www.google.com/search?q=https://github.com/spf13/cobra-cli%40latest)
# Create the basic application structure
cobra-cli init
The cobra-cli init
command scaffolds a simple application for us. Your project directory should now look like this:
.
├── cmd/
│ └── root.go
├── go.mod
└── main.go
Next, add Viper to our project. It will be the workhorse for managing our configuration values.
go get [github.com/spf13/viper@latest](https://www.google.com/search?q=https://github.com/spf13/viper%40latest)
Step 2: Defining Commands and Flags
Our application will have a serve
command that starts the server. We also want a global --config
flag so users can specify a configuration file.
Let’s create the serve
command first.
cobra-cli add serve
This creates a new file, cmd/serve.go
. Now, let’s edit cmd/root.go
and cmd/serve.go
to define our flags. For now, we are just defining them; we will wire them up to Viper in the next step.
Here is the cmd/serve.go
file. Notice we define the --port
flag with its default value. The RunE
function gets its configuration from Viper, not directly from the flag.
package cmd
import (
"fmt"
"[github.com/spf13/cobra](https://github.com/spf13/cobra)"
"[github.com/spf13/viper](https://github.com/spf13/viper)"
)
var serveCmd = \&cobra.Command{
Use: "serve",
Short: "Starts the server",
RunE: func(cmd \*cobra.Command, args []string) error {
// We get the configuration value from Viper, not from the flag directly.
port := viper.GetInt("port")
fmt.Printf("Starting server on port: %d\\n", port)
// In a real app, you would start a server here.
return nil
},
}
func init() {
rootCmd.AddCommand(serveCmd)
```
// Define a local flag for the 'serve' command.
serveCmd.Flags().Int("port", 8080, "Port to run the server on")
```
}
And here is the cmd/root.go
file, where we’ll add the persistent --config
flag.
package cmd
import (
"fmt"
"os"
"[github.com/spf13/cobra](https://github.com/spf13/cobra)"
)
var (
// Used for flags.
cfgFile string
```
rootCmd = &cobra.Command{
Use: "myapp",
Short: "A demo application for Cobra and Viper",
}
```
)
func Execute() {
if err := rootCmd.Execute(); err \!= nil {
os.Exit(1)
}
}
func init() {
// Add the persistent --config flag to the root command.
rootCmd.PersistentFlags().StringVar(\&cfgFile, "config", "", "config file (default is $HOME/.myapp.yaml)")
}
Step 3: The Heart of the Pattern: PersistentPreRunE
We need a single, central place to orchestrate our configuration logic. This logic must run after flags are parsed but before our command’s RunE
function executes. Cobra provides the perfect hook for this: PersistentPreRunE
.
By adding this function to our rootCmd
, we guarantee it runs for any subcommand the user executes. This is where we will tell Viper how to find and prioritize configuration. Let’s update cmd/root.go
with our complete configuration logic.
package cmd
import (
"errors"
"fmt"
"os"
"strings"
```
"[github.com/spf13/cobra](https://github.com/spf13/cobra)"
"[github.com/spf13/viper](https://github.com/spf13/viper)"
```
)
var (
cfgFile string
```
rootCmd = &cobra.Command{
Use: "myapp",
Short: "A demo application for Cobra and Viper",
// PersistentPreRunE is called after flags are parsed but before the
// command's RunE function is called.
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initializeConfig(cmd)
},
}
```
)
func Execute() {
if err := rootCmd.Execute(); err \!= nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func init() {
rootCmd.PersistentFlags().StringVar(\&cfgFile, "config", "", "config file (default locations: ., $HOME/.myapp/)")
}
func initializeConfig(cmd \*cobra.Command) error {
// 1. Set up Viper to use environment variables.
viper.SetEnvPrefix("MYAPP")
// Allow for nested keys in environment variables (e.g. `MYAPP_DATABASE_HOST`)
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "*", "-", "*"))
viper.AutomaticEnv()
```
// 2. Handle the configuration file.
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Search for a config file in default locations.
home, err := os.UserHomeDir()
// Only panic if we can't get the home directory.
cobra.CheckErr(err)
// Search for a config file with the name "config" (without extension).
viper.AddConfigPath(".")
viper.AddConfigPath(home + "/.myapp")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
// 3. Read the configuration file.
// If a config file is found, read it in. We use a robust error check
// to ignore "file not found" errors, but panic on any other error.
if err := viper.ReadInConfig(); err != nil {
// It's okay if the config file doesn't exist.
var configFileNotFoundError viper.ConfigFileNotFoundError
if !errors.As(err, &configFileNotFoundError) {
return err
}
}
// 4. Bind Cobra flags to Viper.
// This is the magic that makes the flag values available through Viper.
// It binds the full flag set of the command passed in.
err := viper.BindPFlags(cmd.Flags())
if err != nil {
return err
}
// This is an optional but useful step to debug your config.
fmt.Println("Configuration initialized. Using config file:", viper.ConfigFileUsed())
return nil
```
}
Why This Works
- Centralized Logic: All configuration is handled in one place,
initializeConfig
, making it easy to understand and modify. The logic is called fromPersistentPreRunE
so it always runs before any command logic. - Environment Variables First: By calling
viper.AutomaticEnv()
early, we set up the ability to read from the environment. TheSetEnvPrefix
andSetEnvKeyReplacer
calls ensure variables likeMYAPP_PORT
correctly map to theport
key. - Flexible Config File: The logic first checks for a user-provided
--config
file. If it’s missing, it gracefully searches in common locations (.
and$HOME/.myapp
). We explicitly ignore the “file not found” error, allowing the application to run without a config file. - Connecting Flags to Viper:
viper.BindPFlags(cmd.Flags())
is the crucial link. It tells Viper: “For any key you’re asked for, likeport
, first check if a Cobra flag namedport
was set. If so, use that value.” This is how flags get top priority. - Clean Command Code: Our command logic in
serve.go
remains pristine. It doesn’t need to know where theport
value came from. It just asks Viper:viper.GetInt("port")
.
Step 4: Seeing It All Work: Precedence in Action
Let’s test our new, robust configuration system. First, create a configuration file named config.yaml
in the root of your project.
port: 8081
Now, build the binary:
go build -o myapp
Let’s walk through the different scenarios to demonstrate the precedence order.
Case A: Using the Default Value
We run the command with no flags, environment variables, or config file.
# Run from a directory without config.yaml to test the default
(cd /tmp && /path/to/your/project/myapp serve)
Output:
Configuration initialized. Using config file:
Starting server on port: 8080
Result: We get the default value (8080
) defined in the flag in serve.go
.
Case B: Using the Config File
Now, run from our project directory where config.yaml
exists.
./myapp serve
Output:
Configuration initialized. Using config file: /path/to/your/project/config.yaml
Starting server on port: 8081
Result: The value from the file (8081
) overrides the default (8080
).
Case C: Using an Environment Variable
The environment variable takes precedence over the config file.
MYAPP\_PORT=9000 ./myapp serve
Output:
Configuration initialized. Using config file: /path/to/your/project/config.yaml
Starting server on port: 9000
Result: The environment variable (9000
) overrides the file value (8081
).
Case D: Using a Command-Line Flag
The flag is the ultimate override, winning against all other sources.
./myapp serve --port=3000
Output:
Configuration initialized. Using config file: /path/to/your/project/config.yaml
Starting server on port: 3000
Result: The flag’s value (3000
) overrides all other sources.
Conclusion: A Pattern for Growth
You’ve now implemented a professional configuration pattern that makes your Go CLIs robust, predictable, and easy to use. 🚀
By using Cobra’s PersistentPreRunE
hook to orchestrate Viper, you have:
- A single source of truth for configuration values in your application logic (e.g.,
viper.Get...
). - A clear and logical precedence: Flags > Environment Variables > Config File > Defaults.
- A clean separation between your application’s code and its configuration.
This pattern is the foundation for building much larger applications. You can add more commands, define more flags, and expand your config.yaml
with nested keys (e.g., database.host
). For even larger projects, consider moving the initializeConfig
function and related logic into its own config
package to further separate concerns. The core logic remains the same, providing a solid, testable, and maintainable base for your project’s future.