Docker Exposure Guide

Is your Docker exposed?

You set up UFW. You think your ports are blocked. Then you run docker run -p 6379:6379 redis and your Redis instance is reachable from the public internet — UFW notwithstanding. This is not a bug. It is Docker's documented default behaviour, and it catches experienced engineers by surprise every week.

The iptables trap: how Docker bypasses UFW

Exposed — avoids UFW
docker run -p 6379:6379 redis
docker run -p 8080:80 nginx
# binds to 0.0.0.0 by default
Safe — localhost only
docker run -p 127.0.0.1:6379:6379 redis
docker run -p 127.0.0.1:8080:80 nginx
# only reachable from this machine

Port 2375: the hole in the hull

Port 9222: Chrome DevTools on the public internet

How to check what's actually exposed

There are two ways to check your exposure: from inside the box, and from outside it. Both matter. Docker's iptables manipulation means the inside view can be misleading — a port that looks protected from the host's perspective may still be reachable from outside.

INSIDE — HOST BINDINGS
ss -tlnp

Shows which ports have open TCP listeners and which process owns them. Look for 0.0.0.0 in the Local Address column. Any service bound to 0.0.0.0 is a candidate for unintended exposure.

INSIDE — DOCKER PORTS
docker ps --format 'table {{.Names}}\t{{.Ports}}'

Lists all running containers and their port mappings. A mapping like 0.0.0.0:6379->6379/tcp means that container port is published to all interfaces. A mapping like 127.0.0.1:6379->6379/tcp is safe.

OUTSIDE — REACHABILITY
nmap -sV -p 2375,6379,9222,5432 <your-server-ip>

Run this from a machine outside your network. This is the attacker's view — it sees what's actually reachable, not what you think you've blocked. Replace the port list with all ports your services use.

What Keelix shows you

Keelix scan finding — example
WARNRedis bound to 0.0.0.0 — reachable from internet
CRITDocker daemon API (port 2375) reachable — unauthenticated root access
PASSPostgreSQL bound to 127.0.0.1 — not internet-reachable
Each finding maps to a CIS Docker Benchmark or OWASP control.

Fix it: four steps

01

Bind to 127.0.0.1, not 0.0.0.0

Change every -p mapping to -p 127.0.0.1:HOST:CONTAINER for services that don't need to be directly public. In docker-compose.yml: ports: ["127.0.0.1:6379:6379"]. Re-run docker ps and verify the 0.0.0.0 bindings are gone.

02

Never expose the Docker daemon on TCP

Run the daemon on the Unix socket only (the default). If you need remote access for CI or tooling, use SSH tunnelling (DOCKER_HOST=ssh://user@host) or mutual TLS — never -H tcp://0.0.0.0:2375 on a internet-connected host.

03

Apply a cloud security group as a second layer

Your cloud provider's security group or network ACL operates at the network edge, before traffic reaches your host's iptables rules. It is not a substitute for correct Docker bindings, but it is a valuable second layer. Deny all inbound traffic except the ports you explicitly need to be public.

04

Verify with an outside-in scan

After fixing the bindings, confirm the change with an outside-in scan — either nmap from an external host or keelix scan on the box itself. The outside-in view is what attackers see; the inside view (ss, netstat) can be misleading when Docker's iptables rules are in play.

Frequently asked

Is my Docker container exposed to the internet?
Possibly — and more likely than you'd expect. Every time you run `docker run -p 8080:80` or publish a port in docker-compose.yml without binding it to 127.0.0.1, Docker inserts iptables rules that open that port to all interfaces. These rules bypass host firewalls like UFW entirely, so a 'ufw deny 8080' rule will not protect you. Run `ss -tlnp` or `keelix scan` to see what's actually reachable from outside your box.
Why does Docker bypass UFW?
Docker manages its own iptables and nftables rules directly, inserting ACCEPT rules in the DOCKER chain before the FORWARD rules UFW relies on. This is a known, documented behaviour — not a bug — and it applies even when UFW is enabled and configured to deny the port. The OWASP Docker Security Cheat Sheet (Rule #5a) explicitly warns about this. The fix is to bind ports to 127.0.0.1 instead of 0.0.0.0 at publish time.
What is the difference between binding to 0.0.0.0 and 127.0.0.1?
0.0.0.0 means 'listen on every network interface on this host', including the public IP. Any internet-connected client can reach the port if your cloud provider's security group or network firewall allows it — and because Docker bypasses UFW, the host firewall won't stop it. 127.0.0.1 (localhost) means 'listen only on the loopback interface' — only processes on the same machine can connect. For any service not meant to be directly public, use 127.0.0.1:HOST_PORT:CONTAINER_PORT in your -p mapping.
Why is port 2375 dangerous?
Port 2375 is the Docker daemon's unencrypted TCP API port. If your daemon is running with -H tcp://0.0.0.0:2375, anyone who can reach that port has unauthenticated root-equivalent access to your host: they can launch containers, mount filesystems, exfiltrate data, or take over the machine entirely. Attackers actively scan the internet for port 2375 using masscan — Akamai's Hunt Team documented a live malware strain doing exactly this in August 2025. The Docker daemon should never be exposed on TCP without mutual TLS, and ideally should use the Unix socket only.

See exactly what's reachable from the internet.

Free, local-first, Apache-2.0. The outside-in scan runs on your box. Nothing leaves it.

operator@host — keelix
# install — free & open source (Apache-2.0)
$ curl -fsSL https://keelix.dev/install.sh | sh
$ keelix scan