How to manage configuration settings in Go web applications

Published on:

When I'm building a web application in Go, I prefer to use command-line flags to pass configuration settings to the application at runtime. But sometimes, the client I'm working with wants to use environment variables to store configuration settings, or the nature of the project means that storing settings in a TOML, YAML or JSON file is a better fit. And of course that's OK — it makes sense to be flexible and vary how configuration is managed based on the specific needs of a project and/or client.

So, in this tutorial, I want to share the patterns that I use for parsing configuration settings — whether they come from flags, environment variables or files — and explain how I pass the settings onwards to where they are needed in the rest of the web application code. I'll also end with a short discussion about the relative pros and cons of the different approaches.

It's a fairly detailed post, so here are the shortcut links for quick reference:

Example code

To illustrate the patterns in the rest of this tutorial, let's pretend that we have a web application where we want to configure the following five settings:

Setting Type Description
port int The port number the web application listens on
verboseLogging bool Enables detailed request and error logging
requestTimeout time.Duration Maximum duration to wait for a request to complete
basicAuthUsername string Username required for HTTP Basic Authentication
basicAuthPassword string Password required for HTTP Basic Authentication

Regardless of where the configuration settings are coming from (flags, environment variables or a file), I'm quite strict about keeping all the code related to configuration settings isolated in one place, and reading in the configuration setting values right at the start of the program, before doing almost anything else.

Most of the time, I prefer to store all the configuration setting values in a single config struct, like so:

type config struct {
    port           int
    verboseLogging bool
    requestTimeout time.Duration
    basicAuth      struct {
        username string
        password string
    }
}

I like this because it feels very clear — all the configuration settings are contained in a single struct, along with their appropriate Go type, and you can easily see at a glance what configuration settings the application expects and supports.

Using command-line flags

As I mentioned at the start of this tutorial, using command-line flags with the standard library flag package is my preferred approach to managing configuration settings. With this approach, you explicitly pass the configuration values as part of the command when running the program. For example:

$ go run main.go -port=9999 -verbose-logging=true -request-timeout=10s -basic-auth-username=admin -basic-auth-password="secr3tPa55word"

In your Go code, you define a specific command-line flag using syntax like this:

flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on")`

In this example code, we define a command-line flag named port that accepts an integer value and stores it at the location pointed to by the &cfg.port pointer. It will have a default value of 4000 if no corresponding -port flag is provided when starting the application, and the final parameter is a description that will be displayed when a user runs the program with the -help flag.

Importantly, after you've defined all the command-line flags for your application, you need to call the flag.Parse() function to actually read in the values from the command-line arguments.

Let's put this together in a very simple application that reads the command-line flag values into a config struct, and then prints them out.

File: main.go
package main

import (
    "flag"
    "fmt"
    "time"
)

// The config struct holds all configuration settings for the application.
type config struct {
    port           int
    verboseLogging bool
    requestTimeout time.Duration
    basicAuth      struct {
        username string
        password string
    }
}

func main() {
    // Create a new config instance.
    var cfg config

    // Define the command-line flags. Notice that we define these so that the values 
    // are read directly into the appropriate config struct field, and set sensible default 
    // values for each of them.
    flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on")
    flag.BoolVar(&cfg.verboseLogging, "verbose-logging", false, "Enables detailed request and error logging")
    flag.DurationVar(&cfg.requestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete")
    flag.StringVar(&cfg.basicAuth.username, "basic-auth-username", "", "Username required for HTTP Basic Authentication")
    flag.StringVar(&cfg.basicAuth.password, "basic-auth-password", "", "Password required for HTTP Basic Authentication")

    // Parse the flags with the flag.Parse function. This is important!
    flag.Parse()

    // Print all configuration settings.
    fmt.Printf("Port: %d\n", cfg.port)
    fmt.Printf("Verbose Logging: %t\n", cfg.verboseLogging)
    fmt.Printf("Request Timeout: %v\n", cfg.requestTimeout)
    fmt.Printf("Basic Auth Username: %s\n", cfg.basicAuth.username)
    fmt.Printf("Basic Auth Password: %s\n", cfg.basicAuth.password)
}

If you're following along, go ahead and run the application with your own values in the command-line flags. You should see the same values printed out by the application, like so:

$ go run main.go -port=9999 -verbose-logging=true -request-timeout=30s -basic-auth-username=admin -basic-auth-password="secr3tPa55word"
Port: 9999
Verbose Logging: true
Request Timeout: 30s
Basic Auth Username: admin
Basic Auth Password: secr3tPa55word

If you don't provide a value for a specific flag, the application will revert to using the default value you specified. For example, if you don't provide a -port flag it will default to the value of 4000, like so:

$ go run main.go -basic-auth-username=admin -basic-auth-password="secr3tPa55word"
Port: 4000
Verbose Logging: false
Request Timeout: 5s
Basic Auth Username: admin
Basic Auth Password: secr3tPa55word

Help text

One of the great things about the standard library flag package is the support for automatic help text. If you run your application with the flag -help, it will list all the available flags for the application, along with their accompanying help text and default values if appropriate. Like so:

$ go run main.go -help
Usage of /tmp/go-build2103583960/b001/exe/main:
  -basic-auth-password string
        Password required for HTTP Basic Authentication
  -basic-auth-username string
        Username required for HTTP Basic Authentication
  -port int
        The port number the web application listens on (default 4000)
  -request-timeout duration
        Maximum duration to wait for a request to complete (default 5s)
  -verbose-logging
        Enables detailed request and error logging

Boolean flags

For boolean flags, if you want to pass a value of true you can simply include the flag name without assigning a value. The following two commands are equivalent:

$ go run main.go -verbose-logging=true
$ go run main.go -verbose-logging

In contrast, you always need to use -flag=false if you want to set a boolean flag value to false.

Dashes

You can use one or two dashes in front of a flag name, both work identically. The standard library flag package does not support 'short' flags, and the number of dashes has no effect on the behavior or any special meaning. So it's just a matter of personal taste which you use. The following two commands are equivalent:

$ go run main.go -verbose-logging -request-timeout=30s
$ go run main.go --verbose-logging --request-timeout=30s

Invalid flags

If you try to pass an invalid value as a command-line flag, the application will automatically exit with an error message and the help text for reference. For example, if you try to pass a non-integer value in the -port flag, the parsing would fail and the output would look like this:

$ go run main.go -port=foobar
invalid value "foobar" for flag -port: parse error
Usage of /tmp/go-build2103583960/b001/exe/main:
  -basic-auth-password string
        Password required for HTTP Basic Authentication
  -basic-auth-username string
        Username required for HTTP Basic Authentication
  -port int
        The port number the web application listens on (default 4000)
  -request-timeout duration
        Maximum duration to wait for a request to complete (default 5s)
  -verbose-logging
        Enables detailed request and error logging
exit status 2

Similarly, if you try to use a flag that as not been defined, the application will automatically exit with an error message and the help text. For example:

$ go run main.go -foobar=baz
flag provided but not defined: -foobar
...etc

Custom flag types

The flag package provides functions for reading command-line flag values into the following Go types: bool, int, int64, uint, uint64, float64, string and time.Duration.

If you want to parse a command-line flag value into another Go type (such as time.Time or []string), you have a few different options. The simplest approach is to use the flag.Func() function, which I've written about here. Or you can also make your own custom type that implements the flag.Value or encoding.TextUnmarshaler interfaces, and define the flag using either the flag.Var() or flag.TextVar() functions respectively. I've shared a gist demonstrating how to do this here.

Alternatively, there are third-party packages (such as spf13/viper) that you can use, which automatically support parsing command-line flags into a wider range of Go types. Personally, I've never felt it necessary to use these, but YMMV.

Flagsets

Lastly, if you want you can create flagsets, which act like a 'container' for a distinct set of command-line flags. It's rare that I need to use flagsets in a web application, but I do often use them when building CLI applications with multiple subcommands. There's a good tutorial about how to use flagsets here.

Using environment variables

First, I'll start by saying that you can use environment variables in conjunction with command-line flags if you want. Simply set your environment variables as normal, and use them in the command when starting your application. Like so:

$ export VERBOSE_LOGGING="true"
$ export REQUEST_TIMEOUT="30s"
$ go run main.go -verbose-logging=$VERBOSE_LOGGING -request-timeout=$REQUEST_TIMEOUT

But if you don't want to do this, you can read the values from environment variables directly into your Go code using the os.Getenv() function. This will return the value of the environment variable as a string, or the empty string "" if the environment variable doesn't exist. You can also use the os.LookupEnv() function to check whether a specific environment variable exists or not.

To help read values from environment variables, I like to create an internal/env package containing some helper functions that convert the environment variable string to the appropriate Go type, and optionally set a default value for if the environment variable doesn't exist (just like command-line flags). For example:

File: internal/env/env.go
package env

import (
    "fmt"
    "os"
    "strconv"
    "time"
)

func GetInt(key string, defaultValue int) int {
    value, exists := os.LookupEnv(key)
    if !exists {
        return defaultValue
    }

    intValue, err := strconv.Atoi(value)
    if err != nil {
        panic(fmt.Errorf("environment variable %s=%q cannot be converted to an int", key, value))
    }
    return intValue
}

func GetBool(key string, defaultValue bool) bool {
    value, exists := os.LookupEnv(key)
    if !exists {
        return defaultValue
    }

    boolValue, err := strconv.ParseBool(value)
    if err != nil {
        panic(fmt.Errorf("environment variable %s=%q cannot be converted to a bool", key, value))
    }
    return boolValue
}

func GetDuration(key string, defaultValue time.Duration) time.Duration {
    value, exists := os.LookupEnv(key)
    if !exists {
        return defaultValue
    }

    durationValue, err := time.ParseDuration(value)
    if err != nil {
        panic(fmt.Errorf("environment variable %s=%q cannot be converted to a time.Duration", key, value))
    }
    return durationValue
}

func GetString(key string, defaultValue string) string {
    value, exists := os.LookupEnv(key)
    if !exists {
        return defaultValue
    }
    return value
}

In some projects, I use a twist on these helper functions and panic if a specific environment variable isn't set, rather than returning a default value. For example:

func MustGetInt(key string) int {
    value, exists := os.LookupEnv(key)
    if !exists {
        panic(fmt.Errorf("environment variable %s must be set", key))
    }

    intValue, err := strconv.Atoi(value)
    if err != nil {
        panic(fmt.Errorf("environment variable %s=%q cannot be converted to an int", key, value))
    }
    return intValue
}

Using those helper functions in your application then looks a bit like this:

File: main.go
package main

import (
    "fmt"
    "time"

    "your-project/internal/env"
)

type config struct {
    port           int
    verboseLogging bool
    requestTimeout time.Duration
    basicAuth      struct {
        username string
        password string
    }
}

func main() {
    var cfg config

    cfg.port = env.GetInt("PORT", 4000)
    cfg.verboseLogging = env.GetBool("VERBOSE_LOGGING", false)
    cfg.requestTimeout = env.GetDuration("REQUEST_TIMEOUT", 5*time.Second)
    cfg.basicAuth.username = env.GetString("BASIC_AUTH_USERNAME", "")
    cfg.basicAuth.password = env.GetString("BASIC_AUTH_PASSWORD", "")

    fmt.Printf("Port: %d\n", cfg.port)
    fmt.Printf("Verbose Logging: %t\n", cfg.verboseLogging)
    fmt.Printf("Request Timeout: %v\n", cfg.requestTimeout)
    fmt.Printf("Basic Auth Username: %s\n", cfg.basicAuth.username)
    fmt.Printf("Basic Auth Password: %s\n", cfg.basicAuth.password)
}

If you'd like to try this out, go ahead and add the necessary environment variables to your /etc/environment or ~/.profile files, or export them in your shell, and try running the application again. You should see the configuration settings reflected in the output, or any default values for ones that you didn't set.

$ export PORT="9999"
$ export VERBOSE_LOGGING="false"
$ export BASIC_AUTH_USERNAME="admin"
$ export BASIC_AUTH_PASSWORD="secr3tPa55word"
$ go run main.go 
Port: 9999
Verbose Logging: false
Request Timeout: 5s
Basic Auth Username: admin
Basic Auth Password: secr3tPa55word

Using .env files

If you're working on multiple projects on the same development machine (and not using separate containers for each project), it can become awkward to manage environment variables and avoid clashes across the projects. Rather than setting environment variables in /etc/environment or ~/.profile, a fairly common workaround is to create an .env file in your project containing the environment variables, like so:

File: .env
export PORT=5000
export VERBOSE_LOGGING=true
export REQUEST_TIMEOUT=10s
export BASIC_AUTH_USERNAME=admin
export BASIC_AUTH_PASSWORD=secr3tPa55word

Then you can source the .env file to export the variables in the current terminal session and run your Go application:

$ source .env 
$ go run main.go 
Port: 5000
Verbose Logging: true
Request Timeout: 10s
Basic Auth Username: admin
Basic Auth Password: secr3tPa55word  

Alternatively, if you don't want to keep running the source command, you can use the joho/godotenv package to automatically load the values from the .env file into the environment when your application starts up.

Using configuration files

The third option that I sometimes use is configuration files, which store all the settings in a single file on-disk. I normally only use these in projects where there are a lot of configuration settings, and loading them all via command-line flags would be onerous and error-prone. Or also, if the configuration settings are complex, with a deeply nested 'structure' to them.

There are a lot of different formats that you can use for configuration files, such as TOML or YAML — or even JSON. They all have different advantages and disadvantages, and you'll be hard-pressed to find one that everybody agrees is 'perfect'. But whatever format you choose, there is probably a Go package that you can use to automatically parse values from the file into a config struct for you.

For example, let's say that you want to use TOML and have a configuration file that looks like this:

File: config.toml
# Server configuration
port = 4000
verbose_logging = true
request_timeout = "10s"

# Basic authentication settings
[basic_auth]
username = "admin"
password = "secr3tPa55word"

You can use the BurntSushi/toml package to read the file and unpack the contents to a config struct like so:

File: main.go
package main

import (
    "fmt"
    "log"
    "time"

    "github.com/BurntSushi/toml"
)

// Make sure the struct fields are exported, so that the BurntSushi/toml package
// can write to them, and use struct tags to map the TOML key/value pairs to the
// appropriate struct field.
type config struct {
    Port           int           `toml:"port"`
    VerboseLogging bool          `toml:"verbose_logging"`
    RequestTimeout time.Duration `toml:"request_timeout"`
    BasicAuth      struct {
        Username string `toml:"username"`
        Password string `toml:"password"`
    } `toml:"basic_auth"`
}

func main() {
    var cfg config

    // Load configuration settings from the config.toml file.
    metadata, err := toml.DecodeFile("config.toml", &cfg)
    if err != nil {
        log.Fatalf("error loading configuration: %v", err)
    }

    // Check for any undecoded keys in the config.toml file.
    if len(metadata.Undecoded()) > 0 {
        log.Fatalf("unknown configuration keys: %v", metadata.Undecoded())
    }

    fmt.Printf("Port: %d\n", cfg.Port)
    fmt.Printf("Verbose Logging: %t\n", cfg.VerboseLogging)
    fmt.Printf("Request Timeout: %v\n", cfg.RequestTimeout)
    fmt.Printf("Basic Auth Username: %s\n", cfg.BasicAuth.Username)
    fmt.Printf("Basic Auth Password: %s\n", cfg.BasicAuth.Password)
}

Notice that in this code we're making use of the metadata returned by the toml.DecodeFile() function to check if any settings were not decoded successfully — which should help to catch typos or invalid keys in the TOML file.

Passing settings to where they are needed

Getting the configuration settings into the config struct, wherever they come from, is the first half of the puzzle. The second part is getting those settings to where you need them in your Go code. There are many different ways to approach this, and no single 'right' way.

For small or medium sized web applications, I often use a pattern of creating an application struct which contains all the dependencies that my HTTP handlers need, and I implement the handlers as methods on the application struct. To make the configuration settings available to the HTTP handlers, I simply include the config struct as a field in application.

For example:

File: main.go
package main

import (
    "flag"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "time"
)

type config struct {
    port           int
    verboseLogging bool
    requestTimeout time.Duration
    basicAuth      struct {
        username string
        password string
    }
}

// The application struct contains the dependencies for the handlers, including 
// the config struct
type application struct {
    config config
    logger *slog.Logger
}

func main() {
    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    var cfg config
    flag.IntVar(&cfg.port, "port", 4000, "The port number the web application listens on")
    flag.BoolVar(&cfg.verboseLogging, "verbose-logging", false, "Enables detailed request and error logging")
    flag.DurationVar(&cfg.requestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete")
    flag.StringVar(&cfg.basicAuth.username, "basic-auth-username", "", "Username required for HTTP Basic Authentication")
    flag.StringVar(&cfg.basicAuth.password, "basic-auth-password", "", "Password required for HTTP Basic Authentication")
    flag.Parse()

    app := &application{
        config: cfg,
        logger: logger,
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/", app.home)

    // Use the port configuration setting
    logger.Info("starting server", "port", cfg.port)

    err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.port), mux)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
}

func (app *application) home(w http.ResponseWriter, r *http.Request) {
    // Use the verboseLogging configuration setting
    if app.config.verboseLogging {
        app.logger.Info("handling request", "method", r.Method, "path", r.URL.Path)
    }

    fmt.Fprintf(w, "Hello!")
}

If you run this application with the -verbose-logging flag, and make a HTTP request to localhost:4000, you should see the details of the request in the log output, similar to below — demonstrating that the config setting is correctly available to the handler.

$ go run main.go -verbose-logging
time=2025-06-27T14:15:40.230+02:00 level=INFO msg="starting server" port=4000
time=2025-06-27T14:15:48.705+02:00 level=INFO msg="handling request" method=GET path=/

In larger applications where I want to define my handlers outside of package main, or pass the config struct to functions in other packages, I normally define an exported Config struct in an internal/config package, and pass this around as necessary. For example, let's say that you have a project structure like so:

├── go.mod
├── go.sum
├── main.go
└── internal
    ├── config
    │   └── config.go
    └── handlers
        └── home.go

Then the contents of those .go files would look something like this:

File: internal/config/config.go
 
package config

import "time"

type Config struct {
    Port           int
    VerboseLogging bool
    RequestTimeout time.Duration
    BasicAuth      struct {
        Username string
        Password string
    }
}
File: internal/handlers/home.go
package handlers

import (
    "fmt"
    "log/slog"
    "net/http"

    "your-project/internal/config"
)

func Home(cfg config.Config, logger *slog.Logger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if cfg.VerboseLogging {
            logger.Info("handling request", "method", r.Method, "path", r.URL.Path)
        }

        fmt.Fprintf(w, "Hello!")
    }
}
File: main.go
package main

import (
    "flag"
    "fmt"
    "log/slog"
    "net/http"
    "os"
    "time"

    "your-project/internal/config"
    "your-project/internal/handlers"
)

func main() {
    logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

    var cfg config.Config
    flag.IntVar(&cfg.Port, "port", 4000, "The port number the web application listens on")
    flag.BoolVar(&cfg.VerboseLogging, "verbose-logging", false, "Enables detailed request and error logging")
    flag.DurationVar(&cfg.RequestTimeout, "request-timeout", 5*time.Second, "Maximum duration to wait for a request to complete")
    flag.StringVar(&cfg.BasicAuth.Username, "basic-auth-username", "", "Username required for HTTP Basic Authentication")
    flag.StringVar(&cfg.BasicAuth.Password, "basic-auth-password", "", "Password required for HTTP Basic Authentication")
    flag.Parse()

    mux := http.NewServeMux()
    mux.HandleFunc("/", handlers.Home(cfg, logger))

    // Use the port configuration setting
    logger.Info("starting server", "port", cfg.Port)

    err := http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), mux)
    if err != nil {
        logger.Error(err.Error())
        os.Exit(1)
    }
}

Obviously I'm using command-line flags in these examples, but the same patterns work for environment variables or config files too — once the config struct is loaded with the data, it doesn't matter where it originally came from and the code patterns are the same.

Discussion

If you've been in the web development world for a long time and buy into the 12-factor app principles (which I generally do), you might think that the correct approach is "just use environment variables". But over the years I've come to the conclusion that they have some drawbacks:

  • I've been bitten more times than I want by bugs that were ultimately a result of an unset or unexpected value in an environment variable — and I think that part of the problem here is that environment variables aren't readily and easily observable in the same way that the values in command-line flags or a configuration file are.
  • If you're working on multiple projects on the same development machine (rather than working in separate containers for each project), you have to manage the lack of natural isolation between environment variables... you need to make sure that there aren't any naming clashes, and that (for example) application A isn't accidentally using the DB_PASSWORD setting intended for application B.
  • I've also seen a lot of Go codebases where configuration settings are read in using os.Getenv() at the point in the code where they are needed. This makes discoverability difficult — it's hard to look at an application's code and easily see what the expected configuration settings are.

You can mitigate these issues with some of the techniques that we've discussed in this tutorial. If you're strict about reading all the settings into a single config struct at application startup, that addresses the discoverability problem. If you create helpers like env.MustGetInt() which panic if an environment variable isn't set, that helps to eliminate bugs that exist due to missing environment variables. And you can work around some of the environment variable isolation problems in development by using a .env file — but at that point, it might be worth considering whether a configuration file might be more appropriate.

One of the big reasons that I like to use command-line flags is that you get a lot of stuff for free. You get automatic -help text, automatic type conversions, the ability to set defaults, and it handles invalid inputs and undefined flags nicely. Also, it's always very clear what configuration values are being used — you either explicitly pass the values when starting the application, or the default values hardcoded into your Go codebase are used. On top of that, most other gophers will be familiar with the flag package and you don't need any third-party dependencies.

When I'm using command-line flags, I typically set the default values to things that are appropriate for a development environment. This is mainly so I don't have to keep typing long commands to run the application when actively developing it.

In terms of application secrets, like I mentioned earlier, there's nothing stopping you from storing a specific secret in an environment variable and using it in conjunction with a command-line flag if you want. For example, if you store a password for your database user in a DB_PASSWORD environment variable, you can include it as a command-line flag value when starting the application like so:

$ go run main.go -db-user=web -db-password=$DB_PASSWORD

Or, although it is a bit more 'magical', you could even use the environment variable as the default value:

flag.StringVar(&cfg.db.password, "db-password", os.Getenv("DB_PASSWORD"), "Database user password")

So, for all these reasons, I tend to prefer using command-line flags for configuration. The big exception to this is when there are a lot of configuration settings, and it would be awkward to pass them all via command-line flags, or the settings have a deeply nested 'structure' to them. In these cases, I think it can be more practical and maintainable to store the settings in a TOML or JSON configuration file, and load them on application startup like we demonstrated earlier.