Keep track of environment variables in go

Starting point

When we configure our programs, we usually have three ways to do it: config files, cli flags and environment variables. While all of these three options are a solid way to go, with CI/CD environments you are usually instructed to use environment variables, e.g. envvars.

If we look at the golang’s os package documentation, we can see that it offers multiple ways to read envvars:

  • Environ which gives you raw list of strings in form of “key=value” which you can parse your self
  • Getenv which gives you the envvar value if it exists, or an empty string
  • LookupEnv which is similar to Getenv, but also returns you a boolean telling you if the variable exists or not

With these, reading the environment variables is easy. Without any further planning, we can write some code like this:

func main() {
	connstr := fmt.Sprintf("user='%s' password='%s' dbname='%s' host='%s' port='%s'",
		os.Getenv("DBUSER"),
		os.Getenv("DBPASS"),
		os.Getenv("DBNAME"),
		os.Getenv("DBHOST"),
		os.Getenv("DBPORT"),
	)

	fmt.Printf("%q\n", connstr)
}
$ DBUSER=foo DBPASS=bar DBNAME=mydb DBHOST=localhost DBPORT=5432 go run main.go
"user='foo' password='bar' dbname='mydb' host='localhost' port='5432'"

All good so far, right? Well, if you remember to set all the variables all the time, then yes. What happens if we run the program without setting any variables?

$ go run main.go
"user='' password='' dbname='' host='' port=''"

Eh, doesn’t look so promising, does it? And what happens when your environment variable count keeps growing? Its likely that you need options for connecting to a database, some caching system, some external APIs and maybe even feature flags. Littering those all over the place without having any up-to-date list can be PITA to maintain. Especially if we check add checks for the default values everywhere too.

How I do it

We have couple of problems that we need to solve:

  • How to keep track of the environment variables that our program needs?
  • How the set (and keep track of) the default values of those variables?

That pretty much sums up to a key-value list. We can easily use go’s map type:

var defaultEnvv = map[string]string{
	"DBHOST": "localhost",
	"DBPORT": "5432",
	"DBUSER": "foo",
	"DBPASS": "bar",
	"DBNAME": "mydb",
}

Here our environment variables are the keys on the map, and the values are their defaults. For reading the values from the environment, we can do something like this:

func ReadEnv(defaults map[string]string) map[string]string {
	envv := make(map[string]string)

	for k, v := range defaults {
		envv[k] = v

		if l, ok := os.LookupEnv(k); ok {
			envv[k] = l
		}
	}

	return envv
}

Pretty simple, right? If the then adjust our earlier code to use this new ReadEnv function:

func main() {
	envv := ReadEnv(defaultEnvv)
	connstr := fmt.Sprintf("user='%s' password='%s' dbname='%s' host='%s' port='%s'",
		envv["DBUSER"],
		envv["DBPASS"],
		envv["DBNAME"],
		envv["DBHOST"],
		envv["DBPORT"],
	)

	fmt.Printf("%q\n", connstr)
}

We can run the program with default values, or set then manually:

# No values
$ go run main.go
"user='foo' password='bar' dbname='mydb' host='localhost' port='5432'"
# With values
$ DBUSER=my-production-user DBPASSWORD="VERYSECRET" go run main.go
"user='my-production-user' password='bar' dbname='mydb' host='localhost' port='5432'"

Cool, we solved those two problems! If you looks closely, you’ll notice that we’re still littering our environment variable usage as strings all over the place. But in my projects this has been enough. This is mainly because of these two things:

  • All our environment variable access is in the main.go file
  • That same file has list of the environment variables available for the whole program on top of the main.go file (if we stick with the ReadEnv function)

Here is the code snippet for reading envvars that I currently carry with me to my projects:

type Envv map[string]string

func ReadEnv(defaults Envv) Envv {
	envv := make(map[string]string)

	for k, v := range defaults {
		envv[k] = v

		if l, ok := os.LookupEnv(k); ok {
			envv[k] = l
		}
	}

	return envv
}

Further thoughts

At some point I would like to play with this idea a bit more, and do something like this:

type Enviroment struct {
	DBHost string `env:"DBHOST,localhost"`
	DBPort string `env:"DBPORT,5432"`
	...

	CookieSecret string `env:"COOKIE_SECRET,,required"`
}

Where the idea is to list the environment variables as struct fields, so the compiler watches after us if we mistype something or remove some field. And if you look closely, the CookieSecret field doesn’t have default value and is marked as required. Our ReadEnv function would then return an error if COOKIE_SECRET isn’t set.

There probably is a library that handles all of this and much more out-of-the-box, but my 8 lines long function has served me well so far.