Docker · advanced

How to verify Docker port publication reaches the intended process

Verifying Docker port publication is a binding-and-route diagnostic, not a connectivity check. The goal is to confirm that a `-p`/`--publish` flag on `docker run` translates into a host port whose socket terminates inside the containerized process bound to the intended container port, without accidentally publishing on a routable interface or to a wrong listener.

The symptoms

  • `docker run -p 8080:80` returns connection refused from external clients even though `docker ps` shows `0.0.0.0:8080->80/tcp` as a binding.
  • Application health probes from outside the host fail with timeout or reset, while `docker exec` into the container shows the service listening on the expected port.
  • Unrelated host services respond on a port that the operator believed was bound to a container, indicating accidental publication on a too-broad address.
  • Two containers declared `-p 8080:80` on different UIDs and one silently shadows the other because the kernel rebinds to the second process.
  • `curl` inside the container works, but the same path from the host gateway or another container fails, pointing to publish-form misinterpretation rather than application failure.

Likely causes

  • `-p 8080:80` was parsed as `hostPort:containerPort` by the operator but the actual published form resolves to a different interface (e.g., `127.0.0.1:8080` vs `0.0.0.0:8080`) depending on Docker daemon defaults and version-specific behavior.
  • The containerized process binds only to a loopback address inside the container namespace, so the publish mapping exists at the proxy layer but no socket accepts traffic at the container side.
  • A second `-p` mapping or an overlapping host port shadows the intended binding, and `docker ps` is read after the rebind rather than at container start.
  • Userland proxy mode is enabled or disabled inconsistently with the kernel expectation, so `docker-proxy` mediates traffic instead of direct iptables/IPVS rules, masking which socket is actually serving.
  • The compose or orchestration layer declares `expose` only and does not declare `ports`, so no host publication occurs even though a container port appears reachable inside the compose network.

First ten minutes

  1. 01Capture the exact command line or compose fragment that created the container; record the publish flags verbatim, including any default network and the userland proxy setting on the daemon.
  2. 02Run a read-only `docker ps` and `docker inspect` pass to record the published fields as currently observed, not as declared; compare against the intended form field by field.
  3. 03From inside the container, enumerate which sockets are bound and on which container IP, to verify the process is listening on the target container port before the proxy layer is blamed.
  4. 04From the host, enumerate which sockets are bound on the host IP and port that the publish claims, and verify the owning PID namespace boundary, not just the port number.

Evidence to collect

  • The exact `docker run` arguments or compose `ports`/`expose` block that produced the container, including any `network_mode`, `userland-proxy-*` daemon flags, and image entrypoint defaults.
  • `docker inspect` output fields `HostConfig.PortBindings`, `NetworkSettings.Ports`, `Config.ExposedPorts`, and the resolved mapping per port.
  • Inside-container socket listing on the published container port showing the local bind address and owning PID.
  • Host socket listing on the host port showing the bind address (loopback vs wildcard) and the kernel process or `docker-proxy` PID owning it.
  • The application's own listener log or readiness endpoint response observed from inside the container versus from outside the host, with differences annotated.

Where to look

  • The boundary between the container network namespace and the host network namespace, where `docker-proxy` and iptables/IPVS rules translate a host socket into a container socket; mismatches here explain most publication failures.
  • The compose file or orchestrator definition, which can declare `expose` without `ports`, producing a container-internal listener with no host publication.
  • The container image's default entrypoint and any startup script, where a process may bind to a loopback or specific address inside the container namespace and silently disagree with the published container port.
  • The Docker daemon configuration for proxy mode and default address pool, where toggling the proxy changes which PID actually owns the host socket.

Diagnostic steps

  1. 01Reconstruct the publish form: take the operator's declared `-p host:container` and compare it field by field against `docker inspect` `NetworkSettings.Ports`; a missing or rewritten `HostIp` field is the first discriminator between accidental loopback publish and intended wildcard publish.
  2. 02Differentiate proxy mode from iptables mode: with the userland proxy enabled, a `docker-proxy` process owns the host socket; with proxy disabled, an iptables or IPVS rule owns the routing, and `ss`/`netstat` will not show a listening process on the host port. The presence or absence of `docker-proxy` for a given container-port pair is direct evidence of which mode is active.
  3. 03Differentiate application bind from publication: enumerate sockets inside the container on the published container port and confirm the bind address covers `0.0.0.0` or the container's interface, not `127.0.0.1`. A loopback bind inside the container produces silent drops at the proxy layer that look like publish failures.
  4. 04Differentiate conflict from configuration: if the host socket is owned by a non-Docker PID, another service is bound to the requested host port, and Docker's publish either failed silently or rebound to a different host port depending on the form used.
  5. 05Differentiate compose `expose` from `ports`: confirm whether the manifest declared `expose` only; in that case no host publication will ever exist and the symptom is expected behavior, not a misconfiguration.

Common mistakes

  • Reading `docker ps` and concluding publication is correct because the mapping string exists, without checking whether the `HostIp` field is loopback or wildcard as intended.
  • Assuming `curl` from inside the container validates the publish; the publish boundary is not exercised by intra-container traffic and any green result is uninformative about external reachability.
  • Toggling userland proxy mid-incident without recording the prior state, which changes which PID owns the host socket and can mask the original evidence.
  • Re-launching a container with the same publish flag and treating the new behavior as the original failure, when the image default entrypoint rebinds between runs.
  • Treating a compose `expose` declaration as equivalent to `ports`, then escalating as if the host boundary were broken.

Safe fixes

  • If the operator's intent is wildcard publish on a routable interface, and the observed `HostIp` is loopback, recreate the container with the explicit form that pins the host IP rather than relying on daemon defaults; record the exact flag string as evidence before and after.
  • If the process is bound only to a loopback address inside the container, the fix is at the image or configuration layer (not the publish layer); the publish mapping is correct and changing `-p` will not change reachability until the application actually binds to a non-loopback address.
  • If `docker-proxy` is absent and iptables/IPVS is in use, do not assume a missing host listener means failure; consult the host's NAT rule chain for the published port rather than adding a conflicting listener.
  • If a host port is held by a non-Docker PID, change the host port mapping rather than killing the holding process; document the new mapping and re-run the verification sequence from the top.
  • If compose declared `expose` only, add a `ports` block with the exact intended form; do not assume the orchestrator will translate `expose` into host publication.

Prove the fix

  1. 01After the change, `docker inspect` on the new container shows a `HostConfig.PortBindings` entry whose `HostIp` matches the declared intent for every published port, and whose `HostPort` is the exact integer declared or reassigned by design.
  2. 02An external client using the host's routable interface and the documented `HostPort` receives a non-error response from the containerized process's health/readiness endpoint, and the same call against a loopback source fails or succeeds consistently with the declared `HostIp`.
  3. 03Inside the container, the listening process on the published container port shows a bind address that is not loopback, and the same PID is reachable through the host path; this closes the loop between container-side bind and host-side publication.
  4. 04If a change involved toggling userland proxy mode, an additional regression check shows the expected owner (`docker-proxy` PID for proxy mode, or an iptables/IPVS rule referencing the container IP for kernel mode) on the host side after the change.

Prevention and next steps

  • Pin the publish form declaratively in compose or IaC, including the `HostIp` and `HostPort` fields, so a daemon default change cannot silently alter the publication surface between environments.
  • Make image entrypoints bind to `0.0.0.0` (or all interfaces) by default and assert this in startup logs, so a publication failure cannot be misread as an application bind failure.
  • Run a read-only port-ownership check as part of deployment verification, comparing the expected host bind address against the actual socket ownership recorded at boot.
  • Reserve per-environment host port ranges so accidental collisions between services are detected at admission time rather than at first connection.
  • Treat `expose` and `ports` as semantically distinct in code review; require a justification comment whenever a service is reachable only inside a compose network.

Safe commands and checks

docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Ports}}'
docker inspect --format '{{json .HostConfig.PortBindings}}' <container>
docker inspect --format '{{json .NetworkSettings.Ports}}' <container>
docker inspect --format '{{json .Config.ExposedPorts}}' <container>
docker exec <container> ss -ltnp 2>/dev/null || docker exec <container> netstat -ltnp 2>/dev/null
ss -ltnp 2>/dev/null | grep -E ':<port> ' || netstat -ltnp 2>/dev/null | grep -E ':<port> '
pgrep -af 'docker-proxy' || true
iptables -t nat -S 2>/dev/null | grep -E '<container_ip>|<port>' || true