
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Hardcoded credentials and scattered configuration files are the most common security failures I see during infrastructure audits. To properly manage secrets and config in Go apps, you must decouple sensitive data from source code while maintaining a predictable loading hierarchy for different environments. This separation ensures your application remains portable across local development, CI pipelines, and production clusters without exposing critical assets.
How do you structure configuration loading in Go?
The standard library os.Getenv works for simple scripts but fails for complex applications requiring type safety, validation, and multi-source merging. In practice, most production Go services use a dedicated library like Kubernetes secrets management patterns adapted for application-level code, or more commonly, the Viper library. Viper provides a unified interface to read from files, environment variables, and remote stores while handling case-insensitive key mapping.
Setting up a typed configuration struct
Avoid passing raw maps throughout your codebase. Define a strict struct that represents your entire configuration surface. This enables compile-time checks and makes dependencies explicit.
package config
type Config struct {
Server ServerConfig
Database DatabaseConfig
Auth AuthConfig
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Host string `mapstructure:"host"`
}
type DatabaseConfig struct {
URL string `mapstructure:"url"`
MaxConns int `mapstructure:"max_conns"`
}
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
} Loading with Viper and automatic env binding
Bind environment variables automatically so containers can override file-based config without code changes. The prefix prevents collisions in shared environments.
func Load() (*Config, error) {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("/etc/app/")
// Bind all keys to APP_ prefixed env vars
v.SetEnvPrefix("APP")
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
// Read file if exists (not required)
_ = v.ReadInConfig()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
} How do you securely inject secrets at runtime?
Configuration files should never contain production secrets. Even encrypted files in Git create operational risk when keys rotate or team members leave. Instead, treat secrets as a distinct concern injected at the platform layer. For teams managing secrets in CI/CD pipelines, this same principle applies: the pipeline provides the secret, the app consumes it blindly.
Environment variables for containerized workloads
Containers make environment variables the universal secret transport. They are supported by every orchestrator and require no additional SDKs. However, they have limitations: visible in process listings, truncated in some logging systems, and difficult to rotate without restarts.
- Use strong prefixes (
APP_DB_PASSWORD) to avoid collisions - Never log the full environment block in error handlers
- Validate presence at startup — fail immediately if missing
- Consider base64 encoding only if the value contains newlines or special chars
Cloud-native secret stores for production
For AWS, GCP, or Azure deployments, fetch secrets directly from managed stores. This enables rotation without redeployment and centralizes audit trails. If you are building on AWS, refer to managing secrets with AWS Secrets Manager for IAM policy patterns.
import "github.com/aws/aws-sdk-go-v2/service/secretsmanager"
func getSecret(ctx context.Context, name string) (string, error) {
client := secretsmanager.NewFromConfig(cfg)
out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(name),
})
if err != nil {
return "", fmt.Errorf("fetch secret %s: %w", name, err)
}
return *out.SecretString, nil
} How do you validate configuration at startup?
Failing after 30 minutes of operation because a typo broke a database URL is unacceptable. Validate every required field during initialization. Use a validation library or custom methods on your config struct to enforce constraints before any HTTP server starts or database connection opens.
func (c *Config) Validate() error {
if c.Server.Port <= 0 || c.Server.Port > 65535 {
return errors.New("server.port must be between 1 and 65535")
}
if c.Database.URL == "" {
return errors.New("database.url is required")
}
if len(c.Auth.JWTSecret) < 32 {
return errors.New("auth.jwt_secret must be at least 32 characters")
}
return nil
}
// In main.go
cfg, err := config.Load()
if err != nil { log.Fatal(err) }
if err := cfg.Validate(); err != nil {
log.Fatalf("invalid configuration: %v", err)
} This pattern aligns with Twelve-Factor App principles where config validity is a deployment-time concern, not a runtime surprise. For observability integration, ensure validated config values (non-secret ones) are exposed via health endpoints or structured logs as described in structured logging best practices.
What are the trade-offs between config approaches in Go?
No single method fits every scenario. Small CLI tools differ fundamentally from long-lived microservices. Understanding these trade-offs prevents over-engineering simple tools or under-securing critical services.
| Approach | Best For | Security Level | Rotation Support | Complexity |
|---|---|---|---|---|
| Hardcoded Constants | Prototypes only | Critical Risk | None | Trivial |
| .env Files + godotenv | Local Development | Low | Manual Restart | Low |
| Viper + YAML + Env Override | Most Production Apps | Medium-High | Restart Required | Medium |
| Cloud Secret Manager SDK | Regulated / Multi-Tenant | High | Dynamic Possible | Medium-High |
| Kubernetes Secrets Volume | K8s-Native Workloads | High | Pod Restart | Platform Dependent |
| HashiCorp Vault Agent | Enterprise / Zero Trust | Highest | Automatic Renewal | High |
In my experience auditing Nepal-based fintech startups and global SaaS platforms alike, the Viper + Environment Variable hybrid covers 80% of use cases. Reserve Vault or direct SDK integration for systems handling PII, payments, or requiring SOC 2 compliance evidence trails.
How do you prevent secret leaks in Go projects?
Technical controls matter less than workflow discipline. Even perfect encryption fails if a developer copies a production key into a test file. Implement defense-in-depth across the entire lifecycle.
- Add pre-commit hooks: Tools like
gitleaksortrufflehogscan staged changes before they reach remote repositories. Configure them in.pre-commit-config.yamlso enforcement is automatic. - Exclude config files from version control: Your
.gitignoremust include*.env,config.prod.yaml, and any private key patterns. Commit onlyconfig.example.yamlwith placeholder values. - Redact logs aggressively: Create a custom zap/slog encoder that masks fields named
password,secret,token, orkey. Assume every log line will eventually appear in a support ticket. - Rotate on personnel changes: When an engineer leaves or changes roles, assume their cached credentials are compromised. Automate rotation where possible; document manual steps otherwise.
- Audit access patterns: Cloud secret stores provide access logs. Set alerts for unusual fetch frequencies or source IPs outside your VPC/Kubernetes cluster.
Manage Secrets and Config in Go Apps for Production Readiness
Getting configuration right is foundational to reliable operations. Start with Viper and environment variables for flexibility, add cloud secret stores when compliance demands it, and validate everything before your first request handler runs. Remember that the goal isn't just security — it's predictable behavior across every environment your team touches. If your current setup involves committed YAML files with real passwords or unvalidated startup sequences, prioritize fixing those gaps this week. For architecture reviews or help implementing secure configuration patterns in your Go services, reach out to discuss your specific requirements.