Vault Config Loader

A complete application that authenticates to HashiCorp Vault (auto-detecting Kubernetes vs AppRole environment), reads secrets from KV v2, and populates a typed config struct for the application to use.

Input: Vault address and auth credentials (AppRole role/secret IDs or Kubernetes service account). Output: A populated AppConfig struct with database, Redis, and API credentials ready for use.

Setup

mkdir vault-config-loader
cd vault-config-loader
go mod init github.com/yourorg/vault-config-loader
go get github.com/hashicorp/vault/api@latest

main.go

package main

import (
	"context"
	"fmt"
	"log"
	"os"

	vault "github.com/hashicorp/vault/api"
)

type AppConfig struct {
	DBHost     string
	DBUser     string
	DBPassword string
	DBName     string
	RedisURL   string
	APIKey     string
}

// loginAppRole authenticates with Vault using AppRole credentials and sets the
// resulting token on client. Use this in CI/CD environments.
func loginAppRole(client *vault.Client, roleID, secretID string) error {
	data := map[string]interface{}{
		"role_id":   roleID,
		"secret_id": secretID,
	}

	resp, err := client.Logical().Write("auth/approle/login", data)
	if err != nil {
		return fmt.Errorf("approle login: %w", err)
	}

	client.SetToken(resp.Auth.ClientToken)
	return nil
}

// loginKubernetes authenticates with Vault using the pod's service account JWT.
// No external secrets needed; Vault validates the JWT against the cluster.
func loginKubernetes(client *vault.Client, role string) error {
	jwt, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token")
	if err != nil {
		return fmt.Errorf("reading SA token: %w", err)
	}

	data := map[string]interface{}{
		"jwt":  string(jwt),
		"role": role,
	}

	resp, err := client.Logical().Write("auth/kubernetes/login", data)
	if err != nil {
		return fmt.Errorf("k8s auth: %w", err)
	}

	client.SetToken(resp.Auth.ClientToken)
	return nil
}

// stringField safely extracts a string field from a Vault KV data map.
func stringField(data map[string]interface{}, key string) (string, error) {
	v, ok := data[key]
	if !ok {
		return "", fmt.Errorf("key %q not found in vault response", key)
	}
	s, ok := v.(string)
	if !ok {
		return "", fmt.Errorf("key %q is not a string (got %T)", key, v)
	}
	return s, nil
}

// loadConfig auto-detects the runtime environment, authenticates to Vault, reads
// secrets from KV v2, and returns a populated AppConfig. It uses Kubernetes auth
// inside a pod and AppRole elsewhere.
func loadConfig(ctx context.Context) (*AppConfig, error) {
	addr := os.Getenv("VAULT_ADDR")
	if addr == "" {
		addr = "http://127.0.0.1:8200" // default for local dev server
	}

	config := vault.DefaultConfig()
	config.Address = addr

	client, err := vault.NewClient(config)
	if err != nil {
		return nil, fmt.Errorf("vault client: %w", err)
	}

	// Auto-detect environment: Kubernetes pods have KUBERNETES_SERVICE_HOST set
	if os.Getenv("KUBERNETES_SERVICE_HOST") != "" {
		if err := loginKubernetes(client, "myapp"); err != nil {
			return nil, fmt.Errorf("vault auth: %w", err)
		}
	} else {
		roleID := os.Getenv("VAULT_ROLE_ID")
		secretID := os.Getenv("VAULT_SECRET_ID")
		if roleID == "" || secretID == "" {
			return nil, fmt.Errorf("VAULT_ROLE_ID and VAULT_SECRET_ID are required outside Kubernetes")
		}
		if err := loginAppRole(client, roleID, secretID); err != nil {
			return nil, fmt.Errorf("vault auth: %w", err)
		}
	}

	// Read secrets from KV v2
	dbSecrets, err := client.KVv2("secret").Get(ctx, "myapp/db")
	if err != nil {
		return nil, fmt.Errorf("read db secrets: %w", err)
	}

	redisSecrets, err := client.KVv2("secret").Get(ctx, "myapp/redis")
	if err != nil {
		return nil, fmt.Errorf("read redis secrets: %w", err)
	}

	// Build typed config — use stringField to avoid panics on missing keys
	cfg := &AppConfig{}

	if cfg.DBHost, err = stringField(dbSecrets.Data, "host"); err != nil {
		return nil, err
	}
	if cfg.DBUser, err = stringField(dbSecrets.Data, "username"); err != nil {
		return nil, err
	}
	if cfg.DBPassword, err = stringField(dbSecrets.Data, "password"); err != nil {
		return nil, err
	}
	if cfg.DBName, err = stringField(dbSecrets.Data, "dbname"); err != nil {
		return nil, err
	}
	if cfg.RedisURL, err = stringField(redisSecrets.Data, "url"); err != nil {
		return nil, err
	}
	if cfg.APIKey, err = stringField(redisSecrets.Data, "api_key"); err != nil {
		return nil, err
	}

	return cfg, nil
}

func main() {
	ctx := context.Background()

	cfg, err := loadConfig(ctx)
	if err != nil {
		log.Fatalf("config load failed: %v", err)
	}

	fmt.Printf("Connected to %s@%s/%s\n", cfg.DBUser, cfg.DBHost, cfg.DBName)
	// Start application with cfg...
}

Running It Locally

Start a Vault dev server (in-memory, no persistence, token root):

vault server -dev -dev-root-token-id=root

In a second terminal, seed the secrets:

export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=root

# Write DB secrets
vault kv put secret/myapp/db \
  host=localhost \
  username=myapp \
  password=supersecret \
  dbname=myapp

# Write Redis secrets
vault kv put secret/myapp/redis \
  url=redis://localhost:6379 \
  api_key=abc123

Run the loader using token auth (dev only):

export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=root

go run main.go

Expected output:

Connected to myapp@localhost/myapp

Running with AppRole Auth

Enable AppRole and create a role in Vault:

vault auth enable approle
vault policy write myapp-policy - <<EOF
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
EOF

vault write auth/approle/role/myapp \
  token_policies="myapp-policy" \
  token_ttl=1h

# Get the role ID and generate a secret ID
export VAULT_ROLE_ID=$(vault read -field=role_id auth/approle/role/myapp/role-id)
export VAULT_SECRET_ID=$(vault write -field=secret_id -f auth/approle/role/myapp/secret-id)

Then run without VAULT_TOKEN:

unset VAULT_TOKEN
export VAULT_ADDR=http://127.0.0.1:8200

go run main.go

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy