About Expertise Work Projects
Hosted Monitoring & Dashboards Self-Hosted Observability Stack Bulk Document Data Extraction
Free Tools
Website Health Check Email Domain Health Check DNS Health Check SSL Certificate Checker Redirect Chain Checker Robots.txt Checker XML Sitemap Validator Docker Compose Checker WordPress Security Check Downtime Cost Calculator
Blog Certifications Hire Me

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.

Nothing you paste is stored, logged or run. It is read in memory and thrown away.

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.

Questions

Do you store the file I paste?
No. It is read in memory, checked, and thrown away when the response finishes. There is no cache, no database and no file written to disk, which is a deliberate difference from the other scanners on this site — those cache their results for fifteen or thirty minutes because they make real requests to real servers, and this one makes none at all. The file is also sent as a form body rather than in the address bar, so it does not end up in the web server access log the way a query string does. There is no account, no email field and no signup. Checking is limited to thirty files an hour from one visitor, which is there to stop the page being scripted rather than to ration anything.
Should I remove the passwords before pasting?
You do not have to, and the check is more useful if you do not, because spotting credentials in the file is one of the things it looks for. Nothing you paste is stored and no value is ever printed back — the report gives you the line number and the variable name only. That said, if the file belongs to a client rather than to you, replacing the values with placeholders before you paste is a sensible habit, and every other check still works.
Is Docker Compose even suitable for production?
For a single server, yes, and a great many businesses run perfectly well on exactly that. What it does not give you is any answer to the machine failing, because everything is on that one machine. So the honest boundary is this: if you can tolerate the time it takes to rebuild a server from a backup, Compose is a reasonable and much simpler choice than an orchestrator. If you cannot, the problem is not Compose and moving to Kubernetes is not the smallest fix available to you.
Does this check my images for vulnerabilities?
No, and that is a different job that needs a different tool. This reads your Compose file and nothing else, so it can tell you that an image is pinned to latest but not what is inside it. For the contents, scan the image itself with something like Trivy, Grype or Docker Scout. The two checks complement each other: a perfectly patched image still gives away the host if you mount the Docker socket into it.
It says I have no healthcheck, but my image has one built in.
Then you can ignore that finding, and the wording says so. A Compose file only shows what the file declares. A HEALTHCHECK baked into the image by its Dockerfile is invisible from here, and there is no way for a static check to find out. Reporting it as inconclusive on every service would have been worse, because most images do not ship one and the finding is right most of the time.
Why does it not warn me about running containers as root?
Because that check fires on almost every correct file, and a report full of warnings nobody should act on is a report nobody reads. Plenty of official images already drop privileges internally, so a missing user: line frequently means nothing at all. Running as a non-root user is still worth doing — it is just not something a Compose file alone can tell you that you have got wrong.
Why is my published application port not flagged?
Because whether that is a problem depends on what sits in front of it, and the file cannot say. Port 3000 open to the world is a mistake behind a reverse proxy and completely normal without one. So only well-known database and cache ports are flagged, where the answer is the same either way. Note that a port bound to 127.0.0.1 is never flagged at all, because that is the correct way to reach a service from the host and nowhere else.
What happened to the version: line at the top of my file?
It is obsolete and this tool ignores it. Compose stopped using it years ago and the current version prints a warning if it is there. Removing it changes nothing about how your stack runs, so it is tidying rather than a fix, and it is not reported as a finding — if a check has no real consequence behind it, it does not belong in a report you are meant to act on.