Every Docker Compose stack you deploy needs credentials. Database
passwords, API tokens, SMTP credentials, admin usernames, TLS
private keys. In a typical homelab with 15-20 containers, you
accumulate dozens of secrets scattered across compose files,
.env files, and shell scripts.
The default approach — hardcode everything in docker-compose.yml
or dump it in a .env file in the same directory — works until
it doesn’t. A misplaced commit, a backup that lands on the wrong
drive, or a visitor poking around your terminal history reveals
every credential in plaintext.
This guide walks through four tiers of Docker secrets management for homelab environments, from dead simple to properly vault-backed. Each tier builds on the previous one, so you can start where you are and level up as your paranoia (or exposure) grows.
Tier 1 — Better .env Hygiene (The Baseline)
If you’re not using .env files yet, start here. If you are,
clean them up.
Stop Hardcoding Everything
Don’t do this:
|
|
Do this:
|
|
The :? syntax causes Docker Compose to fail at startup with a
clear error message instead of substituting an empty string:
|
|
Structure Your .env Files
A single flat .env in the project root is fine for small stacks.
For larger setups, use separate files for different environments:
|
|
Reference the file explicitly in your compose command:
|
|
.gitignore Is Your Safety Net
# /opt/authentik/.gitignore
.env
.env.*
secrets/
*.key
*.pem
!*.example
If you version-control your compose files (and you should), the
.gitignore prevents secrets from ever landing in a repo. Test
it:
|
|
Generate Secrets Automatically
Stop typing passwords. Generate them at deployment:
|
|
This is the bare minimum. Every homelab should be here. The remaining tiers add encryption and rotation.
Tier 2 — Docker Secrets for Sensitive Files
Docker has a built-in secrets feature that works natively in Swarm mode and is available in standalone Compose via file-based secrets.
Compose File-Based Secrets
For docker compose (not Swarm), secrets are files mounted into
the container at runtime:
|
|
Inside the container, each secret is available as a plaintext file
at /run/secrets/<name>. The application reads the file path
from the _FILE environment variable.
Why Use Docker Secrets Over Environment Variables
| Concern | Environment Variables | Docker Secrets (file) |
|---|---|---|
Visible in docker inspect |
Yes (plaintext) | No (file path only) |
Visible in /proc |
Yes | No |
| Leaked in error traces | Common | Unlikely |
Leaked in docker logs |
If printed at startup | Only if app reads + prints |
| Readable by child processes | Yes | Only if mounted readable |
| File permissions | N/A | 0400 by default (root) |
| IDE auto-complete logs | Can leak | No |
The key advantage: a secret mounted as a file never appears in
docker inspect output, docker logs, or environment dumps.
This matters when you paste logs into a forum or bug report.
Database Password Example
Most Docker images (Postgres, MySQL, Authentik) support the
_FILE suffix convention:
|
|
Create Secrets Securely
|
|
Git Ignore the Secrets Directory
# In project .gitignore
secrets/
*.txt
Tier 3 — SOPS with age (Encrypted at Rest)
Files on disk are still plaintext. If someone gains access to your
server and reads secrets/pg_pass.txt, the game is over. SOPS
(Secrets OPerationS) by Mozilla encrypts your secrets at rest and
decrypts them only at deployment time.
How SOPS Works with age
SOPS supports multiple encryption backends: age, PGP, AWS KMS, GCP KMS, and Azure Key Vault. For homelab use, age (Actually Good Encryption) is the sweet spot — simpler than PGP, no key server dependency, single file key.
flowchart LR
A[secrets.enc.env] -->|sops decrypt| B[.env.production]
B -->|docker compose| C[Containers]
D[age key: ~/.config/sops/age/keys.txt] --> A
Step 1 — Install SOPS and age
|
|
Step 2 — Generate an age Key Pair
|
|
Protect this key. If you lose keys.txt, all your encrypted
secrets are permanently unrecoverable. Back it up to your password
manager, offline USB drive, and a safe.
Step 3 — Configure SOPS
Create .sops.yaml in your project root:
|
|
Or set it globally in ~/.config/sops/sops.yaml.
Step 4 — Encrypt Your .env File
|
|
Step 5 — Decrypt at Deployment
|
|
Or automate it in a deploy script:
|
|
Encrypt Entire Compose Files
For stacks with inline secrets you can’t easily extract:
|
|
Integrate with Your Backup Script
Encrypted secrets are safe to back up to the cloud. The backup target never sees plaintext — only the age-protected ciphertext:
|
|
Recovering the age Key
Store a copy of ~/.config/sops/age/keys.txt in your password
manager and on an encrypted USB drive. If the server dies, you
need both the encrypted .env files AND the age key to recover
credentials.
|
|
Tier 4 — Vaultwarden API for Dynamic Secrets
The previous tiers all rely on static secrets — values you generate once and store. For environments where you rotate credentials regularly (or have multiple users who need access), a secret vault with an API is the right approach.
Vaultwarden (self-hosted Bitwarden-compatible server) exposes an
API that can serve secrets to your deployment pipeline. Combined
with the bw CLI, you can fetch credentials at deploy time
without ever touching a file.
Step 1 — Deploy Vaultwarden
|
|
Deploy and create your admin account, then disable signups.
Step 2 — Create an API Key
In the Vaultwarden web UI:
- Go to Settings → Security → Keys
- Create a new API Key
- Save the client ID and client secret (shown once)
Step 3 — Store Secrets as Secure Notes
Create a secure note in your vault for each compose stack:
Name: authentik-production
URI: https://auth.example.com
Fields:
PG_PASS (hidden) = ...
AUTHENTIK_SECRET_KEY (hidden) = ...
AUTHENTIK_BOOTSTRAP_PASSWORD (hidden) = ...
AUTHENTIK_BOOTSTRAP_EMAIL (visible) = [email protected]
The (hidden) field type prevents the value from being shown in
the Vaultwarden web UI unless you explicitly reveal it.
Step 4 — Fetch Secrets at Deploy Time
Install the Bitwarden CLI:
|
|
Create a deployment script that fetches secrets from Vaultwarden:
|
|
Run it:
|
|
Step 5 — Secret Rotation
Secret rotation is where vault-backed secrets shine. When you need to rotate a database password:
- Log into Vaultwarden
- Edit the
PG_PASSfield in theauthentik-productionitem - Update the actual Postgres password:
docker exec postgres psql -c "ALTER USER authentik WITH PASSWORD 'newpass'" - Run the deployment script again
No files to edit. No sops re-encryption. No git commits. The
single source of truth is the vault.
Tier Comparison — Which One Should You Use
| Tier | Method | Setup Effort | Security | Rotation | Best For |
|---|---|---|---|---|---|
| 1 | .env hygiene |
5 minutes | Low | Manual | Quick experiments, single-user lab |
| 2 | Docker Secrets (file) | 10 minutes | Medium | Manual | Production stacks in a trusted LAN |
| 3 | SOPS + age | 20 minutes | High | sops re-encrypt | Backups, shared repos, CI/CD pipelines |
| 4 | Vaultwarden API | 45 minutes | Very High | API-driven | Multi-user, frequent rotation, compliance |
My recommendation for most homelabs:
- Start at Tier 1 — every stack gets a
.env.productionwith auto-generated passwords and strict.gitignore - For critical stacks (Vaultwarden itself, Authentik, database servers), go to Tier 3 — SOPS/age encrypts at rest, and the encrypted files are safe to back up anywhere
- If you have 20+ stacks or share access with others, invest in Tier 4 — the Vaultwarden API approach pays off quickly when you need to rotate credentials without editing files
Putting It All Together — Reference Architecture
Here’s what this looks like in practice across a homelab:
/opt/
├── authentik/
│ ├── docker-compose.yml # Committed to git (no secrets)
│ ├── .env.production.enc # SOPS/age encrypted (in git)
│ ├── .sops.yaml # SOPS config (in git)
│ └── deploy.sh # Decrypts + deploys
│
├── gitea/
│ ├── docker-compose.yml
│ ├── .env.production.enc
│ ├── secrets/
│ │ └── db_password.txt # Docker Secrets file (git-ignored)
│ └── .sops.yaml
│
├── postgres/
│ ├── docker-compose.yml # Uses Docker Secrets (not env vars)
│ └── secrets/
│ ├── pg_pass.txt
│ └── pg_user.txt
│
├── manage-secrets.sh # Centralized vault-fetch script
│
└── ~/.config/sops/age/
└── keys.txt # age key (BACKED UP TO VAULTWARDEN)
Authentication flow:
- Developer/automation pushes a deploy or runs
deploy.sh - If using SOPS (Tier 3): age key decrypts
.encfiles to in-memory.env, Docker Compose picks them up - If using vault (Tier 4):
bwCLI authenticates with API key, fetches secrets from Vaultwarden, writes temporary.env - Compose deploys.
shredwipes the temporary.envfile - The only persistent secrets on disk are encrypted (SOPS) or stored in a vault
Avoiding Common Mistakes
Mistake 1: The .env in the Wrong Scope
Docker Compose automatically loads .env from the project
directory. Many users create a global .env in their home folder
and wonder why it’s not picked up. Answer: Compose looks for .env
in the same directory as the compose file, not the current working
directory.
Mistake 2: Docker Secrets and Swarm Mode
File-based secrets work in standalone Compose. Mounted secrets
(the Swarm-native /run/secrets/<name>) require Swarm mode and
a different syntax. Stick with secrets: { file: ... } unless
you’re running a Swarm cluster.
Mistake 3: Committing Encrypted Files Without Verification
Just because a file is encrypted doesn’t mean it’s safe to commit.
Run git diff --name-only to verify you’re not accidentally
committing the plaintext .env alongside the encrypted .enc
version.
Mistake 4: Losing the age Key
SOPS is useless without the age private key. Store it in your password manager, on an encrypted USB drive, and print a paper backup. Yes, paper — a QR code of the key in your safe is better than losing all your credentials when the server dies.
Mistake 5: Overlooking Process Secrets
Even with perfect secrets management, secrets can leak through:
- Shell history: Commands like
PG_PASS=abc123 docker compose upend up in.bash_history. Useexport HISTCONTROL=ignorespaceand prefix sensitive commands with a space. - Docker build args: Build args end up in the image history.
Don’t use them for secrets — use
--secretwith BuildKit instead:docker build --secret id=mysecret,src=./secret.txt. - Debug logs: Applications that log environment variables at startup will log your secrets. Check Grafana/Loki logs after deployment.
The Three-Layer Defense
Nobody can predict every attack vector, but a layered approach covers the common ones:
- At rest: SOPS/age encryption on disk. Even if the server is compromised, secrets are ciphertext unless the age key is also extracted.
- In transit: Container to container traffic uses Docker internal networks or Traefik with TLS. No plaintext secrets traverse the LAN.
- In use: Docker Secrets mount files as
mode 0400(root owner). Applications read from/run/secrets/. Environment dumps anddocker inspectreveal nothing.
If any one layer fails, the other two still protect your credentials.
Summary
Secrets management for Docker Compose doesn’t require enterprise infrastructure. The tools are already in your homelab:
.envhygiene prevents the most common mistakes — hardcoded passwords, committed secrets, and shared credentials- Docker Secrets moves sensitive data from environment variables to restricted files that don’t leak
- SOPS with age encrypts secrets at rest, making them safe to back up anywhere and version-control alongside your compose files
- Vaultwarden API gives you a single source of truth for secrets with push-button rotation
Start with Tier 1 today — it takes five minutes and eliminates 90% of the risk. Add Tier 3 for your critical stacks this weekend. Move to Tier 4 when the pain of manual rotation exceeds the setup cost.
Your future self, trying to remember what the Postgres password is, will thank you.