Secrets Management for Self-Hosting
What Is Secrets Management?
Secrets management is how you store, distribute, and rotate sensitive data — API keys, database passwords, TLS certificates, OAuth tokens, and encryption keys. Every self-hosted service needs credentials, and how you handle them determines whether your setup is secure or a breach waiting to happen.
Updated March 2026: Verified with latest Docker images and configurations.
The Four Levels
Most self-hosters progress through these levels as their setup grows:
| Level | Method | Security | Effort | Best For |
|---|---|---|---|---|
| 1 | Hardcoded in docker-compose.yml | Low | Minimal | Learning, local-only |
| 2 | .env files (not in Git) | Medium | Low | Small setups (1-5 services) |
| 3 | Encrypted files (SOPS/age) | High | Medium | Git-tracked infrastructure |
| 4 | Dedicated vault (Vault/Infisical) | Highest | Higher | Teams, compliance needs |
Level 1: Environment Variables in Compose
The starting point. Secrets are inline in docker-compose.yml:
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: mysecretpassword # Don't do this in production
Problems: Secrets are visible in docker inspect, shell history, and Git commits. Fine for learning, dangerous for anything internet-facing.
Level 2: .env Files
Move secrets to a .env file alongside your Compose file:
# .env (NEVER commit this file)
POSTGRES_PASSWORD=a_strong_random_password_here
GRAFANA_ADMIN_PASSWORD=another_strong_password
SMTP_PASSWORD=your_email_password
# docker-compose.yml
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Critical: Add .env to .gitignore immediately:
echo ".env" >> .gitignore
Generate strong passwords:
# Generate a 32-character random password
openssl rand -base64 32
# Generate a hex string (for encryption keys)
openssl rand -hex 32
This is sufficient for most home self-hosting setups. The risk is losing the .env file — back it up securely.
Level 3: Encrypted Files (SOPS + age)
For infrastructure stored in Git, encrypt secrets in place. SOPS encrypts values while leaving keys readable:
# Install
brew install sops age # or: apt install age && go install go.mozilla.org/sops/v3/cmd/sops@latest
# Generate encryption key
age-keygen -o ~/.config/sops/age/keys.txt
# Save the public key (age1...) — you'll need it for .sops.yaml
# Create config
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: \.enc\.(yaml|json|env)$
age: >-
age1your_public_key_here
EOF
# Encrypt
sops --encrypt secrets.yaml > secrets.enc.yaml
# Decrypt at deploy time
sops --decrypt secrets.enc.yaml > secrets.yaml
The encrypted file is safe to commit to Git. Only someone with the age private key can decrypt it.
Advantages over plain .env:
- Secrets are version-controlled (you can see when they changed)
- No risk of accidentally committing plaintext secrets
- Key rotation is straightforward (re-encrypt with a new key)
See Vault vs SOPS for a detailed comparison.
Level 4: Dedicated Secrets Vault
For advanced setups with multiple services, teams, or compliance requirements:
| Tool | Best For | Self-Hosted |
|---|---|---|
| HashiCorp Vault | Enterprise, dynamic secrets, PKI | Yes |
| Infisical | Modern UI, developer-friendly | Yes |
| Docker Secrets | Docker Swarm clusters | Built-in |
Vaults provide API-driven secret access, automatic rotation, audit logs, and access policies. The trade-off is operational complexity — another service to maintain, back up, and keep running 24/7.
Docker Compose Secrets (Native)
Docker Compose v2 supports file-based secrets without Swarm mode:
services:
app:
image: myapp:latest
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The secret is mounted at /run/secrets/db_password inside the container. The app reads it as a file, not an environment variable. This is more secure than env vars (not visible in docker inspect), but the app must support reading secrets from files.
See Docker Compose Secrets for the full guide.
Common Mistakes
1. Committing Secrets to Git
Even if you delete the file later, Git history retains it forever. If it happens:
# Remove from history (requires force push — coordinate with team)
git filter-branch --force --index-filter \
"git rm --cached --ignore-unmatch .env" \
--prune-empty --tag-name-filter cat -- --all
Better: prevent it with a pre-commit hook or .gitignore entry.
2. Using the Same Password Everywhere
Every service should have a unique password. If your Nextcloud database password is compromised, it shouldn’t also be your Grafana password. Use openssl rand -base64 32 to generate unique passwords for each service.
3. Storing Secrets in Docker Image Labels or Build Args
Build args and labels are visible in the image metadata:
# WRONG — ARG values are in image history
ARG DB_PASSWORD
ENV DB_PASSWORD=$DB_PASSWORD
Pass secrets at runtime via environment variables or mounted files, never at build time.
4. Not Rotating Secrets After a Breach
If your server was compromised or a Git repo was briefly public, rotate ALL secrets — not just the ones you think were exposed.
Practical Recommendation
| Setup Size | Recommendation |
|---|---|
| 1-3 services, home only | Level 2 (.env files) |
| 5-10 services, Git-tracked | Level 3 (SOPS + age) |
| 10+ services, multiple users | Level 4 (Vault or Infisical) |
| Any size with compliance needs | Level 4 (Vault for audit logging) |
Most self-hosters should start with Level 2 and move to Level 3 when their infrastructure goes into Git.
Next Steps
- Docker Compose Secrets — native Docker secrets support
- How to Self-Host Vault — full Vault setup guide
- How to Self-Host Infisical — modern secrets manager
- Vault vs SOPS — detailed comparison
- Security Basics for Self-Hosting
Related
Get self-hosting tips in your inbox
Get the Docker Compose configs, hardware picks, and setup shortcuts we don't put in articles. Weekly. No spam.
Comments