Secrets Management — Environment Variables, Vault & Sealed Secrets
How to manage secrets in production — environment variables, HashiCorp Vault, Kubernetes Sealed Secrets, and patterns for keeping credentials out of code.
Every application has secrets — database passwords, API keys, JWT signing keys, encryption keys. The question is where they live and who can access them.
The wrong answer: committed to Git, hardcoded in source, or shared in Slack. The right answer depends on your infrastructure, but the principles are universal.
The hierarchy of secrets management
From simplest to most secure:
Level 0: Hardcoded in source code ← NEVER do this
Level 1: .env files (gitignored) ← Local development only
Level 2: Environment variables ← Simple deployments
Level 3: Encrypted config files ← CI/CD, Kubernetes
Level 4: Secrets manager (Vault, AWS SM) ← Production systems
Move up the hierarchy as your system grows. Starting at Level 2 is reasonable for most projects.
Level 1: .env files for local development
# .env (NEVER commit this file)
DATABASE_URL=postgresql://localhost:5432/mydb
DATABASE_USER=admin
DATABASE_PASSWORD=local_dev_password
JWT_SECRET=dev-secret-not-for-production
STRIPE_API_KEY=sk_test_...
.gitignore:
.env
.env.local
.env.*.local
Provide a template so teammates know what’s needed:
# .env.example (commit this)
DATABASE_URL=postgresql://localhost:5432/mydb
DATABASE_USER=
DATABASE_PASSWORD=
JWT_SECRET=
STRIPE_API_KEY=
Spring Boot
# application.yml
spring:
datasource:
url: ${DATABASE_URL}
username: ${DATABASE_USER}
password: ${DATABASE_PASSWORD}
jwt:
secret: ${JWT_SECRET}
Spring Boot reads environment variables automatically. ${DATABASE_URL} resolves from the environment.
Ktor
val dbUrl = System.getenv("DATABASE_URL")
?: throw IllegalStateException("DATABASE_URL not set")
Level 2: Environment variables
Set secrets in your deployment environment — not in code.
Docker
# Don't put secrets in Dockerfile
# Use environment variables at runtime
CMD ["java", "-jar", "app.jar"]
docker run \
-e DATABASE_URL=postgresql://prod:5432/mydb \
-e DATABASE_PASSWORD=prod_password \
myapp:latest
Docker Compose
# docker-compose.yml
services:
app:
image: myapp:latest
env_file:
- .env.production
environment:
- DATABASE_URL=postgresql://db:5432/mydb
CI/CD (GitHub Actions)
Store secrets in GitHub repository settings → Secrets:
# .github/workflows/deploy.yml
jobs:
deploy:
steps:
- name: Deploy
env:
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
run: deploy.sh
Secrets are masked in logs. They’re available to the workflow but never printed.
Level 3: Encrypted config files
Kubernetes Secrets
# secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
type: Opaque
data:
database-password: cHJvZF9wYXNzd29yZA== # base64 encoded
jwt-secret: bXktand0LXNlY3JldA==
Mount as environment variables:
# deployment.yaml
spec:
containers:
- name: app
env:
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: database-password
Important: Kubernetes Secrets are base64-encoded, not encrypted. Anyone with access to the cluster can read them. For actual encryption, use Sealed Secrets or an external secrets manager.
Sealed Secrets (Kubernetes)
Bitnami Sealed Secrets encrypts secrets so they can be safely committed to Git:
# Encrypt a secret
kubeseal --format yaml < secret.yaml > sealed-secret.yaml
The sealed secret is encrypted with the cluster’s public key. Only the Sealed Secrets controller in the cluster can decrypt it:
# sealed-secret.yaml (safe to commit)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: app-secrets
spec:
encryptedData:
database-password: AgBy8h3k...encrypted...
jwt-secret: AgCf9d2l...encrypted...
Commit sealed-secret.yaml to Git. The controller decrypts it into a regular Kubernetes Secret at deploy time.
Level 4: Secrets managers
HashiCorp Vault
Vault is a dedicated secrets management tool. Secrets are stored encrypted, access is audited, and secrets can be rotated automatically.
# Store a secret
vault kv put secret/myapp/database password=prod_password
# Read a secret
vault kv get secret/myapp/database
Application integration
// Spring Cloud Vault
@Configuration
class VaultConfig {
@Value("\${database.password}")
lateinit var databasePassword: String
}
# bootstrap.yml
spring:
cloud:
vault:
host: vault.example.com
port: 8200
authentication: token
token: ${VAULT_TOKEN}
kv:
enabled: true
backend: secret
application-name: myapp
Spring Cloud Vault fetches secrets from Vault at startup and injects them as properties.
AWS Secrets Manager
val client = SecretsManagerClient.builder()
.region(Region.US_EAST_1)
.build()
val secret = client.getSecretValue(
GetSecretValueRequest.builder()
.secretId("myapp/database")
.build()
)
val credentials = Json.decodeFromString<DbCredentials>(secret.secretString())
When to use a secrets manager
- You have multiple services that need shared secrets
- You need audit trails for secret access
- You need automatic secret rotation
- Compliance requires it (SOC2, HIPAA, PCI)
Secret rotation
Secrets should change regularly. A leaked secret with a 90-day rotation window limits the damage to 90 days max.
Database password rotation
- Generate a new password
- Update the database user’s password
- Update the secret in Vault/AWS SM
- Applications pick up the new secret (dynamic or on restart)
With Vault, this can be automatic:
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
allowed_roles="myapp" \
connection_url="postgresql://{{username}}:{{password}}@db:5432/mydb" \
username="vault_admin" \
password="admin_password"
vault write database/roles/myapp \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}';" \
default_ttl="1h" \
max_ttl="24h"
Vault creates a new database user with a temporary password for each application connection. No shared password, automatic expiration.
Common mistakes
1. Secrets in Git history
Even if you delete the secret file, it’s in the Git history:
# This doesn't help — the secret is in previous commits
git rm .env
git commit -m "Remove secrets"
If secrets were committed, rotate them immediately. Use git filter-branch or BFG Repo Cleaner to remove from history, but always assume the secret is compromised.
2. Secrets in Docker images
# WRONG — secret baked into the image
ENV DATABASE_PASSWORD=prod_password
Anyone who pulls the image can read the secret. Pass secrets at runtime, not build time.
3. Secrets in logs
// WRONG — logs the password
logger.info("Connecting to database with password: $password")
// RIGHT — log that you're connecting, not the credentials
logger.info("Connecting to database at $dbHost:$dbPort")
Review logging to ensure secrets aren’t printed.
4. Overly broad access
If every developer can read production secrets, the blast radius of a compromise is huge. Apply least privilege:
- Developers access dev/staging secrets only
- Production secrets are accessible only by CI/CD and production systems
- Use short-lived credentials where possible
Checklist
- No secrets in source code or Git history
-
.envfiles gitignored,.env.examplecommitted - Production secrets in environment variables or secrets manager
- Secrets rotated on a schedule
- Audit trail for secret access
- Least-privilege access to secrets
- Secrets not logged or exposed in error messages
- Different secrets per environment (dev ≠ staging ≠ production)
Summary
| Level | Tool | Best for |
|---|---|---|
| Local dev | .env files | Individual developers |
| Simple deploy | Environment variables | Small teams, simple infrastructure |
| Kubernetes | Sealed Secrets | GitOps-based deployments |
| Enterprise | Vault / AWS Secrets Manager | Multi-service, compliance requirements |
Start with environment variables and .env files. Move to a secrets manager when you need rotation, auditing, or cross-service secret sharing. The one constant: secrets never belong in code.