Configuration - YAML & Environment Setup

Learn configuration management and environment strategies
Time: 30-35 minutes β€’ Level: Beginner β€’ Concepts: 4


🎯 What You’ll Learn

  • Load configuration from YAML files
  • Use environment variables in configuration
  • Organize multi-environment configs
  • Validate configuration automatically
  • Apply Code + Config pattern (recommended!)

πŸ“– Concepts

1. Configuration Basics

Lokstra supports pure code, pure YAML, or code + YAML hybrid approaches.

Recommended: Code + Config Pattern

  • Write core logic in code
  • Configure instances via YAML
  • Best of both worlds!
// Code: Define service factories
func NewPostgresService(params map[string]any) lokstra.Service {
    host := cast.GetValueFromMap(params, "host", "localhost")
    port := cast.GetValueFromMap(params, "port", 5432)
    // ... create and return service
}

func init() {
    lokstra_registry.RegisterServiceFactory("postgres", NewPostgresService)
}
# YAML: Configure instances
services:
  - name: main-db
    type: postgres
    config:
      host: ${DB_HOST:localhost}
      port: ${DB_PORT:5432}
      database: myapp

Why This Pattern?

  • βœ… Type-safe code
  • βœ… Flexible configuration
  • βœ… Easy environment management
  • βœ… Best for production

2. Loading Configuration

Single File

import "github.com/primadi/lokstra/core/config"

cfg := config.New()
err := config.LoadConfigFile("config.yaml", cfg)
if err != nil {
    log.Fatal(err)
}

Multiple Files (Merge)

cfg := config.New()

// Load base config
config.LoadConfigFile("config/base.yaml", cfg)

// Load environment-specific (merges with base)
config.LoadConfigFile("config/production.yaml", cfg)

Directory (Auto-merge)

cfg := config.New()

// Loads all .yaml and .yml files
// Merges them in alphabetical order
err := config.LoadConfigDir("config/", cfg)

File Merge Example:

config/
β”œβ”€β”€ 01-base.yaml      # Loaded first
β”œβ”€β”€ 02-services.yaml  # Merged second
└── 03-prod.yaml      # Merged last (overrides)

3. Environment Variables

Use ${VAR_NAME} or ${VAR_NAME:default} syntax:

services:
  - name: database
    type: postgres
    config:
      # Required environment variable
      password: ${DB_PASSWORD}
      
      # With default value
      host: ${DB_HOST:localhost}
      port: ${DB_PORT:5432}
      
      # Multiple variables
      dsn: "postgres://${DB_USER:postgres}:${DB_PASSWORD}@${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:myapp}"

Set environment variables:

# Linux/Mac
export DB_HOST=prod-db.example.com
export DB_PASSWORD=secret123

# Windows
set DB_HOST=prod-db.example.com
set DB_PASSWORD=secret123

In code:

os.Setenv("DB_HOST", "localhost")
os.Setenv("DB_PASSWORD", "secret")

cfg := config.New()
config.LoadConfigFile("config.yaml", cfg)
// Variables are expanded automatically

4. Configuration Validation

Automatic Validation

All config loading functions validate automatically:

cfg := config.New()
err := config.LoadConfigFile("config.yaml", cfg)
if err != nil {
    // Error includes validation details
    log.Fatal(err)
    // Output:
    // validation failed: 
    //   - services.0.name: This field is required
    //   - servers.0.apps.0.addr: Does not match pattern
}

Manual Validation

// Validate YAML string
yamlContent := `...`
err := config.ValidateYAMLString(yamlContent)

// Validate config struct
cfg := &config.Config{...}
err := config.ValidateConfig(cfg)

What Gets Validated:

  • Required fields present
  • Valid URL formats
  • Name patterns (alphanumeric + underscore)
  • Port ranges (1-65535)
  • Array constraints

πŸ’» Example 1: Basic YAML Configuration

File: config.yaml

# Define services
service-definitions:
  logger:
    type: logger
    config:
      level: info
      format: json

# Define routers
router-definitions:
  api:
    routes:
      - name: health
        path: /health
        handler: HealthCheckHandler
      
      - name: version
        path: /version
        handler: VersionHandler

# Define deployment
deployments:
  production:
    servers:
      web-server:
        base-url: http://localhost:8080
        addr: ":8080"
        published-services:
          - logger

File: main.go

package main

import (
    "log"
    "net/http"
    
    "github.com/primadi/lokstra"
    "github.com/primadi/lokstra/core/config"
    lokstra_registry "github.com/primadi/lokstra/lokstra_registry"
)

func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"status": "ok"}`))
}

func VersionHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte(`{"version": "1.0.0"}`))
}

func main() {
    // Register handlers
    lokstra_registry.RegisterHandler("HealthCheckHandler", HealthCheckHandler)
    lokstra_registry.RegisterHandler("VersionHandler", VersionHandler)
    
    // Load configuration
    cfg := config.New()
    if err := config.LoadConfigFile("config.yaml", cfg); err != nil {
        log.Fatal(err)
    }
    
    // Apply configuration and get server
    server, err := config.ApplyAllConfig(cfg, "web-server")
    if err != nil {
        log.Fatal(err)
    }
    
    // Start server
    log.Println("Server starting on :8080")
    server.Start()
}

Run:

go run main.go

# Test
curl http://localhost:8080/api/health
curl http://localhost:8080/api/version

πŸ’» Example 2: Environment-Based Configuration

Structure:

myapp/
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ base.yaml       # Shared config
β”‚   β”œβ”€β”€ dev.yaml        # Development
β”‚   β”œβ”€β”€ staging.yaml    # Staging
β”‚   └── prod.yaml       # Production
└── main.go

File: config/base.yaml

# Shared configuration
routers:
  - name: api
    routes:
      - name: users
        path: /users
        handler: GetUsersHandler
        method: GET

services:
  - name: database
    type: postgres
    # Config will be overridden by environment files

File: config/dev.yaml

# Development overrides
service-definitions:
  database:
    config:
      host: localhost
      port: 5432
      database: myapp_dev
      user: devuser
      password: devpass
      max_connections: 5

deployments:
  development:
    servers:
      api-server:
        base-url: http://localhost:3000
        addr: ":3000"
        published-services:
          - database

File: config/prod.yaml

# Production overrides
service-definitions:
  database:
    config:
      host: ${DB_HOST}
      port: ${DB_PORT:5432}
      database: ${DB_NAME}
      user: ${DB_USER}
      password: ${DB_PASSWORD}
      max_connections: 25
      ssl_mode: require

deployments:
  production:
    servers:
      api-server:
        base-url: ${API_BASE_URL}
        addr: ":8080"
        published-services:
          - database
        routers: [api]

File: main.go

package main

import (
    "log"
    "os"
    
    "github.com/primadi/lokstra/core/config"
    lokstra_registry "github.com/primadi/lokstra/lokstra_registry"
)

func main() {
    // Register factories and handlers
    lokstra_registry.RegisterServiceFactory("postgres", NewPostgresService)
    lokstra_registry.RegisterHandler("GetUsersHandler", GetUsersHandler)
    
    // Determine environment
    env := os.Getenv("APP_ENV")
    if env == "" {
        env = "dev"
    }
    
    // Load configuration
    cfg := config.New()
    
    // Load base
    if err := config.LoadConfigFile("config/base.yaml", cfg); err != nil {
        log.Fatal(err)
    }
    
    // Load environment-specific
    envFile := "config/" + env + ".yaml"
    if err := config.LoadConfigFile(envFile, cfg); err != nil {
        log.Fatal(err)
    }
    
    log.Printf("Loaded configuration for environment: %s", env)
    
    // Apply and start
    server, err := config.ApplyAllConfig(cfg, "api-server")
    if err != nil {
        log.Fatal(err)
    }
    
    server.Start()
}

Run:

# Development
APP_ENV=dev go run main.go

# Production (with environment variables)
export APP_ENV=prod
export DB_HOST=prod-db.example.com
export DB_PORT=5432
export DB_NAME=myapp_prod
export DB_USER=produser
export DB_PASSWORD=secretpassword
export API_BASE_URL=https://api.example.com

go run main.go

πŸ’» Example 3: Config References (CFG Resolver)

Use ${@CFG:path.to.config} to reference other config values:

File: config.yaml

# Define configuration values
configs:
  - name: features.debug
    value: true
  
  - name: features.timeout
    value: 30
  
  - name: database.max_connections
    value: 25
  
  - name: app.name
    value: MyApp

# Use config references
service-definitions:
  logger:
    type: logger
    config:
      debug: ${@CFG:features.debug}
      app_name: ${@CFG:app.name}
  
  database:
    type: postgres
    config:
      max_connections: ${@CFG:database.max_connections}
      connect_timeout: ${@CFG:features.timeout}

deployments:
  production:
    servers:
      api-server:
        base-url: http://localhost:8080
        addr: ":8080"
        published-services:
          - logger
          - database

Benefits:

  • βœ… DRY - Define once, use many times
  • βœ… Centralized configuration
  • βœ… Easy to override per environment

With Environment Overrides:

# base.yaml
configs:
  - name: features.debug
    value: true

# prod.yaml
configs:
  - name: features.debug
    value: false  # Override for production

🎯 Best Practices

1. Configuration Organization

βœ… DO: Use environment-based structure

config/
β”œβ”€β”€ base.yaml         # Shared configuration
β”œβ”€β”€ dev.yaml          # Development
β”œβ”€β”€ staging.yaml      # Staging
└── prod.yaml         # Production

βœ… DO: Use numbered prefixes for load order

config/
β”œβ”€β”€ 01-base.yaml
β”œβ”€β”€ 02-services.yaml
β”œβ”€β”€ 03-middlewares.yaml
└── 04-production.yaml

βœ— DON’T: Mix concerns in single file

# BAD: Everything in one file
services: [...]
middlewares: [...]
routers: [...]
servers: [...]
# Hard to maintain!

2. Environment Variables

βœ… DO: Use env vars for sensitive data

services:
  - name: database
    config:
      password: ${DB_PASSWORD}
      api_key: ${API_SECRET}

βœ… DO: Provide defaults for non-sensitive values

services:
  - name: database
    config:
      host: ${DB_HOST:localhost}
      port: ${DB_PORT:5432}

βœ— DON’T: Hardcode secrets

# BAD: Credentials in file
services:
  - name: database
    config:
      password: "hardcoded_password"  # NEVER DO THIS!

3. Configuration Validation

βœ… DO: Check errors on load

cfg := config.New()
if err := config.LoadConfigFile("config.yaml", cfg); err != nil {
    log.Fatal("Config error:", err)
}

βœ… DO: Validate before deployment

# Test config validation
go run ./cmd/validate-config config.yaml

βœ— DON’T: Ignore validation errors

config.LoadConfigFile("config.yaml", cfg)  // BAD: No error check

4. Code + Config Pattern

βœ… DO: Define factories in code

// Code - Type-safe and testable
func NewPostgresService(params map[string]any) lokstra.Service {
    cfg := ParsePostgresConfig(params)
    return &PostgresService{config: cfg}
}

func init() {
    lokstra_registry.RegisterServiceFactory("postgres", NewPostgresService)
}

βœ… DO: Configure instances in YAML

# YAML - Easy to change per environment
services:
  - name: main-db
    type: postgres  # References factory
    config:
      host: ${DB_HOST}
      port: ${DB_PORT}

βœ— DON’T: Put logic in YAML

# BAD: YAML can't contain logic
services:
  - name: database
    config:
      # This won't work - no conditionals in YAML!
      timeout: if debug then 60 else 30

πŸ” Common Patterns

Pattern 1: Feature Flags

configs:
  - name: features.new_ui
    value: false
  
  - name: features.beta_api
    value: true

middlewares:
  - name: feature-flags
    type: feature-flags
    config:
      new_ui: ${@CFG:features.new_ui}
      beta_api: ${@CFG:features.beta_api}

Pattern 2: Multi-Region Config

# config/base.yaml
configs:
  - name: region
    value: ${REGION:us-east-1}

services:
  - name: database
    type: postgres
    config:
      host: db-${@CFG:region}.example.com
# Deploy to different regions
REGION=us-east-1 go run main.go
REGION=eu-west-1 go run main.go
REGION=ap-south-1 go run main.go

Pattern 3: Service Composition

services:
  # Base services
  - name: postgres
    type: postgres
    config:
      host: ${DB_HOST:localhost}
  
  - name: redis
    type: redis
    config:
      host: ${REDIS_HOST:localhost}
  
  # Composite service using others
  - name: user-service
    type: user-service
    depends_on:
      - postgres
      - redis
    config:
      cache_enabled: true

πŸ“š Configuration Reference

Complete YAML Structure

# Configuration values
configs:
  - name: string           # Config key (use dotted notation)
    value: any             # Any value (string, number, bool, etc)

# Service definitions
services:
  - name: string           # Service name
    type: string           # Factory type
    depends_on: [string]   # Optional dependencies
    config: map            # Service-specific configuration

# Middleware definitions
middlewares:
  - name: string           # Middleware name
    type: string           # Factory type
    config: map            # Middleware-specific configuration

# Router definitions
router-definitions:
  router-name:
    engine_type: string    # Optional: default, gin, etc
    middlewares: [string]  # Router-level middleware
    routes:
      - name: string       # Route name
        path: string       # URL path
        method: string     # HTTP method (GET, POST, etc)
        handler: string    # Handler name
        middlewares: [string]  # Route-level middleware

# Deployment structure
deployments:
  deployment-name:
    servers:
      server-name:
        base-url: string   # Base URL
        addr: string       # Listen address (e.g., ":8080")
        published-services: [string]  # Service names

βœ… Quick Checklist

After completing this section, you should be able to:

  • Load configuration from YAML files
  • Use environment variables in YAML
  • Organize multi-environment configurations
  • Validate configuration automatically
  • Use CFG references for DRY config
  • Apply Code + Config pattern

πŸš€ Next Steps

Ready for more? Continue to:

πŸ‘‰ App & Server - Application lifecycle and server management

Or explore: