A colleague on the same café Wi-Fi opens http://192.168.1.23:5432 in a database client and connects to your local Postgres with the default postgres/postgres credentials; a security scan of the office network flags a dozen laptops exposing Redis on 6379 and an unauthenticated Mailpit UI on 8025. On Linux, ufw deny 5432 changes nothing. The cause is one line repeated across every Compose file: ports: - "5432:5432", which publishes on all interfaces. This page binds development ports to loopback, explains why host firewalls do not help, and adds a check that keeps it that way, as part of local network and port mapping.

Local development stacks routinely run services with default or no authentication. That is fine on loopback and dangerous on any shared network.

Diagnostic

List what the stack publishes and on which addresses, then test from another interface:

#!/usr/bin/env bash
set -euo pipefail
docker compose ps --format '{{.Service}}\t{{.Ports}}'
docker compose config --format json | jq -r '.services | to_entries[] | .key as $s | (.value.ports // [])[] | "\($s)\t\(.host_ip // "0.0.0.0"):\(.published) -> \(.target)"'
lan_ip=$(ipconfig getifaddr en0 2>/dev/null || hostname -I | awk '{print $1}')
for p in 5432 6379 8025; do
  nc -z -w1 "$lan_ip" "$p" && echo "EXPOSED on $lan_ip:$p" || echo "not reachable on $lan_ip:$p"
done

Expected bad output:

db      0.0.0.0:5432->5432/tcp, :::5432->5432/tcp
cache   0.0.0.0:6379->6379/tcp, :::6379->6379/tcp
mail    0.0.0.0:8025->8025/tcp, :::8025->8025/tcp
db      0.0.0.0:5432 -> 5432
EXPOSED on 192.168.1.23:5432
EXPOSED on 192.168.1.23:6379
EXPOSED on 192.168.1.23:8025

Every published port listens on all IPv4 and IPv6 interfaces and answers on the LAN address.

What a Published Port Binds To Layers showing how a port mapping without a host IP binds every interface, including the LAN. What a Published Port Binds To 5432:5432 no host IP given 0.0.0.0 and :: all interfaces loopback 127.0.0.1, fine LAN interface office or café network VPN interface corporate network
Without a host IP, a mapping listens everywhere the machine has an address.

Root cause

The short port syntax HOST:CONTAINER omits the host IP, and Docker's default for an omitted host IP is 0.0.0.0 (and :: for IPv6) — every interface on the machine. On macOS and Windows, Docker Desktop's port forwarder then listens on all host interfaces. On Linux, Docker programs iptables NAT rules for published ports directly, in chains evaluated before the rules that ufw and firewalld manage, so a host firewall that denies the port does not see the traffic at all. The result is that every development service with a published port is reachable from any network the laptop joins, and the tool most people reach for to prevent it silently does nothing. The fix belongs in the port mapping itself: bind to 127.0.0.1, which is reachable only from the machine.

It is worth being clear about why this matters even for "just development". Local databases are routinely loaded with copies or subsets of production data, seeded with realistic customer records, or connected to cloud sandboxes with real credentials in environment variables. Admin UIs such as Mailpit, pgAdmin or the Traefik dashboard expose that data without authentication by design. A laptop on a conference network, a co-working space or a hotel is on a network with strangers, and scanning a /24 for port 5432 takes seconds. Nothing about the development stack is misconfigured from the stack's point of view; the exposure comes entirely from the default binding.

The same reasoning applies inside companies. Office networks and VPNs connect hundreds of machines, and lateral movement after a single compromised laptop often starts by looking for exactly these services. Loopback binding costs nothing in developer experience, which makes it one of the cheapest security improvements available in local environments.

Resolution

  1. Prefix every development port with 127.0.0.1:
services:
  db:
    image: postgres:16.4
    ports:
      - "127.0.0.1:5432:5432"
  cache:
    image: redis:7.4
    ports:
      - "127.0.0.1:6379:6379"
  mail:
    image: axllent/mailpit:v1.20
    ports:
      - "127.0.0.1:8025:8025"
      - "127.0.0.1:1025:1025"

The long syntax is equivalent and more explicit:

services:
  db:
    ports:
      - target: 5432
        published: "5432"
        host_ip: 127.0.0.1
        protocol: tcp
  1. Publish nothing that does not need to be reached from the host. Services talk to each other over the Compose network by name; only ports a developer opens in a browser or a host tool need publishing. Remove the rest.

  2. Set a daemon-wide default on Linux so forgotten mappings are safe too. In /etc/docker/daemon.json:

{
  "ip": "127.0.0.1"
}

Restart Docker (sudo systemctl restart docker). Mappings without a host IP now bind to loopback. Docker Desktop exposes the same setting in its Docker Engine configuration.

  1. Recreate containers so the new bindings apply:
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --force-recreate
docker compose ps --format '{{.Service}}\t{{.Ports}}'
Should This Port Be Published at All? Decision diagram deciding whether and how to publish a container port. Should This Port Be Published at All? Who needs to reach this port? other containers only do not publish tools on this machine 127.0.0.1 binding a phone on the LAN temporary, with auth
Most ports need no publishing; the rest go to loopback unless a device must reach them.

Expected output

$ docker compose ps --format '{{.Service}}\t{{.Ports}}'
cache   127.0.0.1:6379->6379/tcp
db      127.0.0.1:5432->5432/tcp
mail    127.0.0.1:1025->1025/tcp, 127.0.0.1:8025->8025/tcp
$ nc -z -w1 192.168.1.23 5432 && echo EXPOSED || echo "not reachable on LAN"
not reachable on LAN
$ psql -h 127.0.0.1 -U postgres -c 'select 1' | head -3
 ?column?
----------
        1

Services remain reachable from the laptop's own tools on 127.0.0.1 and are invisible to the network.

Note that the IPv6 entries (:::5432) disappeared as well: binding to 127.0.0.1 publishes only on IPv4 loopback. Tools that resolve localhost to ::1 first may then fail to connect; use 127.0.0.1 explicitly in connection strings, or add a second mapping on [::1] if a tool insists on IPv6.

Prevention

  1. Fail CI on unbound ports in Compose files intended for development:
#!/usr/bin/env bash
set -euo pipefail
bad=$(docker compose config --format json | jq -r '.services | to_entries[] | .key as $s | (.value.ports // [])[] | select((.host_ip // "") != "127.0.0.1") | "\($s): \(.published)"')
[ -z "$bad" ] && echo "all published ports bound to loopback" || { echo "ports published on all interfaces:"; echo "$bad"; exit 1; }
  1. Add the check to make doctor using the LAN-reachability test from the diagnostic, which also catches containers started outside Compose.

  2. Set the daemon default in workstation provisioning so new machines are safe before any project is cloned; see provisioning Linux workstations with Ansible.

0.0.0.0 vs 127.0.0.1 Bindings Comparison of publishing development ports on all interfaces versus loopback only. 0.0.0.0 vs 127.0.0.1 Bindings "5432:5432" "127.0.0.1:5432:5432" all interfaces loopback only reachable from LAN reachable from this machine ufw does not block it nothing to block default credentials exposed credentials stay local
Loopback keeps the same developer experience with none of the exposure.

Platform caveats

Linux: Docker's iptables rules bypass ufw and firewalld for published ports. Binding to loopback or setting the daemon's default ip is the reliable fix; editing the DOCKER-USER chain also works but is easy to get wrong.

macOS (Docker Desktop): loopback bindings are honoured by Docker Desktop's port forwarder. Some older versions forwarded 127.0.0.1 bindings to all interfaces; keep Docker Desktop current.

WSL2: Windows applications reach 127.0.0.1 ports published in WSL through localhost forwarding; LAN devices do not, which is the intended behaviour.

Testing on a phone: when a device on the LAN genuinely needs access, publish that one port on the LAN address temporarily and protect it with authentication, or use a tunnel, then revert.

Rollback

Remove the 127.0.0.1: prefixes and recreate; the old behaviour returns:

#!/usr/bin/env bash
set -euo pipefail
sed -E -i.bak 's/"127\.0\.0\.1:([0-9]+:[0-9]+)"/"\1"/' compose.yaml
docker compose up -d --force-recreate

Frequently Asked Questions

Why can a colleague connect to my local database?

The Compose port mapping has no host IP, so Docker publishes it on every interface, including the Wi-Fi network. Bind it to 127.0.0.1 so only your machine can connect.

Why does ufw deny not block Docker ports?

Docker inserts its own iptables rules for published ports in chains processed before ufw's rules, so ufw never sees the traffic. Bind ports to loopback or configure the DOCKER-USER chain instead.

Will binding to 127.0.0.1 break container-to-container traffic?

No. Containers reach each other over the Compose network by service name and container port; published ports and their host IPs only affect access from the host and beyond.

Is this necessary behind a corporate VPN?

Yes. VPN interfaces are networks too, often reachable by many more machines than a home Wi-Fi. Loopback binding protects development services on every network the laptop joins.