Docker – Ports, Networks and Container DNS

July 12, 20264 min readUpdated 8/21/2026

Two ideas cause nearly every Docker networking problem, and neither is complicated once stated:

A published port has a host side and a container side, and they are independent.

Inside a container, localhost means that container.

-p, read correctly

ports:
  - "3309:3306"

Host on the left, container on the right. Always. So MySQL listens on 3306 inside its container, as MySQL always does, and your machine reaches it on 3309.

Why shifted? Because the host side is a scarce, shared resource — one port, one claimant, for the whole machine. On the machine this track was written on, 3306 is a natively installed MySQL and 3308 belongs to the demo app's development compose file, so the full-stack file takes 3309:

$ docker compose up -d
Error response from daemon: failed to bind host port for 0.0.0.0:3308:
address already in use

The container side is not scarce. Each container has its own network namespace and its own set of 65,535 ports, so ten containers can all listen on 3306 with no conflict at all. Which means there is never a reason to shift it — and shifting it breaks the service names other containers connect by.

Some variations worth knowing:

-p 8080:80              # host 8080 -> container 80
-p 127.0.0.1:8080:80    # ... reachable only from this machine, not the LAN
-p 80                   # random free host port -> container 80
-P                      # publish every EXPOSEd port to random host ports

⚠️ -p 8080:80 binds 0.0.0.0 — every interface. On a laptop on a café network, that database you published is reachable from the café. The 127.0.0.1: prefix is the fix, and is worth defaulting to for anything with data in it.

Containers talk to each other without any of this

Here is the part that clicks late for most people. Publishing is only for traffic coming from outside Docker. Containers on the same network reach each other directly, on the container port, whether or not anything is published.

Compose puts every service in a project on one network and runs a DNS server on it that resolves service names:

SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/pizza?useSSL=false&allowPublicKeyRetrieval=true&connectionTimeZone=LOCAL&preserveInstants=false

mysql is the service name from compose.yaml. 3306 is the port inside that container. Neither value has anything to do with the 3309:3306 line — this traffic never touches the host.

location /api/ {
    proxy_pass         http://api:8085;
    proxy_http_version 1.1;

Same thing from nginx: service name api, container port 8085. The host publishes 8086:8085, and the proxy does not care.

Prove it from inside:

$ docker compose exec web sh -c 'getent hosts api mysql'
172.18.0.3    api
172.18.0.2    mysql

The mistake this all exists to prevent

You have MySQL working from your terminal on 127.0.0.1:3309. You put the same string in the API's configuration. The API cannot connect.

# Wrong — inside the api container, 127.0.0.1 IS the api container,
# which runs no database. And 3309 is a host-side number that means
# nothing on the container network.
SPRING_DATASOURCE_URL: jdbc:mysql://127.0.0.1:3309/pizza

# Right — service name, container port.
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/pizza

Every container has its own loopback interface. localhost is not a synonym for "this machine" in there; it is a synonym for "this container".

The same trap in the other direction: a container that needs something running natively on your laptop — a database you did not containerise, a debugger — cannot use localhost either. Docker Desktop provides a name for it:

host.docker.internal

On Linux it is not automatic; add extra_hosts: ["host.docker.internal:host-gateway"] to the service.

Network types, briefly

bridge is the default and what Compose creates per project. Containers get private IPs, talk to each other by name, and reach the outside world through NAT.

⚠️ One wrinkle worth knowing: the default bridge network you get from a bare docker run has no DNS between containers. Name resolution is a feature of user-defined networks, which is what Compose makes and what docker network create gives you. It is a common source of "it works in Compose and not with docker run".

host removes the isolation entirely — the container shares your network stack, so -p is meaningless and its ports are simply your ports. Fast, and Linux-only in any meaningful sense.

none gives no networking at all. Genuinely useful for a batch job that only touches a mounted volume.

Debugging it

docker network ls
docker network inspect pizza_default          # which containers, which IPs
docker compose port api 8085                  # what is 8085 published as?
docker compose exec web sh -c 'getent hosts api'   # does the name resolve?
docker compose exec web sh -c 'wget -qO- http://api:8085/api/products | head -c 100'

Work outwards. If getent hosts api fails, it is a network or service-name problem. If it resolves but the request hangs, the target is not listening on that port — often because the app bound 127.0.0.1 instead of 0.0.0.0, and is therefore unreachable from anywhere but its own container.

Next: where the data goes.