Environment Variables in Go

Environment Variables in Go

Learn how to work with environment variables in Go applications for configuration management and deployment.

Why Use Environment Variables?

Environment variables are essential for:

  • Configuration Management: Separate config from code
  • Security: Keep secrets out of source code
  • Deployment: Different settings for different environments
  • Flexibility: Change behavior without recompiling

Basic Environment Variable Operations

Reading Environment Variables

Go’s os package provides access to environment variables:

package main

import (
    "fmt"
    "os"
)

func main() {
    // Get environment variable
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080" // default value
    }
    
    fmt.Printf("Server running on port %s\n", port)
    
    // Get with default value helper
    dbHost := getEnvOrDefault("DB_HOST", "localhost")
    fmt.Printf("Database host: %s\n", dbHost)
}

// Helper function to get env var with default
func getEnvOrDefault(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

Checking if Environment Variable Exists

package main

import (
    "fmt"
    "os"
)

func main() {
    // Check if environment variable exists
    value, exists := os.LookupEnv("API_KEY")
    
    if exists {
        fmt.Printf("API_KEY found: %s\n", value)
    } else {
        fmt.Println("API_KEY not set")
    }
    
    // Alternative way
    if os.Getenv("DEBUG") != "" {
        fmt.Println("Debug mode enabled")
    }
}

Setting Environment Variables

package main

import (
    "fmt"
    "os"
)

func main() {
    // Set environment variable (only affects current process)
    os.Setenv("APP_NAME", "MyGoApp")
    
    // Verify it was set
    appName := os.Getenv("APP_NAME")
    fmt.Printf("App name: %s\n", appName)
    
    // Clear environment variable
    os.Unsetenv("APP_NAME")
    
    // Verify it was cleared
    if os.Getenv("APP_NAME") == "" {
        fmt.Println("APP_NAME cleared")
    }
}

Working with Environment Files

Creating .env Files

Create a .env file for development:

# .env
PORT=8080
DB_HOST=localhost
DB_PORT=5432
DB_USER=myuser
DB_PASSWORD=mypassword
DEBUG=true
API_KEY=your-api-key-here

Loading .env Files

Go doesn’t have built-in .env file support, so you’ll need a third-party package:

go get github.com/joho/godotenv
package main

import (
    "fmt"
    "log"
    "os"

    "github.com/joho/godotenv"
)

type Config struct {
    Port        string
    DBHost      string
    DBPort      string
    DBUser      string
    DBPassword  string
    Debug       bool
    APIKey      string
}

func main() {
    // Load .env file
    err := godotenv.Load()
    if err != nil {
        log.Println("Warning: Could not load .env file")
    }
    
    config := Config{
        Port:       getEnvOrDefault("PORT", "8080"),
        DBHost:     getEnvOrDefault("DB_HOST", "localhost"),
        DBPort:     getEnvOrDefault("DB_PORT", "5432"),
        DBUser:     getEnvOrDefault("DB_USER", ""),
        DBPassword: getEnvOrDefault("DB_PASSWORD", ""),
        Debug:      getEnvBool("DEBUG"),
        APIKey:     getEnvOrDefault("API_KEY", ""),
    }
    
    fmt.Printf("Config: %+v\n", config)
}

func getEnvOrDefault(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

func getEnvBool(key string) bool {
    return os.Getenv(key) == "true"
}

Custom .env File Loader

If you prefer not to use external packages:

package main

import (
    "bufio"
    "fmt"
    "os"
    "strings"
)

func loadEnvFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close()
    
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        
        // Skip empty lines and comments
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        
        // Split on first =
        parts := strings.SplitN(line, "=", 2)
        if len(parts) == 2 {
            key := strings.TrimSpace(parts[0])
            value := strings.TrimSpace(parts[1])
            
            // Remove quotes if present
            if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) {
                value = strings.Trim(value, `"`)
            }
            
            os.Setenv(key, value)
        }
    }
    
    return scanner.Err()
}

func main() {
    // Load custom .env file
    err := loadEnvFile(".env")
    if err != nil {
        fmt.Printf("Warning: Could not load .env file: %v\n", err)
    }
    
    port := os.Getenv("PORT")
    fmt.Printf("Port: %s\n", port)
}

Structured Configuration

Configuration Struct with Tags

package main

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

type DatabaseConfig struct {
    Host     string        `env:"DB_HOST" default:"localhost"`
    Port     int           `env:"DB_PORT" default:"5432"`
    User     string        `env:"DB_USER" required:"true"`
    Password string        `env:"DB_PASSWORD" required:"true"`
    Name     string        `env:"DB_NAME" default:"myapp"`
    Timeout  time.Duration `env:"DB_TIMEOUT" default:"5s"`
}

type ServerConfig struct {
    Port         string        `env:"PORT" default:"8080"`
    Host         string        `env:"HOST" default:"0.0.0.0"`
    ReadTimeout  time.Duration `env:"READ_TIMEOUT" default:"30s"`
    WriteTimeout time.Duration `env:"WRITE_TIMEOUT" default:"30s"`
    Debug        bool          `env:"DEBUG" default:"false"`
}

type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
}

func main() {
    config := &Config{
        Server: ServerConfig{
            Port:         getEnvOrDefault("PORT", "8080"),
            Host:         getEnvOrDefault("HOST", "0.0.0.0"),
            ReadTimeout:  getEnvDuration("READ_TIMEOUT", 30*time.Second),
            WriteTimeout: getEnvDuration("WRITE_TIMEOUT", 30*time.Second),
            Debug:        getEnvBool("DEBUG"),
        },
        Database: DatabaseConfig{
            Host:     getEnvOrDefault("DB_HOST", "localhost"),
            Port:     getEnvInt("DB_PORT", 5432),
            User:     getEnvRequired("DB_USER"),
            Password: getEnvRequired("DB_PASSWORD"),
            Name:     getEnvOrDefault("DB_NAME", "myapp"),
            Timeout:  getEnvDuration("DB_TIMEOUT", 5*time.Second),
        },
    }
    
    fmt.Printf("Server config: %+v\n", config.Server)
    fmt.Printf("Database config: %+v\n", config.Database)
}

func getEnvOrDefault(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}

func getEnvRequired(key string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    panic(fmt.Sprintf("Required environment variable %s is not set", key))
}

func getEnvInt(key string, defaultValue int) int {
    if value := os.Getenv(key); value != "" {
        if intValue, err := strconv.Atoi(value); err == nil {
            return intValue
        }
    }
    return defaultValue
}

func getEnvBool(key string) bool {
    return os.Getenv(key) == "true"
}

func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
    if value := os.Getenv(key); value != "" {
        if duration, err := time.ParseDuration(value); err == nil {
            return duration
        }
    }
    return defaultValue
}

Environment-Specific Configuration

Multiple Environment Files

Create different .env files for each environment:

# .env.development
PORT=8080
DB_HOST=localhost
DEBUG=true
LOG_LEVEL=debug

# .env.staging
PORT=8080
DB_HOST=staging-db.example.com
DEBUG=false
LOG_LEVEL=info

# .env.production
PORT=80
DB_HOST=prod-db.example.com
DEBUG=false
LOG_LEVEL=error
package main

import (
    "fmt"
    "log"
    "os"
    "strings"

    "github.com/joho/godotenv"
)

func loadEnvironment() {
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "development"
    }
    
    // Load base .env file
    if err := godotenv.Load(".env"); err == nil {
        log.Printf("Loaded .env file")
    }
    
    // Load environment-specific file
    envFile := fmt.Sprintf(".env.%s", env)
    if err := godotenv.Load(envFile); err == nil {
        log.Printf("Loaded %s file", envFile)
    }
    
    log.Printf("Running in %s environment", env)
}

func main() {
    loadEnvironment()
    
    config := map[string]string{
        "Port":     os.Getenv("PORT"),
        "DBHost":   os.Getenv("DB_HOST"),
        "Debug":    os.Getenv("DEBUG"),
        "LogLevel": os.Getenv("LOG_LEVEL"),
    }
    
    fmt.Printf("Configuration: %+v\n", config)
}

Best Practices

1. Use Descriptive Variable Names

// Good
const (
    EnvDatabaseURL = "DATABASE_URL"
    EnvAPISecret   = "API_SECRET"
    EnvServerPort  = "SERVER_PORT"
)

// Bad
const (
    EnvDB = "DB"
    EnvSec = "SECRET"
    EnvP = "PORT"
)

2. Provide Sensible Defaults

type Config struct {
    ServerPort string
    DatabaseURL string
}

func NewConfig() *Config {
    return &Config{
        ServerPort:  getEnvOrDefault("SERVER_PORT", "8080"),
        DatabaseURL: getEnvRequired("DATABASE_URL"),
    }
}

3. Validate Required Variables

func validateConfig() error {
    requiredVars := []string{
        "DATABASE_URL",
        "API_SECRET",
        "REDIS_URL",
    }
    
    for _, varName := range requiredVars {
        if os.Getenv(varName) == "" {
            return fmt.Errorf("required environment variable %s is not set", varName)
        }
    }
    
    return nil
}

4. Use Environment-Specific Prefixes

const (
    EnvPrefix = "MYAPP_"
    
    EnvServerPort  = EnvPrefix + "SERVER_PORT"
    EnvDatabaseURL = EnvPrefix + "DATABASE_URL"
    EnvLogLevel    = EnvPrefix + "LOG_LEVEL"
)

Security Considerations

1. Never Commit .env Files

Add to .gitignore:

# Environment files
.env
.env.local
.env.*.local

# OS generated files
.DS_Store
Thumbs.db

2. Use Different Secrets for Different Environments

func getSecret() string {
    env := os.Getenv("APP_ENV")
    
    switch env {
    case "production":
        return os.Getenv("PRODUCTION_SECRET")
    case "staging":
        return os.Getenv("STAGING_SECRET")
    default:
        return os.Getenv("DEVELOPMENT_SECRET")
    }
}

3. Rotate Secrets Regularly

Implement secret rotation logic:

func getRotatingSecret() (string, error) {
    primary := os.Getenv("API_SECRET_PRIMARY")
    secondary := os.Getenv("API_SECRET_SECONDARY")
    
    // Try primary secret first
    if primary != "" {
        return primary, nil
    }
    
    // Fall back to secondary during rotation
    if secondary != "" {
        return secondary, nil
    }
    
    return "", fmt.Errorf("no valid secret found")
}

Testing with Environment Variables

Setting Test Environment

package main

import (
    "os"
    "testing"
)

func TestConfig(t *testing.T) {
    // Set test environment variables
    os.Setenv("PORT", "3000")
    os.Setenv("DB_HOST", "localhost")
    os.Setenv("DEBUG", "true")
    
    // Clean up after test
    defer func() {
        os.Unsetenv("PORT")
        os.Unsetenv("DB_HOST")
        os.Unsetenv("DEBUG")
    }()
    
    config := NewConfig()
    
    if config.ServerPort != "3000" {
        t.Errorf("Expected port 3000, got %s", config.ServerPort)
    }
}

Using Test Tables

func TestGetEnvOrDefault(t *testing.T) {
    tests := []struct {
        name     string
        key      string
        value    string
        default  string
        expected string
    }{
        {"env var set", "TEST_KEY", "test_value", "default", "test_value"},
        {"env var not set", "MISSING_KEY", "", "default", "default"},
        {"empty env var", "EMPTY_KEY", "", "default", "default"},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if tt.value != "" {
                os.Setenv(tt.key, tt.value)
                defer os.Unsetenv(tt.key)
            }
            
            result := getEnvOrDefault(tt.key, tt.default)
            if result != tt.expected {
                t.Errorf("Expected %s, got %s", tt.expected, result)
            }
        })
    }
}

Docker and Environment Variables

Dockerfile

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/main .

# Set default environment variables
ENV PORT=8080
ENV HOST=0.0.0.0

EXPOSE 8080
CMD ["./main"]

Docker Compose

version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - PORT=8080
      - DB_HOST=database
      - DB_PORT=5432
      - DB_USER=myuser
      - DB_PASSWORD=mypassword
    env_file:
      - .env
    depends_on:
      - database

  database:
    image: postgres:15-alpine
    environment:
      - POSTGRES_DB=myapp
      - POSTGRES_USER=myuser
      - POSTGRES_PASSWORD=mypassword

Resources

Next Steps

Now that you understand environment variables in Go, learn how to build web applications with Gin or explore Go deployment strategies.

Last updated on