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:

LevelMethodSecurityEffortBest For
1Hardcoded in docker-compose.ymlLowMinimalLearning, local-only
2.env files (not in Git)MediumLowSmall setups (1-5 services)
3Encrypted files (SOPS/age)HighMediumGit-tracked infrastructure
4Dedicated vault (Vault/Infisical)HighestHigherTeams, 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:

ToolBest ForSelf-Hosted
HashiCorp VaultEnterprise, dynamic secrets, PKIYes
InfisicalModern UI, developer-friendlyYes
Docker SecretsDocker Swarm clustersBuilt-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 SizeRecommendation
1-3 services, home onlyLevel 2 (.env files)
5-10 services, Git-trackedLevel 3 (SOPS + age)
10+ services, multiple usersLevel 4 (Vault or Infisical)
Any size with compliance needsLevel 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

Comments