Lesson 14. Load config from file & environment variables in Go with Viper

When developing and deploying a backend web application, we often need different configurations for different environments such as development, testing, staging, and production.

In this lesson, we’ll learn how to use Viper, a popular Go package, to load configurations from both files and environment variables.

Why Use Viper?

Viper is a popular Go package for application configuration. It can handle many formats and sources, making it very flexible.

  • Config file support: Easily specify defaults for local development/testing.
  • Environment variable override: Useful for staging/production (e.g., Docker).
  • File types supported: JSON, TOML, YAML, ENV, INI.
  • Other features:
    • Read from remote systems (etcd, Consul).
    • Supports encrypted/unencrypted values.
    • Watches for config file changes.
    • Allows saving modifications back to file.

In our prvious lesson we have set the coniguration manually by hard-coding some constants for the db driver, db source in the db/sqlc/main_test.go file. And also one more constant for the server address in the db/sqlc/main.go file. So in this tutorial, we will learn how to use viper to read these configurations from file and environment variables.

Step 1: Installation

Let’s start by installing viper. Open your browser and search for golang viper. Then open its github page. Scroll down a bit, Copy this go get command.

go get github.com/spf13/viper

And run it in the terminal to install the package. After this, in the go.mod file of our project, We can see viper has been added as a dependency.

Step 2: Create a Config File

Create a new file named app.env at the project root. The new file app.env to store our config values for development. Move the constants from main.go into this file, and declare them in .env format:

DB_DRIVER=postgres
DB_SOURCE=postgresql://postgres:mysecretpassword@localhost:5432/simple_bank?sslmode=disable
SERVER_ADDRESS=0.0.0.0:8080

Each variable should be declared on a separate line. The variable name is uppercase and its words are separated by underscore. The variable value is followed by the name after an equal symbol. And that’s it. This app.env file is now containing the default configurations for our local development environment.

Step 3: Define the Configuration Structure

We need a Go struct to hold our loaded configuration values, let create config.go inside util.

  1. Inside your util package (or a similar package like config), create a new file named config.go.
  2. Define a Config struct. Use mapstructure tags to tell Viper how to map the environment variable names to the struct fields.

util/config.go:

package util

import (
	"github.com/spf13/viper"
	"github.com/spf13/pflag"
)

// Config store all configuraiton of the application
// The values are read by viper from a config file or environment variable
type Config struct {
	DBDriver      string `mapstructure:"DB_DRIVER"`
	DBSource      string `mapstructure:"DB_SOURCE"`
	ServerAddress string `mapstructure:"SERVER_ADDRESS"`
}

We used viper to load this config file and declare a new type Config struct in this file. This Config struct will hold all configuration variables of the application that we read from file or environment variables. For now, we only have 3 variables: First, the db driver of type string, Second, the db source, also of type string. And finally the server address of type string as well.

Now, to get the values from our config file into the Config struct, we use Viper’s unmarshaling feature. Unmarshaling means converting raw data (from the file or environment variables) into Go struct fields.

Since Viper uses the mapstructure package for this, we add mapstructure tags to each field in our struct. These tags in the config.go must match the variable names in app.env.

Step 4: Write the Function to Load Configuration

Now, let’s write the core function that uses Viper to load settings from the file and environment variables. util/config.go (continued):
In same file config file after type Config Struct { let add following function

package util

import (
	"github.com/spf13/viper"
	"github.com/spf13/pflag"
)

// Config store all configuraiton of the application
// The values ...
type Config struct {
...
//loadConfig reads configuation from file or environment variable
func LoadConfig(path string) (config Config, err error) {
	viper.AddConfigPath(path)
	viper.SetConfigName("app")
	viper.SetConfigType("env") //json, xml

	viper.AutomaticEnv()

	if err = viper.ReadInConfig(); err != nil {
		return
	}

	err = viper.Unmarshal(&config)
	return
}

The new function LoadConfig which takes a path as input, and returns a config object or an error. This function will read configurations from a config file inside the path if it exists, or override their values with environment variables if they’re provided.

First we call viper.AddConfigPath() to tell viper the location of the config file. In this case, the location is given by the input path argument. Next we call viper.SetConfigName() to tell viper to look for a config file with this specific name Our config file is “app.env”, so its name is “app”. We should also tell viper the type of the config file, which is “env” in this case. You can also use JSON, XML or any other format here if you want, Just make sure your config file has the correct format and extension.

Beside reading configurations from file, we also want viper to read values from environment variables. So we call viper.AutomaticEnv() to tell viper to automatically override values that it has read from config file with the values of the corresponding environment variables if they exist. OK, so now we call viper.ReadInConfig() to start reading config values. If error is not nil, then we simply return it. Otherwise, we call viper.Unmarshal() to unmarshals the values into the target config object. And finally just return the config object and any error if it occurs. So basically the load config function is completed.

  • AddConfigPath: tells Viper where to find configs.
  • SetConfigName: base name of the file (without extension).
  • SetConfigType: specifies file type (env here).
  • AutomaticEnv: lets environment variables override file values.
  • ReadInConfig: loads file.
  • Unmarshal: maps values to our struct.

Step 5: Use Config in main.go

Update your main.go file in root folder, Now we can use the viper conifguration in the main.go file instead of hardcore constant for enviroment varaible. Let’s remove all of these hard code values. Then in the main function, let’s call util.LoadConfig() And pass in “.” here dot means the current folder, because our config file is in utils but main.go location is in root folder. If there’s an error, then we just write a fatal log saying cannot load configurations. Else, we just change these variables to config.DBDriver and config.DBSource. And the server address should be config.ServerAdress And we’re done!

package main

import (
	"database/sql"
	"log"

	_ "github.com/lib/pq"
	"github.com/ngodup/simplebank/api"
	db "github.com/ngodup/simplebank/db/sqlc"
	"github.com/ngodup/simplebank/util"
)

func main() {
	config, err := util.LoadConfig(".") 
	if err != nil {
		log.Fatal("cannot load config:", err)
	}

	conn, err := sql.Open(config.DBDriver, config.DBSource)
	if err != nil {
		log.Fatal("cannot connect to db:", err)
	}

	store := db.NewStore(conn)
	server, err := api.NewServer(config, store)
	if err != nil {
		log.Fatal("cannot create server:", err)
	}

	err = server.Start(config.ServerAddress)
	if err != nil {
		log.Fatal("cannot start server:", err)
	}

}

The "." passed to LoadConfig tells Viper to look for the app.env file in the current directory (the project root).

Step 6: Testing It Out

    Run the Server: run main.goThe server should start using the values from app.env (e.g., on port 8080).

    make server

    Server run succesful. But if you run POSTMAN to rereive GET accounts with pagination we got 500 internal server error. This is because the webserver cannot connect to Postgres Database on port 5432 and as our docker container for postgres may not be running and let check it.

    docker ps

    Let’s open the terminal and run docker ps to check if Postgres is running or not.If there is no containers running. If we run docker ps -a, we can see that Postgres container has been exited or not, if exit then run the docker containe for postgress.

    docker start pg-root-container

    So we have to start it by running docker start postgres16. Let’s try to run this server. It’s working! The server is listening on localhost port 8080, Just like what we specified in the app.env file. Let’s open postman and send an API request. I’m gonna call this list accounts API and it works now.

    Overriding the viper configuration

    Let’s try overriding those configurations with environment variable. I will set the server address variable to localhost port 8081 before calling make server.

    SERVER_ADDRESS=0.0.0.0:8080 make server 

    We can check inside the message from running above command in terminal it show that now the server is listening on port 8081 instead of 8080 as before.

    If we try to send the same API request in Postman to port 8080, We will get a connection refuse error. Only when we change this port to 8081, then the request will be successful. So we can conclude that viper has successfully override the values it read from config file with environment variables. That’s very convenient when we want to deploy the application to different environments such as staging or production in the future.

    Step 7: Update db/sqlc/main_test.go

    Replace hard-coded values with configs. Now before we finish, let’s update the main_test.go file to use the new LoadConfig function. First, remove these hard-coded constants. Then in the TestMain() function, we call util.LoadConfig(). But this time, the main_test.go file is inside the db/sqlc folder, while the app.env config file is at the root of the repository, so we must pass in this relative path. These 2 dots basically mean that go to the parent folder. If error is not nil, then we just write a fatal log. Otherwise, we should change these 2 values to config.DBDriver and config.DBSource. And that’s it! We’re done.

    package db
    
    import (
    	"database/sql"
    	"log"
    	"os"
    	"testing"
    
    	_ "github.com/lib/pq"
    	"github.com/ngodup/simplebank/util"
    )
    
    var testQueries *Queries
    var testDB *sql.DB
    
    func TestMain(m *testing.M) {
    	config, err := util.LoadConfig("../..")
    	if err != nil {
    		log.Fatal("cannot load config:", err)
    	}
    
    	testDB, err = sql.Open(config.DBDriver, config.DBSource)
    	if err != nil {
    		log.Fatal("cannot connect to db:", err)
    	}
    
    	testQueries = New(testDB)
    
    	os.Exit(m.Run())
    }
    

    We can check if the main_test.go test are working on not by clicking on run package test and we see that all tests should pass, confirming they are also correctly using the configurations.


    Conclusion

    In this lesson, we learned how to:

    • Use Viper to load config values from files and environment variables.
    • Replace hard-coded values in our project.
    • Support overrides for different environments.
    • Override any value using environment variables for
    • Set defaults in app.env for local development.
    • staging/production deployments.

    This approach makes our application flexible, production-ready, and easy to configure.

    Scroll to Top