Language:English VersionChinese Version

Every production application has secrets: database passwords, API keys, TLS certificates, OAuth client secrets. How you manage them determines whether a misconfiguration is a minor incident or a front-page breach. Yet secrets management remains one of the most commonly neglected areas of infrastructure.

I have implemented secrets management for teams ranging from 3-person startups to 50-developer organizations. The right tool depends on your team size, infrastructure complexity, and compliance requirements. This article compares the three dominant approaches in 2026 and provides implementation guidance for each.

The Problem with .env Files

Let us start with what most teams actually do: store secrets in .env files, committed to a private repository or shared via Slack. This works until it does not.

Real incidents I have witnessed:

  • A developer committed a .env file to a public repository. The AWS keys were used to spin up crypto miners within 14 minutes. Cost: $23,000 before AWS flagged it.
  • A team shared database credentials via Slack DM. Six months later, a former employee still had access because nobody rotated the credentials after their departure.
  • A staging environment used production database credentials “temporarily” for three years. A junior developer ran a migration script against the wrong environment.

These are not exotic scenarios. They are Monday morning incidents. The common thread is that .env files have no access control, no audit trail, no rotation mechanism, and no encryption at rest.

HashiCorp Vault: The Enterprise Standard

Vault is the most comprehensive secrets management solution available. It handles secret storage, dynamic credential generation, encryption as a service, and identity-based access control.

When Vault Is Right

  • You need dynamic secrets (database credentials generated per-session)
  • Compliance requires audit trails for all secret access
  • You have dedicated infrastructure or DevOps capacity to manage it
  • Multiple teams need different access levels to different secrets

Setting Up Vault for a Small Team

# docker-compose.yml for Vault in development
services:
  vault:
    image: hashicorp/vault:1.17
    cap_add:
      - IPC_LOCK
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: "dev-only-token"
      VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
    ports:
      - "8200:8200"

# For production, use Raft storage backend:
# vault server -config=/vault/config/vault.hcl
# Production Vault configuration
# /vault/config/vault.hcl
storage "raft" {
  path    = "/vault/data"
  node_id = "vault-1"
}

listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_cert_file = "/vault/tls/server.crt"
  tls_key_file  = "/vault/tls/server.key"
}

api_addr     = "https://vault.internal:8200"
cluster_addr = "https://vault.internal:8201"

# Enable audit logging
audit {
  type = "file"
  options {
    file_path = "/vault/logs/audit.log"
  }
}

Dynamic Database Credentials

Vaults killer feature is dynamic secrets. Instead of storing a static database password, Vault generates unique credentials for each application instance with automatic expiration:

# Enable the database secrets engine
vault secrets enable database

# Configure PostgreSQL connection
vault write database/config/myapp-db   plugin_name=postgresql-database-plugin   allowed_roles="app-readonly","app-readwrite"   connection_url="postgresql://{{username}}:{{password}}@db.internal:5432/myapp?sslmode=require"   username="vault_admin"   password="initial-admin-password"

# Create a role that generates read-only credentials
vault write database/roles/app-readonly   db_name=myapp-db   creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';     GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";"   default_ttl="1h"   max_ttl="24h"

# Application requests credentials
vault read database/creds/app-readonly
# Returns: username=v-app-readonly-xyz123, password=A1B2C3..., lease_duration=1h

Every credential is unique, time-limited, and audited. When an employee leaves, you do not need to rotate shared passwords — their Vault token is revoked, and all credentials it generated expire automatically.

Vault in Application Code

// Go application using Vault for secrets
package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    vault "github.com/hashicorp/vault/api"
    _ "github.com/lib/pq"
)

type VaultDBProvider struct {
    client *vault.Client
    role   string
    db     *sql.DB
    lease  string
}

func (v *VaultDBProvider) GetConnection(ctx context.Context) (*sql.DB, error) {
    // Read dynamic credentials from Vault
    secret, err := v.client.Logical().ReadWithContext(ctx,
        "database/creds/"+v.role)
    if err != nil {
        return nil, fmt.Errorf("reading vault credentials: %w", err)
    }

    username := secret.Data["username"].(string)
    password := secret.Data["password"].(string)
    v.lease = secret.LeaseID

    dsn := fmt.Sprintf("postgres://%s:%s@db.internal:5432/myapp?sslmode=require",
        username, password)

    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("connecting to database: %w", err)
    }

    // Start background renewal
    go v.renewLease(ctx, secret)

    v.db = db
    return db, nil
}

func (v *VaultDBProvider) renewLease(ctx context.Context, secret *vault.Secret) {
    renewer, err := v.client.NewLifetimeWatcher(&vault.LifetimeWatcherInput{
        Secret: secret,
    })
    if err != nil {
        log.Printf("Failed to create lease renewer: %v", err)
        return
    }
    go renewer.Start()
    defer renewer.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case err := <-renewer.DoneCh():
            if err != nil {
                log.Printf("Lease renewal failed, reconnecting: %v", err)
                v.GetConnection(ctx) // Get fresh credentials
            }
            return
        case <-renewer.RenewCh():
            log.Printf("Lease renewed successfully")
        }
    }
}

SOPS: Encrypted Files in Git

Mozilla SOPS (Secrets OPerationS) takes a different approach: encrypt secrets in files that live alongside your code in Git. The encrypted files are committed to the repository, but only authorized users and systems can decrypt them.

When SOPS Is Right

  • You want secrets versioned alongside code (GitOps workflow)
  • Your team is small (under 15 developers)
  • You do not need dynamic secret generation
  • You already use AWS KMS, GCP KMS, or Azure Key Vault for key management

Setting Up SOPS

# .sops.yaml - Configuration file at repo root
creation_rules:
  # Production secrets encrypted with AWS KMS
  - path_regex: environments/production/.*\.yaml$
    kms: "arn:aws:kms:us-east-1:123456789:key/production-key-id"
    encrypted_regex: "^(password|secret|key|token|dsn)$"

  # Staging secrets encrypted with a different key
  - path_regex: environments/staging/.*\.yaml$
    kms: "arn:aws:kms:us-east-1:123456789:key/staging-key-id"
    encrypted_regex: "^(password|secret|key|token|dsn)$"

  # Development secrets encrypted with age (no cloud dependency)
  - path_regex: environments/dev/.*\.yaml$
    age: "age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p"
    encrypted_regex: "^(password|secret|key|token|dsn)$"
# Create a secrets file
cat > environments/production/secrets.yaml << EOF
database:
  password: "super-secret-production-password"
  host: "db.production.internal"
api:
  key: "sk_live_abc123def456"
  secret: "whsec_production_webhook_secret"
redis:
  password: "redis-production-password"
  host: "redis.production.internal"
EOF

# Encrypt it
sops --encrypt --in-place environments/production/secrets.yaml

# The file now looks like this in Git:
# database:
#     password: ENC[AES256_GCM,data:abc123...,iv:xyz...,tag:...]
#     host: db.production.internal  # Non-secret values stay readable
# api:
#     key: ENC[AES256_GCM,data:def456...,iv:abc...,tag:...]

Notice that only the values matching encrypted_regex are encrypted. The structure of the file remains readable, which makes code review meaningful — you can see that a new secret was added without being able to read its value.

SOPS in CI/CD

# GitHub Actions workflow using SOPS
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # For AWS OIDC authentication
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/deploy-role
          aws-region: us-east-1

      - name: Install SOPS
        run: |
          curl -LO https://github.com/getsops/sops/releases/download/v3.9.0/sops-v3.9.0.linux.amd64
          chmod +x sops-v3.9.0.linux.amd64
          sudo mv sops-v3.9.0.linux.amd64 /usr/local/bin/sops

      - name: Decrypt and deploy
        run: |
          # Decrypt secrets to environment variables
          eval $(sops --decrypt --output-type dotenv environments/production/secrets.yaml)
          # Deploy with decrypted secrets available
          ./deploy.sh

Sealed Secrets: Kubernetes-Native Approach

If you run Kubernetes, Bitnami Sealed Secrets solves the "how do I store Kubernetes Secrets in Git" problem elegantly.

When Sealed Secrets Is Right

  • You run Kubernetes and use GitOps (Flux or ArgoCD)
  • You want secrets managed declaratively in Git
  • You do not need secrets outside of Kubernetes

How It Works

# Install the controller in your cluster
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm install sealed-secrets sealed-secrets/sealed-secrets   --namespace kube-system

# Create a regular Kubernetes secret
kubectl create secret generic myapp-secrets   --from-literal=database-password="production-password"   --from-literal=api-key="sk_live_abc123"   --dry-run=client -o yaml > myapp-secret.yaml

# Seal it (encrypt with the cluster's public key)
kubeseal --format yaml < myapp-secret.yaml > myapp-sealed-secret.yaml

# The sealed secret can be safely committed to Git
cat myapp-sealed-secret.yaml
# myapp-sealed-secret.yaml - Safe to commit
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  name: myapp-secrets
  namespace: default
spec:
  encryptedData:
    database-password: AgBy3i4OJSWK+PiTySYZZA9rO...  # Encrypted
    api-key: AgCtr8YPOJSWK+QwRtGHn8B2rP...           # Encrypted
  template:
    metadata:
      name: myapp-secrets
      namespace: default
    type: Opaque

When applied to the cluster, the Sealed Secrets controller decrypts the SealedSecret and creates a regular Kubernetes Secret that pods can mount as volumes or environment variables.

Comparison Matrix

Feature Vault SOPS Sealed Secrets
Dynamic secrets Yes No No
Audit logging Built-in Git history K8s audit logs
Secret rotation Automatic Manual Manual
Infrastructure needed Vault cluster KMS service K8s controller
Learning curve High Low Medium
Cost (small team) $50-200/mo $0-5/mo $0
GitOps compatible Via agent Native Native
Non-K8s support Yes Yes No
Compliance ready SOC 2, HIPAA Depends on KMS Limited

My Recommendations by Team Size

1-5 developers, no compliance requirements: Start with SOPS and age (no cloud dependency). It takes 30 minutes to set up and handles 90% of secrets management needs. Graduate to Vault when you need dynamic credentials or audit requirements emerge.

5-20 developers, basic compliance: SOPS with AWS KMS or GCP KMS. The cloud KMS gives you key rotation and access logging without running additional infrastructure. Add Sealed Secrets if you run Kubernetes.

20+ developers, SOC 2 or HIPAA: Vault. The investment in setup and maintenance pays off when auditors ask "who accessed this secret and when?" and you can show them a complete audit trail with automatic credential rotation.

Common Mistakes to Avoid

  1. Encrypting entire files instead of individual values. Use SOPSs encrypted_regex to keep file structure readable. Code reviewers need to see what changed, even if they cannot see the actual secret values.
  2. Sharing Vault root tokens. Root tokens should be used once (during initial setup) and then revoked. Create named auth methods and policies for every user and service.
  3. Not rotating secrets after employee departure. Automate this. Vaults dynamic secrets handle it inherently. For SOPS, create a rotation runbook and schedule it.
  4. Using the same secrets across environments. Production and staging should never share credentials. If staging is compromised, production should be unaffected.
  5. Logging secrets accidentally. Audit your logging pipeline. Structured logging with explicit field selection is safer than logging entire request objects.

Secrets management is not glamorous work, but it is the difference between a contained incident and a catastrophic breach. Pick the tool that matches your teams size and complexity, implement it properly, and move on to building features. The best secrets management system is the one your team actually uses consistently.

By

Leave a Reply

Your email address will not be published. Required fields are marked *