DockerSecuritySelf-Hosting

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.

AU

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.
  • .env files 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 age and sops instead 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:

  1. Public config - ports, hostnames, timezone, image names
  2. Sensitive runtime secrets - app passwords, API keys, database credentials used when containers run
  3. Sensitive build secrets - tokens needed only while building an image
  4. 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 *_FILE environment 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 *_FILE variables 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:

  1. keep encrypted secret files in Git
  2. decrypt locally before deployment
  3. mount the decrypted file as a Compose secret
  4. 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:

  1. generate a new credential in the app or database
  2. update the matching secret file on the host
  3. restart only the service that needs the new file
  4. verify the app reconnects cleanly
  5. 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:

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:

  1. secret rotation - how to change credentials without breaking every container
  2. MFA for self-hosted admin accounts - especially password managers and identity providers
  3. backup encryption and recovery docs - because losing your secrets can be just as bad as leaking them
  4. 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.