Configuring Database Connection and Environment Variables in Go: A Clean Architecture Approach

Use Viper to load YAML configuration and automatically overlay environment variables, then construct a GORM MySQL connection using a centralized config struct in pkg/config/config.go.

The manakuro/golang-clean-architecture repository demonstrates how to isolate infrastructure concerns from business logic when configuring database connection and environment variables in Go. By combining Viper for configuration management with a global config struct, the codebase ensures that database credentials and environment-specific settings remain secure, flexible, and accessible throughout the application lifecycle.

Centralizing Configuration with Viper

The repository places all configuration logic in pkg/config/config.go, which defines a struct that mirrors the YAML structure in pkg/config/config.yml. This approach creates a single source of truth for application settings.

The Config Struct and YAML Structure

The config struct in pkg/config/config.go organizes settings into logical groups such as database and server parameters. The ReadConfig function unmarshals the YAML file into this struct and assigns it to the global variable C, making configuration available across packages without manual dependency injection.

// pkg/config/config.go
type config struct {
    Database struct {
        User                 string
        Password             string
        Net                  string
        Addr                 string
        DBName               string
        AllowNativePasswords bool
        Params               struct {
            ParseTime string
        }
    }
    Server struct {
        Address string
    }
}

var C config

func ReadConfig() {
    viper.SetConfigName("config")
    viper.SetConfigType("yml")
    viper.AddConfigPath(filepath.Join(rootDir(), "config"))
    viper.AutomaticEnv()
    
    if err := viper.ReadInConfig(); err != nil {
        log.Fatal(err)
    }
    
    if err := viper.Unmarshal(&C); err != nil {
        log.Fatal(err)
    }
    
    spew.Dump(C) // Debug output; remove in production
}

Loading Environment Variables Automatically

Viper's AutomaticEnv method enables seamless environment variable integration. When viper.AutomaticEnv() is called, Viper checks for environment variables that match struct fields and overrides YAML values accordingly. For example, setting export DATABASE_PASSWORD=supersecret in your shell automatically replaces the password defined in config.yml before the database connection initializes.

This mechanism keeps sensitive credentials out of version control while allowing developers to override any configuration value at runtime using Docker, CI pipelines, or local shell environments.

Building the Database Connection

The database infrastructure layer in pkg/infrastructure/datastore/db.go consumes the global configuration to construct a GORM-compatible MySQL connection. This separation ensures that database driver details remain isolated from domain logic.

Constructing the MySQL DSN

The NewDB function builds a MySQL Data Source Name (DSN) using parameters from config.C.Database. It leverages the go-sql-driver/mysql package's Config struct to format connection parameters correctly, including network type, address, credentials, and connection options like parseTime=true.

// pkg/infrastructure/datastore/db.go
package datastore

import (
    "github.com/go-sql-driver/mysql"
    "github.com/jinzhu/gorm"
    "github.com/manakuro/golang-clean-architecture/pkg/config"
)

func NewDB() *gorm.DB {
    mySqlConfig := &mysql.Config{
        User:                 config.C.Database.User,
        Passwd:               config.C.Database.Password,
        Net:                  config.C.Database.Net,
        Addr:                 config.C.Database.Addr,
        DBName:               config.C.Database.DBName,
        AllowNativePasswords: config.C.Database.AllowNativePasswords,
        Params: map[string]string{
            "parseTime": config.C.Database.Params.ParseTime,
        },
    }
    
    db, err := gorm.Open("mysql", mySqlConfig.FormatDSN())
    if err != nil {
        panic(err)
    }
    
    return db
}

Initializing GORM

The gorm.Open call receives the formatted DSN string and establishes the connection pool. Because NewDB depends on config.C already being populated, it must be called after config.ReadConfig() executes in the application entry point. The resulting *gorm.DB instance is then passed to repositories or used directly in infrastructure layers.

Bootstrapping the Application

Both the HTTP server and database seeding commands follow identical initialization patterns, ensuring consistent configuration across all entry points.

Entry Point Implementation

The main functions in cmd/app/main.go and cmd/seed/main.go demonstrate the required bootstrap sequence. First, they invoke config.ReadConfig() to load YAML and environment variables. Then they initialize the database connection using datastore.NewDB().

// cmd/app/main.go
package main

import (
    "log"
    
    "github.com/manakuro/golang-clean-architecture/pkg/config"
    "github.com/manakuro/golang-clean-architecture/pkg/infrastructure/datastore"
)

func main() {
    // Load configuration from YAML and environment variables
    config.ReadConfig()
    
    // Initialize database connection using loaded config
    db := datastore.NewDB()
    defer db.Close()
    
    // Continue with server initialization...
    log.Println("Database connection established")
}

This pattern guarantees that config.C contains the final, environment-specific values before any infrastructure code attempts to use it.

Docker Compose Integration

The repository includes a docker/docker-compose.yml file that defines MySQL service credentials. These values should align with the defaults in pkg/config/config.yml for local development, while production deployments override them via environment variables.


# docker/docker-compose.yml

version: '3.8'
services:
  mysql:
    image: mysql:8.0.23
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: golang_clean_architecture
    ports:
      - "3306:3306"

When running the Go application locally against this Docker container, the default YAML configuration connects successfully. For production, inject the actual database password via DATABASE_PASSWORD environment variable without modifying any source files.

Summary

  • Centralized configuration lives in pkg/config/config.go, where Viper loads pkg/config/config.yml and automatically overlays matching environment variables.
  • Environment variable precedence is handled by viper.AutomaticEnv(), allowing runtime overrides of YAML defaults for secrets and deployment-specific settings.
  • Database connection construction in pkg/infrastructure/datastore/db.go consumes the global config.C struct to build a MySQL DSN for GORM.
  • Consistent bootstrapping across cmd/app/main.go and cmd/seed/main.go ensures configuration loads before infrastructure initializes.

Frequently Asked Questions

How does Viper handle environment variable precedence in this architecture?

Viper's AutomaticEnv() method checks for environment variables after loading the YAML file but before unmarshaling into the struct. If an environment variable exists that matches a configuration key (e.g., DATABASE_PASSWORD maps to database.password), Viper uses the environment value, overriding the YAML default. This precedence ensures that sensitive values injected at runtime take priority over version-controlled defaults.

Where should database credentials be stored in production?

Database credentials should never be stored in pkg/config/config.yml or any version-controlled file in production. Instead, export them as environment variables on the host system or orchestration platform (Kubernetes secrets, AWS ECS task definitions, or Docker Compose secrets). The application reads these via Viper's environment variable integration, keeping credentials out of source code while maintaining configuration flexibility.

How do I add a new configuration field to the application?

First, add the field to the config struct in pkg/config/config.go within the appropriate nested struct (e.g., Database or Server). Then, add the corresponding default value to pkg/config/config.yml. If the field might need environment-specific overrides, ensure the field name follows the pattern that Viper's AutomaticEnv() can match (e.g., NEW_FIELD_NAME for a field NewFieldName or new.field_name depending on your struct tags and Viper settings).

Can I use a .env file instead of exporting environment variables directly?

Yes, though the repository does not include this by default. To add .env file support, modify pkg/config/config.go to include viper.SetConfigFile(".env") and viper.ReadInConfig() before calling viper.AutomaticEnv(). This allows Viper to load variables from a .env file in addition to system environment variables, which is useful for local development workflows where exporting variables in every shell session is inconvenient.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →