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@latestmain.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=rootIn 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=abc123Run the loader using token auth (dev only):
export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=root
go run main.goExpected output:
Connected to myapp@localhost/myappRunning 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