Docker published the port. Your firewall never saw it.
Your Compose file publishes a database port, and your firewall denies everything you did not allow. On a ufw host those two facts do not combine the way you think, and Docker documents why. So I connect to every port your project publishes, from outside your network, and tell you which ones answered.
You probably got here from my own free checker. It was right.
So this page opens where that report ends, rather than reading your Compose file back to you.
The Docker Compose production checker on this site is free and needs no account and no email address. It finds all seven of the faults on this page, names the line, gives you the line number, and lists what passed as well as what failed. None of that is discovered here. It is already yours.
- What the free checker already gives you
- The verdict, the offending line, its line number, the evidence behind it and the checks that passed. Nothing on this page unlocks any of it, and nothing here is held back to make you pay for it.
- And the obvious next thing is free too
- Paste that file into a language model and ask for a hardened version. You will get one, in seconds, and most of it will be correct. I am not going to sell you a document you can generate while you read this sentence.
So the honest part first. If your file came back clean, or the only findings were the easy ones and you can already answer the question below, you do not need this page. Fix the lines the checker named and get on with your week.
The question is the one no file answers: that published port — does it actually answer, right now, to somebody who is not you?
Every free tool here reads a file. The fault is not in the file.
A Compose file records what you asked Docker to publish. Whether that port answers depends on the bind address, the host firewall, the packet filtering order and anything in front of the machine — and none of those live in the file.
This is where most people are confident, and it is where Docker itself says not to be. If you run ufw, the firewall you configured is not doing what you think for published container ports. That is not my claim. It is on Docker’s own packet filtering and firewalls page:
-
docs.docker.com — Docker and ufw
Docker and ufw use firewall rules in ways that make them incompatible with each other.
-
docs.docker.com — Docker and ufw
When you publish a container’s ports using Docker, traffic to and from that container gets diverted before it goes through the ufw firewall settings.
-
docs.docker.com — Docker and ufw
Packets are routed before the firewall rules can be applied, effectively ignoring your firewall configuration.
Read that narrowly, because it is narrow. It is about ufw, and it is about the order
packets are filtered in. Docker is not insecure by default, and I am not going to tell
you it is. On a firewalld host the same page describes something else entirely — Docker
creates a zone called docker with target ACCEPT and a forwarding
policy to go with it — and it claims no bypass there. So firewalld gets checked
rather than assumed, and if you sit behind a cloud security group the whole question
may already be answered. Finding that out is a short engagement and a good outcome.
If you have had a server hardened — by me on the VPS hardening page or by anyone else — this is exactly the gap that survives it. That work configures the firewall. This work finds where Docker went around it.
The second half: a credential that has already left
The other thing a file cannot tell you is where a leaked credential went. If a password was
passed to a build as an ARG, editing the Compose file changes nothing, because the
value is no longer only in the file. Docker says so in the
Dockerfile
reference:
-
docs.docker.com — Dockerfile reference, ARG
It isn’t recommended to use build arguments for passing secrets such as user credentials, API tokens, etc. Build arguments are visible in the
docker historycommand and inmaxmode provenance attestations
Be precise about the mechanism, because the same page is precise about it. An ARG
is not embedded in the image the way an ENV is — Docker says that too.
It is in the image’s history, and the history travels with the
image. Every registry copy, every pull, every build cache. So the question is never
“is it still in the file”. It is: who has pulled that image since, and what does
the credential still open?
What the free competition does, and does not, cover
Use all of these. They are good, they cost nothing, and none of them connects to your ports from outside.
- The Compose production checker — mine, free
- All seven faults, from a paste, with the line and the line number. Reads the file.
- KICS — free, open source
-
Explicitly supports Compose —
KICS supports scanning DockerCompose files with
— as one of 22 platforms. Reads the file..yamlextension - Trivy — free, open source
- Excellent, and worth running. Its supported configuration list is Kubernetes, Docker, Terraform, Terraform Plan, CloudFormation, Azure ARM Template, Helm, YAML and JSON — no Docker Compose entry. It will still read the file as generic YAML; what it does not have is Compose-aware checks. Reads the file.
- Docker Bench for Security — Docker’s own, Apache-2.0
- Audits the host, the daemon, the running containers and the images against CIS Docker Benchmark v1.6.0. It reads no Compose file at all, and it runs on the host rather than from outside it.
- Docker Scout — free tier, then paid
- Image vulnerabilities and SBOMs, which this page does not do and does not try to. Docker Personal includes it for one Scout-enabled repository; the paid plans are billed per user per month. Reads the image.
Every one of them reads a file, or reads an image. None of them opens a connection to your published port from an address that is not yours — and until somebody does, whether that port answers is a belief rather than a fact.
Two tables. Both of them are records of something that happened.
This is what the review produces. Every row below is an example of the shape rather than anybody’s real stack — but the columns are the real columns, and the fifth one cannot be filled in by reading anything.
Published ports, tested from outside
| Service | Published | Bind address | What the firewall claims | What answered from outside | Evidence |
|---|---|---|---|---|---|
| postgres | 5432 | 0.0.0.0 | ufw — denied by the default policy | Answered. Identified itself as PostgreSQL | Observed |
| redis | 6379 | 0.0.0.0 | ufw — denied by the default policy | Answered. Identified itself as Redis | Observed |
| adminer | 8080 | 0.0.0.0 | ufw — denied by the default policy | Answered. A database login page | Observed |
| web | 443 | 0.0.0.0 | ufw — allowed, deliberately | Answered. This one is meant to | Observed |
| mysql — second stack | 3306 | 127.0.0.1 | not reached — bound to loopback | Nothing. Connection refused | Observed |
| rabbitmq — management | 15672 | 0.0.0.0 | a cloud security group, not the host firewall | Nothing. Dropped before the host | Observed |
| grafana | 3000 | 0.0.0.0 | ufw — denied by the default policy | Not tested. This host was not named in the written permission | Not tested |
Three rows there say the same thing: the firewall says denied, and the port answered anyway. That is the product. It is also the row that does not exist until somebody connects from an address that is not yours, which is why no file-reading tool returns it and why the report is worth what it costs.
Note the other four, because they matter as much. One port answered and is meant to. Two were
genuinely closed — one bound to 127.0.0.1, one covered by a cloud security
group that was doing its job. And one was not tested at all, because that host was not
named in the written permission. The report says so in the table rather than leaving
you to assume it was covered.
Credentials, and how far each one has already travelled
| Credential | Where it lives | Who can already read it | Where copies exist | Rotated |
|---|---|---|---|---|
| Database password | environment: in the Compose file, line 34 |
Anyone with the file — the repository, CI, every clone | Git history since the first commit, a CI variable, two developer laptops | Rotated. Four services and one cron job updated together |
| Payment provider API token | command: on the service line, line 71 |
Anyone who can run ps on the host, and every container using pid: host |
The process list, shell history, the deploy script | Rotated at the provider, and moved out of the command line |
| Registry password | A build ARG in the application image |
Anyone who can pull the image. It is in docker history |
Every registry copy, every pull, the CI build cache | Rotated, and the image rebuilt without the ARG. The old value is still readable in every copy nobody deleted |
| Shared API key | environment:, and compiled into a released mobile client |
Anyone with the file, and anyone who unpacks the app | The repository, the app stores, every installed handset | Not yet. It needs a client release first, which is its own piece of work |
The last column is the state after the repair, which is why one row says not yet. A key compiled into a released mobile app cannot be rotated on the server alone, and a table that pretended otherwise would be selling you a tidier morning than you are going to get.
This is not a penetration test, and I want that in writing.
One connection per port you named. Enough to record that something answered and what it said it was — and then it stops.
What is tried:
- Only the ports your own Compose file publishes. No scanning for others, no enumeration, no discovery sweep
- Only against hosts you have named in writing
- One connection per named port, recorded as answered or not answered, with whatever the service volunteered about itself
What is never tried:
- No credentials. Not default ones, not leaked ones, not the ones found in your own file
- No exploitation, and no container escape demonstration. That is a different kind of engagement and I do not sell it
- Nothing is written, changed or deleted anywhere
So “reachable” on the report means it answered. It never means it was entered, and the report will not imply otherwise. If you need someone to prove exploitability, you want a penetration test from a firm that does them, and I will say so rather than take the work.
Seven things go wrong, and the first three are the same problem.
Host access first, because a container that reaches the host makes everything below it academic. The finding ids are the ones the free checker prints, so you can match a row on your report to a paragraph here.
- compose.docker_socket A container can reach the host’s Docker daemon
- The Docker socket mounted into a container is root on the host today, by design, with no escape required and no permission model in front of it. Anything that can talk to it can start a second container with the whole filesystem mounted. It is separated from everything below because it is not a weakened boundary — it is no boundary at all.
- compose.privileged, compose.network_host, compose.pid_host, compose.ipc_host, compose.cap_dangerous, compose.security_unconfined, compose.userns_host A container shares the host’s isolation boundaries
- Different settings, one outcome: the walls come down one at a time.
privileged: trueremoves all of them at once.pid: hostmakes every process on the machine readable from inside, environment variables included.network_mode: hostmatters twice over here, because a container on the host network opens ports that never appear underports:at all — so the file stops describing what is reachable, which is this page’s whole argument in one line.userns_mode: hostgives back the remapping that makes container root harmless outside. - compose.host_path_mounted, compose.device_dangerous A container has a path or a device into the host
- A bind mount of a sensitive host directory reaches the host without needing
privilegedor a capability to do it. Writable, it ends where the socket ends, by a slower route. Read-only is better and is not safe, because those directories carry service credentials and every account on the machine. A raw disk device is the host filesystem with the host’s permissions taken off. - compose.db_port_published, compose.no_internal_network A backend service is published where it should not be
- A database or a cache with a
ports:entry is reachable from outside the machine, not just from the application in front of it. This is the fault the drill exists for, because the file says the port is published and stops there. Whether it answers depends on the firewall, the bind address, the cloud security group and the packet filtering order — and that combination is not in any file. - compose.admin_ui_published An admin interface is published to every interface
- Adminer, phpMyAdmin, a queue console, a metrics dashboard. They get published to
0.0.0.0during setup and stay there, and unlike a database they are designed to be used by a human with a browser. Anyone who finds one needs no tooling at all. - compose.secret_in_env, compose.secret_in_command Credentials readable in the file, or in the process list
- A literal password in
environment:is readable by everyone with the repository, which is more people than the team. On thecommand:line it is worse: it is in the process list, so any container sharing the host process namespace can read it without touching the file at all. - compose.secret_in_build_arg A credential that has already travelled
- This one is separated from the row above because the blast radius differs in kind. A credential passed as a build
ARGis visible indocker history, so every copy of that image carries it — in the registry, on every machine that pulled it, in the build cache. Editing the Compose file does nothing about any of them. Only rotation does, and rotation is work rather than advice.
Faults four and five are the ones the drill settles. The rest are read off the file, ranked by
what is actually reachable rather than by severity in the abstract — because a
privileged container on a machine nothing can reach is a worse configuration and
a smaller emergency than a database that answered on the first try.
Four ways in
Everyone starts with the drill, because until the ports have been tested nobody knows how much of the file actually matters — including me.
Docker Security Review
Everyone starts here. It also scopes everything else.
- Every port your project publishes, connected to from outside your network and recorded as answered or not answered
- The bind address and the firewall position for each one, so a port that answered has an explanation rather than just a verdict
- The credential blast-radius map — every credential in the project, who can already read it, and where copies exist
- The findings ranked by what is actually reachable, rather than by severity in the abstract
- The free checker’s results triaged, not re-discovered, and what is already correct as well as what is not
- Nothing is changed. One Compose project, one host, one registry, up to 20 services and 20 published ports
Review + Repair
Not a starting point. Follows the drill.
The tier where the ports that answered stop answering.
- What answered gets closed — backends moved behind an internal-only network, bindings corrected to the interface they should have been on, admin interfaces taken off every interface
- Leaked credentials rotated, with the dependents found first so nothing breaks at three in the afternoon
- The drill re-run from outside afterwards, proving the fix holds rather than asserting that it does
- Everything staged and reversible, with a named rollback for each change
Review fee credited — you pay the difference, not both.
Hardened Baseline
The above, plus taking the host access back.
- Everything in the repair
- The socket mount replaced with whatever its dependent actually needs — a scoped proxy, a narrower credential, or a different mechanism entirely
- Capabilities dropped to what the process uses, host namespaces given up, seccomp and AppArmor put back
- Non-root users where the images allow it, and named where they do not
- Staged and reversible, one service at a time, because these are the changes most likely to break something
Review fee credited — you pay the difference, not both.
Deploy Re-test
Because a one-line change can republish a port and nothing will say so.
- The published-port surface re-tested from outside after deploys
- The watch is for a port that should be closed becoming reachable — the drill in its recurring form, on the same named-hosts permission
- New services and new published ports added to the list as they appear
- A one-page record each month you can hand to anyone who asks
- Monthly in advance, cancel anytime, never auto-renewing
Five things about the packages, before you ask
- The cap is one Compose project on one host, with one registry — 20 services and 20 published ports. Services rather than files, on purpose: a twelve-line file publishing a database is a worse morning than a two-hundred-line file publishing nothing, and one project split across three files is still one project. Ports are capped separately because each one is a separate test and a separate conversation about what should sit in front of it.
- The recommended tier is the repair, and that is deliberate. The drill tells you the database answered. The repair means it stops. Only one of those changes your position — but you cannot start there, because until the ports are tested nobody knows which findings are urgent and which are paperwork.
- The second drill in the repair tier is included, not quoted. Charging extra to prove my own fix worked would be the exact failure this page is written against.
- The review fee is credited for 30 days. Come back inside a month and you pay the difference. After that it lapses, and not as a sales tactic: deploys happen, and a drill from last quarter would have to be run again anyway.
- What moves the size of the job is not the service count. It is whether the socket mount has a dependent — Traefik doing container discovery, a CI runner, a backup script — because removing it means replacing what needed it, and that is a build rather than an edit. After that: whether you build your own images, how many ports are published, whether the credentials can actually be rotated, and whether there is an outside vantage point and a window.
Deploy Re-test is not uptime monitoring, and it is not alerting. Uptime monitoring watches that something you want up is up, and it is free in places including the uptime trial on this site. If you want dashboards and somebody watching the whole stack, that is hosted monitoring and it is a different service. This watches the opposite thing: that a port which is supposed to be closed has not quietly become reachable again.
There are no figures on this page, and that is deliberate rather than coy. A fixed number would be too high for four services publishing one port and too low for a stack with a socket mount holding up three other things. Tell me the shape of it in the form — how many services, what is published, and whether anything mounts the Docker socket — and you get a figure back before anything is committed to.
What you have to supply
The first item is not negotiable and nothing starts without it.
Written permission naming every host and every port I may connect to from outside. Not a verbal yes, not “go ahead” in a chat window — in writing, from somebody entitled to give it, listing the hosts and the ports. The boundary above is part of that permission: named ports only, one connection each, no credentials tried, nothing written. If a host is not on the list, it is not tested, and the report says so in the table.
And then the ordinary things
- The Compose file or files, and which host actually runs them. A file in a repository is not necessarily what is running, and the difference between the two is occasionally the whole finding.
- Which host firewall is in use — ufw, firewalld, nftables directly, or a cloud security group. The answer changes the question, and “not sure” is a legitimate answer that I will find out.
- A window, or written permission to test live. The test is light, but a database answering a connection it has never seen before can log it or alert somebody, and I would rather that be expected.
- Registry access, if any image is built in-house, so build arguments and layer history can be checked.
- What depends on the socket mount, if there is one. “We don’t know” is a valid answer and becomes part of the finding rather than a problem.
- Somebody who can actually rotate a credential, or the name of who can. A map of leaked credentials nobody is able to change is half a deliverable.
- Somebody reachable while the test runs. If anything is disturbed despite the precautions, the work stops and you are told inside the hour.
What this cannot promise
Every one of these is on the page rather than in an email after you have paid.
- A reachability test proves what answered, from one vantage point, on one day. It is evidence, not a guarantee — and it is not proof that nothing else is reachable, because only the ports you named were tried.
- “Reachable” means it answered. It does not mean it was entered. No credentials are tried and nothing is written. I am saying that in those words because the alternative reading is worse, and this page should not profit from letting you assume it.
- The ufw statement is Docker’s, and it is about ufw. firewalld is handled differently by Docker’s own documentation and gets checked rather than assumed. A cloud security group may already make the whole question moot.
- Rotating a leaked credential does not un-leak it. Anyone who pulled that image still holds the old value, and any use of it before the rotation is outside what this engagement can see. This is the uncomfortable one and it belongs here rather than in a footnote.
- Removing host access can break what depended on it. A socket mount is usually load-bearing. Every change is staged and reversible with a named rollback, and if something has to break to be fixed you hear that before it happens rather than after.
- It is a point in time. The next
docker compose upcan republish a port and nothing will announce it. That is what the monthly tier is for, and it is a limitation of the one-off review rather than a reason to buy the subscription. - This is not a Kubernetes review and not an image vulnerability service. Trivy and Docker Scout do image scanning free and better.
- If nothing published is reachable and no credential has travelled, that is the finding. The fee is earned, the engagement ends, and you get a report saying you were right — which is worth having and is not a failed review.
Four things this is not
Named up front, so none of them turns into silent scope halfway through.
- Kubernetes. Different orchestrator, different failure modes, different tooling. Compose and Swarm are in scope; Kubernetes is quoted separately, if at all.
- Image vulnerability remediation. Trivy and Docker Scout find known vulnerabilities free and do it better than anything I would build. Upgrading a base image across a running application is application work with its own testing, and it is quoted separately.
- Incident response. If the drill finds that a published port answered and something else got there first, you are told immediately rather than at the end of the week, the engagement pauses, and what comes next is quoted separately.
- Rebuilding what the socket mount was holding up. Removing the mount is in the hardened tier; replacing a container-discovery mechanism, a CI runner or a backup path with something that does not need root on the host is a build. Naming it here stops it becoming silent scope inside tier 3.
How payment works
Plainly, so nothing about it is a surprise later. There is no account to create, no portal to log into and no card stored anywhere.
-
You apply through the form
No payment at this point, and no commitment. Tell me how many services, what is published, and what prompted this.
-
I review it and confirm the scope
If it is not worth doing, I say so here and it costs you nothing. If it is, you get the scope, the host and port list and the window in writing before any invoice exists.
-
An invoice arrives, payable within 48 hours
It carries an invoice number and a due date. Unpaid past 48 hours, the application lapses and the slot is released to somebody else.
-
Work begins once payment is received, and once the permission is signed
Not before, and the written permission is the second half of that sentence rather than a formality. For one-off work there is nothing to suspend after delivery, so payment comes first — that is the standard arrangement and it runs both ways.
-
Deploy Re-test, if you take it, is monthly in advance
Non-payment simply stops the work. Nothing is billed silently in the background and you can stop whenever you like.
If you are still building
Everything above assumes a stack that grew over years. Before launch the same work is a fraction of it, and here the gap is unusually large.
What that involves
- An internal-only network chosen while there are three services rather than twelve. Putting the backends behind one now is a few lines. Retrofitting it later means touching every service at once, on a live system, which is most of the repair tier.
- Nothing published that does not need to be — decided once, at the point where changing it costs nothing and nobody is depending on the current behaviour.
- Secrets kept out of the file and out of build arguments from the first commit, which is the only moment the credential blast radius is genuinely zero. Once an image with a build argument in it has been pushed, that is permanent.
- The first drill run before there is a customer to notice it. Then you know what answers from outside on a day nobody cares about, which is the cheapest that test will ever be.
Mention that you are pre-launch in the form and I will quote it as its own piece of work. It is cheaper, and it is the version of this engagement I would rather sell.
Run the free checker first. Then tell me what is published.
It costs nothing, needs no signup and no email address, and it prints the finding id along with the line it found. If it comes back clean, or you have already connected to those ports from outside your own network and know what answers, you have saved a fee and I would rather tell you that than take it. If nobody has ever tried, that is exactly what this is for.
Prefer to talk? Book a free call ↗ · Or hire me on Upwork ↗ · Typical reply within one business day.
Questions
Your own free checker already told me what is wrong. Why pay you?
Can I not just ask a language model to harden my Compose file?
Is this a penetration test?
I use firewalld, not ufw. Does any of this apply to me?
docker with target ACCEPT and a forwarding policy, and no bypass is claimed. So on firewalld it gets checked rather than assumed, and the drill is what settles it either way.Everything sits behind a cloud security group. Am I fine?
A password leaked through a build argument. I rotated it. Is it over?
docker history, so anyone who pulled that image still holds the old value, and any use of it before the rotation is outside what this engagement can see. What the review gives you is the map: where copies of that image exist, who could have pulled it, and what the credential still opens if somebody did.