The real risk: your secrets travel inside your images
In 2024, GitGuardian detected over 12.8 million exposed secrets in public GitHub repositories — a 28% increase year-over-year according to the State of Secrets Sprawl 2025. Docker Hub images are one of the most underestimated vectors.
When you write ARG API_KEY in a Dockerfile or pass -e DB_PASSWORD=hunter2 at container startup, that secret doesn't stay confined to runtime. It can end up:
- in the image layers (inspectable with docker history --no-trunc);
- in the image metadata exported to Docker Hub (docker inspect);
- in your .env file accidentally committed during a hurried git add ..
Automated scanners (Trivy, Grype, GitGuardian) continuously crawl Docker Hub. A public repository with a plaintext secret is indexed within minutes. The exposure window is nearly zero.
5 configuration mistakes that expose your secrets
- Plaintext env vars in
docker-compose.yml—environment: DB_PASSWORD: hunter2is readable by anyone who accesses the file or the image. - Committed
.envfile — miss.gitignoreonce, and the secret is in git history forever (even aftergit rm, accessible viagit log). ARGpassed at build then copied into the image —ARGs are baked into layer metadata and readable viadocker history.- Secrets in logs — an application that logs its environment variables at startup (Spring Boot, Rails in debug mode, some Node servers) prints credentials in
docker logs. - Bind-mount volumes on
/rootor the project directory — a.envfile mounted from the host remains accessible to any process in the container with root privileges.
Prerequisites
To follow this article, you need:
- A Linux VPS (Debian 12 or Ubuntu 22.04+) with root access.
- Docker Engine ≥ 24 and Docker Compose ≥ 2.24 (check with docker compose version).
- For SOPS: age installed (age package on Debian/Ubuntu, or binary from github.com/FiloSottile/age).
- For .env.vault: Node.js ≥ 18 and the dotenvx CLI (npm install -g @dotenvx/dotenvx).
No Swarm cluster is required for any of the methods presented here.
Method 1 — Docker Secrets in non-Swarm Compose (step by step)
Check the Compose version
Docker Secrets work without Swarm since Docker Compose v2.24.0 (released January 11, 2024). Verify:
docker compose version # Docker Compose version v2.27.1If you're below v2.24, update Compose before continuing (
apt-get install docker-compose-pluginon Debian/Ubuntu).Create the secret files
Docker Compose secrets in non-Swarm mode are files on the host, mounted as tmpfs inside the container. Create them outside the project directory:
mkdir -p /etc/myapp/secrets echo -n 'strong-db-password' > /etc/myapp/secrets/db_password echo -n 'stripe-api-key-xxxxx' > /etc/myapp/secrets/stripe_key chmod 600 /etc/myapp/secrets/* chown root:root /etc/myapp/secrets/*The
-nflag ofechoavoids a trailing newline — some applications read the entire file including the newline, which invalidates the key.Declare secrets in docker-compose.yml
services: app: image: myapp:latest secrets: - db_password - stripe_key environment: # indicate the PATH, not the value DB_PASSWORD_FILE: /run/secrets/db_password STRIPE_KEY_FILE: /run/secrets/stripe_key secrets: db_password: file: /etc/myapp/secrets/db_password stripe_key: file: /etc/myapp/secrets/stripe_keyNote the use of the
*_FILEconvention: your application must read theDB_PASSWORD_FILEvariable, open the indicated file and read its content. Official PostgreSQL, MySQL, Redis images and most Bitnami images natively support this convention — check your image's documentation.Verify the mount inside the container
After
docker compose up -d, inspect the mount:docker compose exec app ls -la /run/secrets/ # -r-------- 1 root root 20 Sep 20 08:12 db_password # -r-------- 1 root root 28 Sep 20 08:12 stripe_key docker inspect myapp_app_1 | grep -A5 Mounts # "Type": "tmpfs", # "Destination": "/run/secrets",The mount type is tmpfs: the content lives in RAM, never written to the container's disk. It disappears when the container stops.
What Docker Secrets does NOT do — understand before continuing
Docker Secrets is not a hermetic vault. What it does not protect against:
- The source file (
/etc/myapp/secrets/db_password) remains on the host disk in plaintext — root on the VM always has access.
- Any process in the container (PID 1 or a subprocess launched by the app) can read/run/secrets/*.
- An env variable derived from the secret (DB_PASSWORD=$(cat /run/secrets/db_password)in an entrypoint) puts the secret back into the env, visible viadocker inspect.Docker Secrets protects against leaks in image layers and in
docker-compose.yml. It does not protect against a compromised process inside the container.
Docker Secrets vs .env.vault vs SOPS — which method for which context
Scroll the table
| Criterion | Docker Secrets (Compose) | .env.vault (dotenvx) | SOPS + age |
|---|---|---|---|
| Swarm required | No (since Compose v2.24) | No | No |
| Secret stored in plaintext on host | Yes (source file) | No (encrypted in repo) | No (encrypted in repo) |
| Remote KMS required | No | No (local symmetric key possible) | No (age works offline) |
| Rotation without redeployment | No (restart needed) | No (rebuild .env) | No (rebuild .env) |
| CI/CD: injection into the pipeline | Complex (files to provision) | Simple (`DOTENV_PRIVATE_KEY` variable) | Medium (age key as CI secret) |
| Learning curve | Low (native Compose) | Low (dotenvx CLI) | Medium (YAML syntax + age/GPG keys) |
Supplementary configuration: rotation and encryption at rest
Docker Compose secret rotation. Non-Swarm Docker Compose does not support hot rotation (unlike Swarm which can update a secret without stopping the service). To change a secret:
# 1. Write the new value
echo -n 'new-password' > /etc/myapp/secrets/db_password
# 2. Restart the affected service
docker compose restart appEncrypting source files with age. If you want to encrypt files on the host (against snapshot theft or a compromised backup), SOPS + age lets you store encrypted files and decrypt them at startup:
# Generate an age key
age-keygen -o /root/.config/sops/age/keys.txt
# Encrypt the secret file
sops --encrypt --age $(age-keygen -y /root/.config/sops/age/keys.txt) \
/etc/myapp/secrets/db_password > /etc/myapp/secrets/db_password.enc
# In your startup script, decrypt before docker compose up
sops --decrypt /etc/myapp/secrets/db_password.enc > /etc/myapp/secrets/db_passwordAccess auditing. Enable Docker logs with journald (--log-driver=journald in /etc/docker/daemon.json) to keep a record of who launched which containers and when.
What Docker Secrets does not protect
Docker Secrets mounts the secret as tmpfs in /run/secrets/: it's a safety net against leaks in images and Compose files, not against a compromised process inside the container. Any process running in the container — including a shell obtained via RCE — can read /run/secrets/*. And root on the host always has access to the source file.
If your threat model includes a compromised container, the right answer is an external secret manager (HashiCorp Vault, AWS Secrets Manager, self-hosted Infisical) that delivers secrets via API with authentication, without ever writing them to the container disk.
Troubleshooting — common errors
unknown shorthand flag: 's' in -s during docker compose up
You are using the old docker-compose command (v1, Python). Switch to docker compose (v2, Go plugin) with apt-get install docker-compose-plugin.
secrets are only supported when deploying to a swarm
Your Docker Compose version is below v2.24. Check with docker compose version and update.
Container starts but /run/secrets/db_password is empty
Verify that the source file exists and is not empty: cat /etc/myapp/secrets/db_password | wc -c. An empty file creates an empty tmpfs mount, without error.
permission denied when reading /run/secrets/
Files are mounted with the source file's permissions. If your process runs as a non-root user in the container, adjust the host file permissions: chmod 640 /etc/myapp/secrets/db_password and check the process GID.
docker inspect still shows the env var in plaintext
You declared the secret but also passed the value in environment:. Remove the direct-value entry and only use the *_FILE convention in environment:.
Your secrets under control — and what's next
Docker Secrets in non-Swarm Compose mode eliminates the main cause of leaks: plaintext credentials in configuration files and image layers. The method fits in five steps, works without external infrastructure and integrates into any existing workflow.
To go further:
- Production checklist: the 10 essential points for a production-ready docker-compose.yml.
- First steps with Docker on VPS: getting started with Docker on VPS if you are building your first environment.
- OS hardening: Linux hardening checklist to secure the host layer on which Docker runs.