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 useThe 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=falsemysql 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 mysqlThe 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/pizzaEvery 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.internalOn 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.