Introduction
One of the most critical aspects of building professional Go applications is proper environment configuration management. Whether you’re developing locally, running tests, or deploying to production, each environment has different requirements. This article walks you through the best practices for setting up environment-specific .env files in your Go project.
By the end of this guide, you’ll understand:
- ✅ How to structure environment files
- ✅ How to load environment variables in Go
- ✅ How to validate configuration
- ✅ How to handle different environments
- ✅ Production-ready configuration management
Why Environment Configuration Matters
Consider this scenario: Your development database has loose security settings, your test database is isolated for testing, and your production database has strict security and backups. You need a way to switch between these without changing code.
That’s where environment configuration comes in. Instead of hardcoding values, you externalize them as environment variables, typically stored in .env files for local development.
Go-simple Project
The Go-Simple project is an open-source Golang repository created by Mobin Shaterian that serves as a living, evolving blueprint for building scalable, clean, and production-grade web services. It is not a simple “hello world” example, but a practical demonstration of advanced architectural patterns and best practices in the Go ecosystem. The project’s primary purpose is to showcase how to integrate modern Go frameworks and tools — such as the Gin web framework, SQLC for database interaction, and Uber FX for dependency injection and lifecycle management — to create a modular, maintainable application. It follows principles like clean architecture and domain-centric design, separating concerns into distinct layers like controllers, services, and repositories, ensuring the codebase remains flexible and testable as it grows.
The project is continually developed and documented through a series of technical articles on Medium, which detail the step-by-step evolution of the architecture. Starting from a basic HTTP server, the project has been refactored to incorporate key enterprise features: it uses Uber FX to manage dependencies and simplify application startup, implements a robust product management module with full CRUD (Create, Read, Update, Delete) functionality, integrates gRPC for high-performance microservice communication, and features a Redis-backed cache layer for speed and scalability. By observing the project’s evolution, developers can learn how to apply modern Go idioms, implement advanced concepts like lifecycle hooks and Data Transfer Objects (DTOs), and establish a solid foundation for their own complex Go applications.
Project Structure
Let’s start with the recommended directory structure:
go-simple/
├── .env # Local development
(don't commit)
├── .env.test # Testing (don't commit)
├── .env.development #
Development (can commit)
├── .env.production # Production
(don't commit)
├── .env.example # Template (COMMIT THIS)
├── .gitignore # Ignore actual .env files
├── cmd/
│
└── server/
│
└── main.go # Entry point
├── internal/
│
├── config/
│
│
├── config.go # Config loading & validation
│
│
└── validate.go # Validation functions
│
├── storage/
│
│
└── db.go # Database initialization
│
└── app/
│
└── app.go # Application setup
└── test/
├── http_server_test.go
└── grpc_server_test.goStep 1: Install Required Dependencies
First, add the necessary Go packages to your go.mod:
go get github.com/joho/godotenv
go get github.com/spf13/viper- godotenv: Loads
.envfiles into environment variables - viper: Configuration management with support for environment variables
Step 2: Create Environment Files
.env.example (Commit this!)
This file serves as a template for developers. It should contain all required environment variables with example values.
# Server ConfigurationAPP_HTTP_PORT=4000APP_HTTP_ADDRESS=127.0.0.1APP_GRPC_PORT=9001#
EnvironmentAPP_ENV=development#
DatabaseAPP_DATABASE_DSN=postgresql://user:pass@localhost:5432/database?sslmode=disable#
JWTAPP_JWT_SECRET=this-is-a-secret-keyAPP_JWT_EXPIRY_HOURS=72#
RedisAPP_REDIS_DSN=localhost:6379APP_REDIS_DB=0APP_REDIS_PREFIX=go-simpleAPP_REDIS_DEFAULT_TTL=5.env.development (Local development)
# Server ConfigurationAPP_HTTP_PORT=4000APP_HTTP_ADDRESS=127.0.0.1APP_GRPC_PORT=9001#
EnvironmentAPP_ENV=development# Database - Local
PostgreSQLAPP_DATABASE_DSN=postgresql://postgres:postgres@localhost:5432/go_simple_dev?sslmode=disable# JWTAPP_JWT_SECRET=dev-secret-key-change-in-productionAPP_JWT_EXPIRY_HOURS=72# RedisAPP_REDIS_DSN=localhost:6379APP_REDIS_DB=0APP_REDIS_PREFIX=go-simpleAPP_REDIS_DEFAULT_TTL=5.env.test (Testing)
# Server ConfigurationAPP_HTTP_PORT=4001APP_HTTP_ADDRESS=127.0.0.1APP_GRPC_PORT=9002#
EnvironmentAPP_ENV=test# Database - Separate test
databaseAPP_DATABASE_DSN=postgresql://postgres:postgres@localhost:5432/go_simple_test?sslmode=disable# JWTAPP_JWT_SECRET=test-secret-keyAPP_JWT_EXPIRY_HOURS=1# Redis - Different DB for isolationAPP_REDIS_DSN=localhost:6379APP_REDIS_DB=1APP_REDIS_PREFIX=go-simple-testAPP_REDIS_DEFAULT_TTL=1.gitignore
Make sure actual .env files are never committed:
# Environment files - never commit actual values.env.env.test.env.development.env.production# But
commit the template!.env.exampleStep 3: Create the Config Package
internal/config/config.go
This file loads and structures your configuration:
package config
import (
"fmt"
"log"
"os"
"github.com/spf13/viper")// Config represents all application configuration
type Config struct {
HTTPPort int
HTTPAddress string
GRPCPort int
ENV string
JWTSecret string
JWTExpiryHours int
Database DatabaseCfg
Redis RedisCfg
}
// DatabaseCfg contains database configuration
type DatabaseCfg struct {
DSN string
}
// RedisCfg contains Redis configuration
type RedisCfg struct {
DSN string DB int Prefix string DefaultTTL int // minutes
}
// NewConfig creates and returns validated configuration
func NewConfig() (*Config, error) {
v := viper.New() v.SetEnvPrefix("APP") v.AutomaticEnv() // Check if environment
variables are set if !hasEnvironmentVariables() {
return nil, fmt.Errorf("no environment variables found (APP_* prefix). " +
"Make sure .env file was loaded or environment variables are set",)
}
// Build config cfg := buildConfig(v) // Validate configuration if err := ValidateConfig(cfg);
err != nil {
return nil, fmt.Errorf("⚠️ invalid configuration: %w", err)
}
log.Printf("✅ Loaded config: %+v\n", cfg) return cfg, nil
}
// buildConfig
constructs the Config struct from viper valuesfunc buildConfig(v *viper.Viper) *Config {
return &Config{
HTTPPort: v.GetInt("HTTP_PORT"), HTTPAddress:
v.GetString("HTTP_ADDRESS"), GRPCPort: v.GetInt("GRPC_PORT"), ENV: v.GetString("ENV"),
JWTSecret: v.GetString("JWT_SECRET"), JWTExpiryHours: v.GetInt("JWT_EXPIRY_HOURS"),
Database: DatabaseCfg{
DSN: v.GetString("DATABASE_DSN"),
}, Redis: RedisCfg{
DSN: v.GetString("REDIS_DSN"), DB: v.GetInt("REDIS_DB"), Prefix:
v.GetString("REDIS_PREFIX"), DefaultTTL: v.GetInt("REDIS_DEFAULT_TTL"),
},
}
}
// hasEnvironmentVariables checks if any APP_*
variables are setfunc hasEnvironmentVariables() bool {
requiredVars := []string{
"APP_HTTP_PORT", "APP_DATABASE_DSN", "APP_ENV",
}
for _, varName := range requiredVars {
if os.Getenv(varName) != "" {
return true
}
}
return false
}
// LoadEnv loads environment
variables from.env filefunc LoadEnv() {
env := os.Getenv("APP_ENV") if env == "" {
env = "development"
}
log.Printf("📋 Environment: %s\n", env) paths := []string{
fmt.Sprintf(".env.%s", env), ".env", filepath.Join("..", fmt.Sprintf(".env.%s", env)),
filepath.Join("..", ".env"),
}
for _, path := range paths {
if err := godotenv.Load(path);
err == nil {
log.Printf("✅ Loaded from: %s\n", path) return
}
}
log.Printf("⚠️ No .env file found\n") log.Printf("ℹ️ Using system environment variables\n")
}
// Helper methods for environment checks// IsTest returns true if environment is test
func (cfg *Config) IsTest() bool {
return cfg.ENV == "test"
}
// IsDevelopment returns true if environment is development
func (cfg *Config) IsDevelopment() bool {
return cfg.ENV == "development"
}
// IsProduction returns true if environment is production
func (cfg *Config) IsProduction() bool {
return cfg.ENV == "production"
}Step 4: Validate Configuration
Create internal/config/validate.go to ensure all configuration values are correct:
package config
import (
"fmt"
"log"
"net"
"strings")// ValidateConfig validates all configuration values
func ValidateConfig(cfg *Config) error {
checks := []func(*Config) error{
validateHTTPPort, validateGRPCPort, validatePortConflict, validateHTTPAddress,
validateEnvironment, validateJWTSecret, validateJWTExpiry, validateDatabaseDSN,
validateRedisDSN, validateRedisDB, validateRedisPrefix, validateRedisTTL,
}
for _, check := range checks {
if err := check(cfg);
err != nil {
return err
}
}
validateWarnings(cfg) return nil
}
// validateHTTPPort validates HTTP port
func validateHTTPPort(cfg *Config) error {
if cfg.HTTPPort <= 0 || cfg.HTTPPort > 65535 {
return fmt.Errorf("invalid HTTP_PORT: %d. Expected value between 1 and 65535",
cfg.HTTPPort,)
}
return nil
}
// validateGRPCPort validates GRPC port
func validateGRPCPort(cfg *Config) error {
if cfg.GRPCPort <= 0 || cfg.GRPCPort > 65535 {
return fmt.Errorf("invalid GRPC_PORT: %d. Expected value between 1 and 65535",
cfg.GRPCPort,)
}
return nil
}
// validatePortConflict ensures ports don't conflict
func validatePortConflict(cfg *Config) error { if cfg.HTTPPort == cfg.GRPCPort { return fmt.Errorf(
"port conflict: HTTP_PORT and GRPC_PORT cannot be the same (%d)", cfg.HTTPPort, ) } return nil}//
validateHTTPAddress validates HTTP address
func validateHTTPAddress(cfg *Config) error { if cfg.HTTPAddress == "" { return
fmt.Errorf("HTTP_ADDRESS cannot be empty") } if ip := net.ParseIP(cfg.HTTPAddress); ip == nil &&
cfg.HTTPAddress != "localhost" { return fmt.Errorf("invalid HTTP_ADDRESS: %q", cfg.HTTPAddress) }
return nil}// validateEnvironment validates environment value
func validateEnvironment(cfg *Config) error { if cfg.ENV == "" { return
fmt.Errorf("ENV cannot be empty") } validEnvs := []string{"development", "test", "production"} for
_, valid := range validEnvs { if cfg.ENV == valid { return nil } } return
fmt.Errorf("invalid ENV: %q", cfg.ENV)}// validateJWTSecret validates JWT secret
func validateJWTSecret(cfg *Config) error { if cfg.JWTSecret == "" { return
fmt.Errorf("JWT_SECRET cannot be empty") } if len(cfg.JWTSecret) < 32 {
log.Printf("⚠️ WARNING: JWT_SECRET is too short (%d chars). "+ "Recommended: 32+ characters\n",
len(cfg.JWTSecret)) } return nil}// validateJWTExpiry validates JWT expiry
func validateJWTExpiry(cfg *Config) error { if cfg.JWTExpiryHours <= 0 { return
fmt.Errorf("invalid JWT_EXPIRY_HOURS: %d", cfg.JWTExpiryHours) } return nil}// validateDatabaseDSN
validates database DSN
func validateDatabaseDSN(cfg *Config) error { if cfg.Database.DSN == "" { return
fmt.Errorf("DATABASE_DSN cannot be empty") } if !strings.HasPrefix(cfg.Database.DSN,
"postgresql://") && !strings.HasPrefix(cfg.Database.DSN, "postgres://") &&
!strings.HasPrefix(cfg.Database.DSN, "mysql://") { return fmt.Errorf("invalid DATABASE_DSN format")
} return nil}// validateRedisDSN validates Redis DSN
func validateRedisDSN(cfg *Config) error { if cfg.Redis.DSN == "" { return
fmt.Errorf("REDIS_DSN cannot be empty") } parts := strings.Split(cfg.Redis.DSN, ":") if len(parts) <
2 { return fmt.Errorf("invalid REDIS_DSN format: expected host:port") } return nil}//
validateRedisDB validates Redis DB
func validateRedisDB(cfg *Config) error { if cfg.Redis.DB < 0 || cfg.Redis.DB > 15 { return
fmt.Errorf("invalid REDIS_DB: %d (must be 0-15)", cfg.Redis.DB) } return nil}// validateRedisPrefix
validates Redis prefix
func validateRedisPrefix(cfg *Config) error { if cfg.Redis.Prefix == "" { return
fmt.Errorf("REDIS_PREFIX cannot be empty") } if strings.Contains(cfg.Redis.Prefix, "
") { return fmt.Errorf("invalid REDIS_PREFIX: cannot contain
spaces") } return nil}// validateRedisTTL validates Redis TTL
func validateRedisTTL(cfg *Config) error { if cfg.Redis.DefaultTTL <= 0 { return
fmt.Errorf("invalid REDIS_DEFAULT_TTL: %d", cfg.Redis.DefaultTTL) } return nil}// validateWarnings
logs warnings for production environments
func validateWarnings(cfg *Config) { if cfg.IsProduction() { if cfg.JWTSecret ==
"this-is-a-secret-key" {
log.Printf("⚠️ SECURITY WARNING: Using default JWT_SECRET in production!\n") } if cfg.HTTPAddress
== "127.0.0.1" { log.Printf("⚠️ WARNING: HTTP_ADDRESS is 127.0.0.1 in production\n") } if
strings.Contains(cfg.Database.DSN, "localhost") {
log.Printf("⚠️ WARNING: DATABASE_DSN points to localhost in production\n") } if
strings.Contains(cfg.Redis.DSN, "localhost") {
log.Printf("⚠️ WARNING: REDIS_DSN points to localhost in production\n") } }}
Step 5: Use Configuration in main.go
Your main entry point should load the environment and config first:
package main
import (
"log"
"your-module/internal/app"
"your-module/internal/config"
)
func main() {
// Step 1: Load.env file config.LoadEnv() // Step 2: Load and validate configuration cfg, err :=
config.NewConfig() if err != nil {
log.Fatalf("❌ Configuration error: %v", err)
}
// Step 3: Initialize and run app app.NewApp(cfg).Run()
}Step 6: Handle Different Environments
Use the helper methods to apply environment-specific logic:
package server
import (
"log"
"your-module/internal/config"
)
func NewGinEngine(cfg *config.Config) *gin.Engine {
engine := gin.New() // Development: Verbose logging if cfg.IsDevelopment() {
gin.SetMode(gin.DebugMode) log.Println("🔍 Running in DEBUG mode")
}
// Production: Silent mode if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode) log.Println("🚀 Running in RELEASE mode")
}
// Test: Specific test mode if cfg.IsTest() {
gin.SetMode(gin.TestMode) log.Println("🧪 Running in TEST mode")
}
return engine
}Step 7: Testing with Environment Files
Your tests can easily switch environments:
package test
import (
"os"
"testing"
"your-module/internal/app"
"your-module/internal/config"
)
func TestHTTPServer(t *testing.T) {
// Set test environment os.Setenv("APP_ENV", "test") // Change to project root (if running from
test directory) os.Chdir("..") defer os.Chdir("test") // Load test environment config.LoadEnv()
// Create app app := app.NewApp() // Your test code here
}Git Commit
Summary
Environment configuration management is crucial for professional Go applications. Here’s what you’ve learned:
✅ Structure: Use .env.development, .env.test, .env.production
✅ Loading: Use godotenv to load files, viper for configuration
✅ Validation: Validate all config values and fail fast
✅ Environment-aware: Use helpers like IsProduction() for logic
✅ Security: Never commit actual .env files
✅ Testing: Easy environment switching for tests
✅ Production: Works with system environment variables
By following these practices, you’ll have:
- 🚀 Professional configuration management
- 🔒 Secure secret handling
- 🧪 Easy testing setup
- 📦 Production-ready code
- 🎯 Clear separation of concerns
A message from our Founder
Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.
Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We don’t receive any funding, we do this to support the community. ❤️
If you want to show some love, please take a moment to follow me on LinkedIn, TikTok, Instagram. You can also subscribe to our weekly newsletter.
And before you go, don’t forget to clap and follow the writer️!
