Secrets Management for Homelabs: How to Stop Putting Passwords in Docker Compose
Learn safer secrets management for homelabs with Docker Compose secrets, BuildKit secrets, and practical steps to stop hardcoding passwords.
Author
David Okonkwo
FTC disclosure: This article contains affiliate links. If you buy through them, HomelabAddiction may earn a commission at no extra cost to you.
Key Takeaways
- If your database password is sitting in
docker-compose.yml, you already have a secret-management problem. - Docker Compose secrets are a much safer default than plain environment variables for many self-hosted apps.
- Build-time secrets and runtime secrets are different problems - and they need different tools.
.envfiles are useful for ordinary configuration, but they are not a good long-term home for sensitive credentials.- If you want to keep deployment files in Git, encrypt secret files with tools like
ageandsopsinstead of pretending a private repo is the same as secure storage.
If you have ever opened one of your old Compose files and found a database password, API token, or admin secret sitting in plain text, you are not alone. A lot of us start there because it feels simple. The problem is that simple and safe are not the same thing.
In a homelab, secrets tend to spread quietly. They end up in your shell history, Git repo, screenshots, backups, pasted snippets, and sometimes even blog drafts. It is a bit like hiding your house key under the doormat because you promise yourself it is only temporary. Temporary has a way of becoming the default.
This guide shows you a beginner-friendly path to better secret handling for Docker-based homelabs. We will start with Docker Compose secrets, cover build-time secrets with BuildKit, and finish with an optional way to encrypt secret files at rest if you keep deployment files in version control.
Along the way, I will keep the focus on one question: what is the safest improvement you can make today without turning your homelab into a full-time job?
Why this matters before we touch any commands
A secret is any piece of sensitive data that can unlock access somewhere else. In a homelab, that usually means:
- database passwords
- API keys
- DNS provider tokens
- SMTP credentials
- backup repository credentials
- tunnel or VPN auth tokens
- private keys and passphrases
Why does this matter so much? Because one leaked secret usually becomes two problems at once. First, the account behind that secret is exposed. Second, you lose trust in every copy of that secret you have ever made.
That is why I recommend thinking about secrets in four buckets:
- Public config - ports, hostnames, timezone, image names
- Sensitive runtime secrets - app passwords, API keys, database credentials used when containers run
- Sensitive build secrets - tokens needed only while building an image
- Encrypted secret files at rest - secret material you want to store safely in Git or a synced folder
If you remember nothing else from this article, remember this: do not solve all four buckets with one blunt tool.
What to use instead of plain environment variables
Environment variables are popular because they are easy. That does not make them the best place for every secret.
Why this matters: environment variables are often visible to more processes than you expect. They can show up in debug output, crash logs, shell history, and copy-pasted support snippets. They are not automatically catastrophic, but they are loose. File-mounted secrets are tighter.
For a lot of self-hosted Docker apps, the safest practical upgrade is this:
- keep ordinary settings in
.env - move sensitive values into files
- mount those files as Docker Compose secrets
- use images that support
*_FILEenvironment variables when possible
That pattern is easier to audit because you can look at your Compose file and immediately see which values are sensitive.
Step 1: Create a small secrets directory
Why this matters: putting secrets in their own directory gives you a clean mental boundary. Think of it like moving spare keys from random drawers into a small lockbox.
Create a project folder and a secrets subdirectory:
mkdir -p ~/homelab/secrets-demo/secrets
cd ~/homelab/secrets-demo
Now create two example secret files:
printf 'super-db-password-change-me\n' > secrets/postgres_password.txt
printf 'super-app-password-change-me\n' > secrets/app_db_password.txt
chmod 600 secrets/*.txt
That chmod 600 step is worth keeping. It does not magically solve everything, but it reduces accidental exposure on a multi-user system.
If you use Git in this project, add the secrets directory to .gitignore immediately:
cat <<'EOF' >> .gitignore
secrets/
.env
EOF
Step 2: Build a Compose file that uses secrets correctly
Why this matters: the goal is not just to hide passwords. The goal is to pass them into containers in a way that is explicit, limited, and easier to audit.
Here is a simple example with PostgreSQL and an app container that reads secrets from files:
services:
db:
image: postgres:16
container_name: demo-postgres
restart: unless-stopped
environment:
POSTGRES_DB: appdb
POSTGRES_USER: appuser
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- postgres_password
app:
image: ghcr.io/example/demo-app:latest
container_name: demo-app
restart: unless-stopped
depends_on:
- db
environment:
APP_DB_HOST: db
APP_DB_NAME: appdb
APP_DB_USER: appuser
APP_DB_PASSWORD_FILE: /run/secrets/app_db_password
secrets:
- app_db_password
ports:
- "8080:8080"
secrets:
postgres_password:
file: ./secrets/postgres_password.txt
app_db_password:
file: ./secrets/app_db_password.txt
volumes:
pgdata:
Save that as compose.yaml.
A few important details are doing real work here:
- the top-level
secrets:section defines where the secret comes from - each service gets only the secrets it actually needs
- the secret appears in the container as a file under
/run/secrets/... - the image reads the file path through
*_FILEvariables instead of expecting the raw password directly
This per-service access is one of the best parts of the model. Your app container does not automatically get your database root password just because both services live in the same Compose stack.
Step 3: Start the stack and verify the secret mount
Why this matters: secret management is one of those topics where people often trust the YAML and skip validation. I would rather verify once now than debug a mystery later.
Start the stack:
docker compose up -d
Check container status:
docker compose ps
Now verify that the secret exists inside the database container:
docker exec demo-postgres ls -l /run/secrets
docker exec demo-postgres sh -c 'wc -c /run/secrets/postgres_password'
You should see the mounted file listed under /run/secrets. I prefer wc -c in examples like this because it confirms the file exists without printing the secret itself.
If you want to confirm the app image supports _FILE variables, check its documentation first. This is one of the biggest practical gotchas. Docker Compose secrets are good, but the container image still has to know how to read them.
The official Docker documentation for Compose secrets is here:
Step 4: Keep non-sensitive configuration in .env, not secrets
Why this matters: if everything is treated like a secret, nothing is organized. You want clear separation between ordinary config and sensitive values.
A reasonable .env file for the same stack might look like this:
TZ=UTC
APP_PORT=8080
POSTGRES_DB=appdb
POSTGRES_USER=appuser
That is fine because none of those values are dangerous by themselves.
What should not go in .env long term?
- database passwords
- API tokens
- tunnel credentials
- SMTP passwords
- cloud backup credentials
Some people ask, "But my .env file is not public, so what is the problem?" The problem is that private is not the same as controlled. A private repo can still be copied. A synced folder can still be backed up to places you forgot about. A .env file can still get pasted into a Discord screenshot at 1 AM.
If you already self-host authentication or password tools, this is also a good time to tighten adjacent habits. These guides pair well with today's topic:
Step 5: Handle build-time secrets with BuildKit, not ARG
Why this matters: build-time secrets are a separate category. A token used while building an image should not be passed the same way as a password used while running a container.
This is where many otherwise careful setups still leak secrets. Someone needs to pull a private dependency, so they do this:
ARG NPM_TOKEN
RUN npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN
Please do not do that. Build arguments can leak into image history, and now your secret is stuck to the build process like glitter in carpet.
Use BuildKit secrets instead.
Example Dockerfile:
# syntax=docker/dockerfile:1.7
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN="$(cat /run/secrets/npm_token)" && \
npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \
npm ci
COPY . .
CMD ["npm", "start"]
Build it like this:
printf 'your-npm-token-here\n' > /tmp/npm_token.txt
docker build --secret id=npm_token,src=/tmp/npm_token.txt -t demo-buildkit-app .
rm -f /tmp/npm_token.txt
That secret is available only for the build step that needs it. It is not supposed to live inside the final image.
Official reference:
This distinction is important enough to repeat plainly:
- Compose secrets are for containers at runtime
- BuildKit secrets are for image builds
Trying to force one tool to solve both problems usually creates the next mistake.
Step 6: Encrypt secret files at rest if you keep deployment files in Git
Why this matters: a lot of homelab users keep infrastructure in Git because it is convenient, and honestly, it should be. The problem starts when Git becomes your secret store by accident.
If you want to keep the deployment repo but avoid plaintext secret files, age and sops are a solid practical path.
Generate an age key pair:
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
Show your public recipient key:
grep '^# public key:' ~/.config/sops/age/keys.txt
Now encrypt a secret file with SOPS:
sops --encrypt \
--age YOUR_AGE_PUBLIC_KEY \
secrets/postgres_password.txt > secrets/postgres_password.txt.enc
Decrypt it when needed:
sops --decrypt secrets/postgres_password.txt.enc > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
If you go this route, the flow becomes:
- keep encrypted secret files in Git
- decrypt locally before deployment
- mount the decrypted file as a Compose secret
- securely remove decrypted copies when done
This is not the only path forward, but it is a very understandable one for self-hosters.
Official references:
Step 7: Rotate a secret without turning maintenance into a fire drill
Why this matters: secret management is not finished when the first deploy works. The real test is whether you can change a password later without dreading the process.
A simple rotation workflow for file-backed secrets looks like this:
- generate a new credential in the app or database
- update the matching secret file on the host
- restart only the service that needs the new file
- verify the app reconnects cleanly
- remove any old copies or notes you created during the change
For a PostgreSQL-style example, the host-side secret update looks like this:
printf 'new-super-db-password\n' > secrets/postgres_password.txt
chmod 600 secrets/postgres_password.txt
docker compose up -d db
docker compose logs --tail=50 db
If the dependent application also needs the new credential, update that app-side secret file and restart the app service too:
printf 'new-super-db-password\n' > secrets/app_db_password.txt
chmod 600 secrets/app_db_password.txt
docker compose up -d app
docker compose logs --tail=50 app
The point is not that every image behaves identically. The point is to build a habit where secret rotation is a short maintenance task, not a weekend mystery. If you run public-facing services behind a reverse proxy or expose admin panels remotely, being able to rotate credentials quickly is one of the most useful operational skills you can have.
A simple decision framework you can actually use
If you are unsure where a value belongs, use this quick test:
| Type of value | Example | Best starting place |
|---|---|---|
| Ordinary config | timezone, port, hostname | .env or Compose environment |
| Runtime secret | DB password, API key | Compose secret file mounted to /run/secrets |
| Build secret | npm token, private repo credential | docker build --secret with BuildKit |
| Long-term stored secret file | token you want to keep with infra code | encrypt with sops + age |
That is the whole mental model. You do not need an enterprise vault to stop making beginner mistakes. You just need the right bucket.
Recommended gear for safer secret-handling habits
These are not required, but they fit naturally into a security-focused homelab workflow:
- YubiKey 5 NFC for protecting your admin accounts and password manager with strong MFA - https://www.amazon.com/s?k=yubikey+5+nfc&tag=homelabaddiction-20
- Samsung T7 Shield SSD for keeping encrypted exports, recovery docs, and secret backups off your main server - https://www.amazon.com/s?k=samsung+t7+shield+ssd&tag=homelabaddiction-20
- Fanless N100 mini PC if you want a small dedicated Docker host instead of piling every service onto one old box - https://www.amazon.com/s?k=fanless+n100+mini+pc+2.5gbe&tag=homelabaddiction-20
I especially like the first two pairings here because secret hygiene is not only about Docker. Recovery plans matter too.
Common mistakes
1. Committing .env because the repo is private
Private repos reduce exposure. They do not erase it. Secrets spread through clones, backups, forks, screenshots, chat snippets, and old laptops.
2. Assuming every image supports *_FILE
Many do. Not all do. Always check the image documentation before you build your workflow around file-based secrets.
3. Using BuildKit for runtime problems
BuildKit is excellent for build-time credentials. It does not replace runtime secret handling in Compose.
4. Printing secrets while "just testing"
Try to verify secret files by path, ownership, or length instead of dumping their contents in your shell.
5. Leaving decrypted secret files behind
If you decrypt encrypted files during deployment, make cleanup part of the habit. Otherwise you did the hard part and kept the weak ending.
6. Treating reverse-proxy or TLS setup as unrelated
It is all connected. If you self-host public apps, secret handling belongs in the same conversation as your reverse proxy, TLS, and remote access setup. These guides help tie that together:
- How to Set Up Nginx Proxy Manager on Docker
- How to Set Up HTTPS for Your Homelab
- How to Set Up WireGuard on Docker
- SSH Hardening Guide
FAQ
Are Docker Compose secrets perfect security?
No. They are a better default than stuffing sensitive values directly into environment variables or YAML files, but they are still one layer in a broader security model. Host access, backups, logging, and account hygiene still matter.
When is a plain .env file acceptable?
For non-sensitive configuration, .env is fine. Use it for things like ports, hostnames, or timezone. Use secret files for passwords, API keys, and tokens.
What if my image does not support *_FILE variables?
You can sometimes wrap the entrypoint with a small shell script that reads the secret file and exports the value before starting the app. But check whether the image already supports a cleaner native pattern first.
Do I need Swarm to use Compose secrets?
No. Modern Docker Compose supports file-backed secrets for Compose projects without requiring you to move your entire homelab into Swarm mode.
Should I jump straight to Vault, 1Password Secrets Automation, or another dedicated secret manager?
Only if your needs justify it. For many homelabs, a clean split between .env, Compose secrets, BuildKit secrets, and encrypted secret files gets you a long way without adding operational drag.
What to learn next
If this guide helped, the next best topics to learn are:
- secret rotation - how to change credentials without breaking every container
- MFA for self-hosted admin accounts - especially password managers and identity providers
- backup encryption and recovery docs - because losing your secrets can be just as bad as leaking them
- remote-access hardening - VPN, SSH, and reverse-proxy security all touch the same risk surface
A good homelab does not need enterprise ceremony. It does need good habits. Moving secrets out of Compose files is one of those habits that pays off immediately. It is not flashy, and nobody will compliment you for it in a screenshot. But future you will be very glad you did it.
