Is your docker-compose.yml safe to put in production?
Paste the file. This reads it the way an attacker and a bad Tuesday would — what is exposed, what runs as root on the host, what loses your data, and what never comes back after a reboot. No signup, no email required.
A Compose file that works is not the same as one that is safe
Almost every problem this finds is invisible while everything is fine. The stack comes up,
the site loads, and nothing in the logs suggests otherwise. Then the server reboots, or a
scanner finds an open port, or somebody runs docker compose down on the wrong
machine — and the file that worked yesterday turns out to have been one bad afternoon
away from an outage the whole time.
Most of these settings arrived for a good reason too. A port was published to connect a database client. A capability was added to make one error message go away. A password went in temporarily. Development shortcuts are not mistakes; they only become mistakes when they quietly travel to production, which is where they usually end up.
Every test, explained
Twenty-four checks across seven groups, plus what is deliberately left out and why.
What can reach the host it runs on
This is the group that matters most, and it is the one people are most surprised by. A container is not a virtual machine. It is a process on your server with a different view of the filesystem, and a handful of settings in a Compose file hand that view back.
The clearest example is mounting /var/run/docker.sock. It gets copy-and-pasted into monitoring agents, reverse proxies and CI runners constantly. But anything that can talk to that socket can start a new container with your whole disk mounted inside it. There is no permission model in front of it. Access to the socket is root on the host, full stop.
The tool also looks for privileged: true, shared host namespaces (pid, ipc, network_mode), added kernel capabilities like SYS_ADMIN, and security profiles turned off with seccomp:unconfined. Each one is usually added to make a single thing work, and each one grants far more than that thing needed.
What is published to the network
Services in a Compose file already reach each other by name. Your application connects to db:5432 whether or not that port is published, because they share a network. So a ports: entry on a database is almost never doing what someone thinks it is doing.
What it does instead is bind the port to 0.0.0.0, which includes the public address of the server. Internet-wide scanners find open database ports in hours, not weeks, and the default credentials for every common image are in the first page of any wordlist.
One detail matters here, and the tool gets it right rather than being noisy: 127.0.0.1:5432:5432 is not a finding. Binding to loopback is the correct way to reach a database from the host and nowhere else. Only a mapping with no address in front of it is flagged.
Credentials written into the file
A password in a Compose file is a password in version control. It is in every clone of the repository, in the shell history of whoever pasted it, in the CI logs if the file is ever printed, and in docker inspect for anyone with access to the host. Rotating it means finding all of those.
The tool reads variable names rather than values. Anything shaped like POSTGRES_PASSWORD, SECRET_KEY or API_TOKEN is checked, and a literal value is flagged. A reference such as ${DB_PASSWORD} is not, because that is the fix rather than the problem.
It also reads command: and entrypoint:, where a credential is worse still. Command-line arguments are visible in ps to every user on the machine, without any container access at all.
The value itself never appears in the report. You get the line number and the variable name. Printing the password back onto a web page would be a strange thing for a security tool to do.
Whether your data survives
A database with no named volume writes into the container's own writable layer. That works perfectly, right up until the container is replaced.
And containers get replaced for completely ordinary reasons. docker compose down removes them. Changing an environment variable recreates them. Pulling a newer image recreates them. None of those feel like destructive operations, which is exactly why this one hurts.
The tool also flags relative bind mounts, because ./data resolves against whatever directory you happened to run the command from, and creates an empty directory rather than failing when it is wrong. And it flags a bind mount placed over a path the image fills in itself, which is the usual reason a database container starts, logs a permissions error and exits.
Whether it comes back on its own
Three settings decide what happens on a bad day, and all three are one line.
restart: unless-stopped means the machine can reboot after a kernel update and your site comes back without anyone logging in. Without it, the container stays exited until somebody notices.
A healthcheck is the difference between "the process is running" and "the application is working". A container stuck on a connection it will never get is reported as up, and every monitor reading container state believes it.
Then depends_on. In its short form it waits for the container to start, not for the service to be ready. So your application starts against a database that is not accepting connections yet, fails, and stays failed. That is the single most common reason a stack works on the second up and not the first.
Whether one container can take the machine down
Two limits, and they fail in completely different ways.
With no memory limit, a container can allocate everything the host has. When it does, the kernel picks a process to kill — and it frequently picks something else. The visible symptom is your database dying, or SSH becoming unreachable, which is why this one takes so long to diagnose.
The log driver is the quieter one. The default json-file driver writes to a file that grows forever. Nothing rotates it. On a small VPS a chatty container fills the disk over a few months, and a full disk stops the database writing, stops mail, and stops the logs that would have explained it. Two options close it permanently.
A missing CPU limit is reported as information rather than a warning, because the outcome is genuinely different. Everything gets slower. Nothing gets killed.
Whether you can rebuild what is running now
image: postgres:latest looks like a version. It is not. It is a pointer that moves, so two servers running the same file can be running different software, and a container recreated for an unrelated reason can come back on a new major version of your database.
An image with no tag at all is the same thing with less warning, because nothing in the file says latest anywhere. Nobody reading it sees a version that can move.
The practical cost is rollback. If the current version breaks something, there is no previous version named anywhere to go back to.
What this deliberately does not flag
A check that is wrong more often than it is right makes the whole report less believable, so a few obvious candidates were left out on purpose.
It does not warn about a missing user: directive. That would fire on nearly every correct file, including ones using images that already drop privileges internally.
It does not judge published application ports. Whether 3000:3000 is a problem depends entirely on what sits in front of it, and a Compose file cannot tell you that. Only well-known database and cache ports are flagged.
And it cannot see inside your images. If your image ships its own HEALTHCHECK in the Dockerfile, the healthcheck warning does not apply to you — the file has no way to know, so the finding says so rather than pretending certainty.
What usually goes wrong, and how it gets fixed
The same handful of causes turn up again and again. Here they are, with the change that fixes each one.
Something needs the Docker socket
Usually it is a reverse proxy that discovers containers automatically, a monitoring agent, or a deployment tool. The requirement is real. Mounting the socket read-write into a container that also faces the internet is the part that is not.
Two things help. Mount it read-only, which blocks the obvious writes but is not a boundary on its own. Better, put a socket proxy in front of it so the container can list containers and nothing else:
# Instead of this
services:
proxy:
image: traefik:v3.1
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Do this — the proxy sees a filtered API, not the socket
services:
dockersocket:
image: tecnativa/docker-socket-proxy:0.2 # pin to a current tag
environment:
CONTAINERS: 1 # allow, read-only
POST: 0 # refuse every write
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [internal]
proxy:
image: traefik:v3.1
command:
- --providers.docker.endpoint=tcp://dockersocket:2375
networks: [internal, web]
The application container never touches the socket. If it is compromised, the attacker reaches an API that refuses every write instead of a socket that grants everything.
The database is published to the internet
Nearly always this was added to connect a database client from a laptop during setup, and then never removed. The fix is one line, and it does not break the application, because the application was never using that port.
# Reachable from anywhere on the internet
services:
db:
image: postgres:16
ports:
- "5432:5432"
# Reachable from the host only — for a local client or an SSH tunnel
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432"
# Best: no ports at all. Your app already reaches it as db:5432
services:
db:
image: postgres:16
To reach it from your machine afterwards, tunnel over SSH: ssh -L 5432:localhost:5432 you@server. Then point your client at localhost. No port needs to be open to anyone.
There are passwords in the file
Compose reads a .env file sitting beside it automatically. So moving a credential out is genuinely a two-minute job, and the file keeps working exactly as before.
# docker-compose.yml — safe to commit
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set this in .env}
# .env — beside the compose file, never committed
POSTGRES_PASSWORD=the-real-one
# .gitignore
.env
The :? is worth using. It makes Compose refuse to start with a clear message if the variable is missing, instead of quietly starting the database with an empty password. And if the old value was ever committed, treat it as public and rotate it — removing it from the current file does not remove it from the history.
The database has nowhere to keep its data
This one has a trap in the fix, so read the second paragraph before you change anything.
Applying this recreates the container, which is the exact event that destroys the data. Back up first, from the running container, then make the change, then restore.
# 1. Back up while the old container is still running
docker compose exec db pg_dumpall -U postgres > backup.sql
# 2. Add the volume
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
# 3. Recreate, then restore
docker compose up -d
cat backup.sql | docker compose exec -T db psql -U postgres
Use a named volume rather than a bind mount to a host directory. Docker owns the permissions, which avoids the "database starts, logs a permissions error, exits" loop that bind mounts cause on database data directories.
The logs will fill the disk
Nothing rotates container logs by default. This is slow enough that it never gets attention, and then it takes the server down on a weekend.
Set it per service, or once for the whole daemon:
# Per service
services:
web:
image: nginx:1.27
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Or once, for everything — /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
The daemon setting applies to containers created afterwards, so recreate existing ones for it to take effect. Check what is already there with du -sh /var/lib/docker/containers/* before you assume you have time.
It only works on the second "docker compose up"
Classic symptom, and it is nearly always depends_on in its short form. The application starts the moment the database container exists, which is several seconds before the database is accepting connections.
The long form fixes it properly, but it needs a healthcheck on the dependency to have something to wait for:
services:
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
start_period: 30s
app:
image: myapp:1.4.2
depends_on:
db:
condition: service_healthy
restart: unless-stopped
Add restart: unless-stopped as well. Waiting for readiness handles the ordinary case; a restart policy handles the case where the database takes longer than the retries allow.
Containers that behave the same on your laptop and in production.
I build and review Docker deployments for small teams — the hardening, the restart behaviour, the backups, and the monitoring that tells you before a customer does. Send me your compose file and I will tell you what I would change.
Prefer to talk? Book a free call ↗ · Or hire me on Upwork ↗ · Typical reply within one business day.