Skip to content

13.6.6 — Running Containers: Networking, Storage and Every Flag That Matters

The container is running. The port is published. docker ps shows 0.0.0.0:3000->3000/tcp. And curl localhost:3000 answers:

curl: (56) Recv failure: Connection reset by peer

The application is listening on 127.0.0.1:3000 inside the container. Loopback inside a network namespace is a different loopback from the host's, so the published port forwards traffic to an address nothing is bound to. One word in the application's config — 0.0.0.0 instead of 127.0.0.1 — fixes it.

That failure is the theme of this page. Every container networking and storage surprise comes from a namespace boundary you forgot was there, and once you can draw the boundary, they all become obvious.

1. The container lifecycle

A container is always in exactly one state, and knowing which one narrows any problem immediately.

created ──start──▶ running ──pause──▶ paused ──unpause──▶ running
                     │                                        │
                     ├──stop (SIGTERM, wait, SIGKILL)──▶ exited
                     ├──kill (SIGKILL immediately)─────▶ exited
                     └──process exits on its own───────▶ exited

                                                        rm ──▶ gone

docker stop is two signals with a wait in between. It sends SIGTERM, waits ten seconds by default, then sends SIGKILL. --time=30 changes the wait. docker kill skips straight to SIGKILL, so in-flight work is lost.

exited is not removed. An exited container still holds its writable layer, its logs and its configuration on disk. This is why docker ps -a shows hundreds of them on a machine that has been running builds for a month, and why they consume real disk. --rm removes a container when it exits, and it is the right default for anything interactive or short-lived.

The failure to recognise instantly: a container that exits immediately with code 0. The main process finished. A container lives exactly as long as its foreground process — that is the entire lifecycle rule. A start script that launches the server in the background and then ends stops the container, because from the outside the container's job was that script and the script is done. This trips almost everyone arriving from virtual machines, where a machine stays up regardless of what any one process does.

2. Networking: what the four modes actually do

Every container gets a network namespace, and the mode decides what is in it.

Host machinecontainer "api"own network namespaceeth0 = 172.18.0.2listens 0.0.0.0:3000lo here ≠ host's locontainer "db"own network namespaceeth0 = 172.18.0.3listens 0.0.0.0:5432veth pairveth pairbridge br-a1b2 (a virtual switch, 172.18.0.0/16)embedded DNS at 127.0.0.11 resolves "db" → 172.18.0.3iptables: NAT + port publishhost eth0 — the real network
Figure 1 — Default bridge networking. Each container's eth0 is one end of a virtual cable; the other end plugs into a software switch on the host. Containers reach each other directly across the bridge by name. Traffic to the outside is address-translated, and a published port is an iptables rule forwarding a host port onto a container IP.

--network bridge — the default

A veth pair is a virtual network cable with two ends. Whatever enters one end leaves the other. Docker puts one end inside the container's namespace and names it eth0, and plugs the other end into a software switch on the host called a bridge.

Containers on the same bridge talk to each other directly by IP. Traffic leaving for the internet is address-translated to the host's IP.

The default bridge (docker0) and a user-defined bridge behave differently, and the difference matters:

Default bridgeUser-defined bridge
DNS by container nameNoYes
Isolation from other containersNoYes
Connect/disconnect while runningNoYes

Always create a network. It is one command and it is what gives you name resolution:

bash
docker network create app-net
docker run -d --name db  --network app-net postgres:17
docker run -d --name api --network app-net -e DATABASE_URL=postgres://db:5432/app myapi

db resolves to the database container's IP, because Docker runs an embedded DNS server at 127.0.0.11 inside each container on a user-defined network, and it knows the container names. The IP itself changes on every restart; the name does not. Never put a container IP in configuration.

Publishing ports

bash
docker run -p 8080:3000 myapi        # (1)
docker run -p 127.0.0.1:8080:3000 myapi  # (2)
docker run -P myapi                   # (3)

(1) Host port 8080 forwards to container port 3000. The order is host:container and getting it backwards is a rite of passage.

(2) Bind only to the host's loopback, so the port is reachable from the machine but not from the network. The default, -p 8080:3000, binds to 0.0.0.0 — every interface, including the public one. On a cloud VM that means the internet, and it bypasses the firewall you thought you had, because Docker writes its rules into a chain that is evaluated before most host firewall rules. This is a genuinely common way to expose a database to the world by accident.

(3) Publish every EXPOSEd port on a random high host port.

Publishing is an iptables DNAT rule, not a proxy. The packet's destination address is rewritten and it is routed straight to the container. (For a few cases such as loopback-only publishing, a small helper process does proxy, which is why you may see docker-proxy in the process list.)

--network host — no network namespace

The container uses the host's network stack directly. Its localhost is the host's localhost, and any port it binds is bound on the host with no publishing.

Use it for: measurable performance gains where the NAT hop matters, and for tools that need to see the host's real interfaces — monitoring agents, network diagnostics.

The costs: no isolation at all, port conflicts with the host and with every other host-network container, and it does not work on Docker Desktop for Mac or Windows in the way people expect, because "the host" there is the hidden Linux VM, not your laptop.

--network none — nothing but loopback

An interface-free namespace. Use it for genuinely untrusted computation — parsing a hostile file, running submitted code — where the strongest statement you can make is that it has no network at all.

--network container:<name> — share another container's namespace

Two containers, one network stack, one IP, one port space, talking over localhost. This is exactly what a Kubernetes pod does (Chapter 13.6.11), and seeing it here first makes pods obvious rather than mysterious.

macvlan and overlay

macvlan gives the container its own MAC address on the physical network, so it appears to the rest of the LAN as a separate machine with its own IP. Used for legacy systems that must be addressed directly, and it requires cooperation from the network.

overlay spans multiple hosts by encapsulating container traffic inside packets between hosts. It exists for Docker Swarm. Kubernetes solves the same problem differently and better (Chapter 13.6.12).

The three networking failures you will meet

"Published port, connection refused." The application is bound to 127.0.0.1 inside the container. Bind to 0.0.0.0. This is the opening of this page and it is the most common one by a wide margin.

"Cannot reach the host's database from a container." localhost inside the container is the container. On Docker Desktop, the host is reachable at host.docker.internal. On Linux, add --add-host=host.docker.internal:host-gateway.

"Two containers cannot see each other." They are on the default bridge, which has no DNS, or on different networks entirely. Create a user-defined network and put both on it. docker network inspect app-net lists the members and settles it in one command.

3. Storage: three kinds of mount

The container's writable layer disappears when the container is removed. That is by design (Chapter 13.6.2). Anything that must survive goes into a mount, and there are exactly three kinds.

bash
docker run -v pgdata:/var/lib/postgresql/data postgres:17     # (1) named volume
docker run -v "$(pwd)/src:/app/src" myapi                     # (2) bind mount
docker run --tmpfs /tmp:size=64m myapi                        # (3) tmpfs

(1) A named volume is storage Docker manages, in its own directory on the host. This is the right choice for data: it survives container removal, is backed up as a unit, can use a driver for network or cloud storage, and — importantly on Mac and Windows — lives inside the Linux VM where filesystem performance is native.

(2) A bind mount maps a host path into the container. The right choice for source code during development, so an edit on your machine appears instantly inside the container. On Mac and Windows it is slow, because every file operation crosses the VM boundary; a project with a large node_modules bind-mounted can be several times slower than the same code on Linux.

(3) A tmpfs mount lives in memory only and never touches disk. For scratch files and for secrets you do not want written anywhere.

The --mount syntax is the clearer modern form and it fails loudly instead of guessing:

bash
docker run --mount type=volume,source=pgdata,target=/var/lib/postgresql/data postgres:17
docker run --mount type=bind,source="$(pwd)"/src,target=/app/src,readonly myapi

-v with a path that does not exist creates a directory; --mount with a missing source errors. The -v behaviour is why people end up with an empty directory mounted over their configuration file and a service that starts with defaults for no visible reason.

Two behaviours worth knowing:

A named volume that is empty gets seeded from the image. Mount an empty volume at a path the image already populated and the image's files are copied in on first use. A bind mount never does this — it hides whatever was there. This is the whole reason -v ./node_modules:/app/node_modules behaves differently from what people expect.

Ownership is by numeric UID, and the numbers must line up. A container running as UID 1000 writing to a bind-mounted host directory owned by UID 501 gets permission denied. On Linux this is a real friction point, and --user "$(id -u):$(id -g)" is the usual answer.

Anonymous volumes accumulate. A VOLUME in a Dockerfile with no volume supplied creates one with a random name every run. docker volume ls on a long-lived machine is often a page of them. docker volume prune clears the unused ones.

4. Resource limits

bash
docker run \
  --memory=512m \                # (1)
  --memory-reservation=256m \    # (2)
  --cpus=1.5 \                   # (3)
  --pids-limit=200 \             # (4)
  --restart=unless-stopped \     # (5)
  myapi

(1) Hard memory ceiling. Exceed it and the process is killed — OOMKilled, exit code 137, no graceful shutdown. docker inspect shows "OOMKilled": true, which is how you tell this apart from a crash.

(2) A soft target the kernel tries to hold you near under pressure.

(3) 1.5 CPU cores' worth of time. Exceeding it throttles, it does not kill: the container is simply not scheduled until the next period. This is the invisible failure — a service that is inexplicably slow, with normal CPU graphs, because the throttling only shows in a specific metric. Chapter 13.6.13 covers it in the Kubernetes context, where it bites hardest.

(4) Maximum processes. The one-line defence against a fork bomb.

(5) Restart policy. no (default), on-failure[:max], always, unless-stopped. unless-stopped is the right choice for a service: it restarts on failure and after a host reboot, but respects an explicit docker stop rather than bringing the container back when you deliberately stopped it.

And the trap from Chapter 13.6.2 repeats here: the runtime inside the container does not see these limits through the usual interfaces. /proc/meminfo reports the host's memory and the CPU count reports the host's cores. Runtimes and thread pools that size themselves from those numbers size for the wrong machine and then get killed. Modern JVMs and .NET read cgroup limits; many libraries still do not.

5. Configuration and secrets at run time

bash
docker run -e NODE_ENV=production -e PORT=3000 myapi     # (1)
docker run --env-file ./prod.env myapi                    # (2)
docker run -v ./secrets:/run/secrets:ro myapi             # (3)

(1) Individual variables. (2) A file of KEY=value lines. Not committed to the repository.(3) A read-only mount of secret files, which is better than environment variables for anything sensitive.

Why files beat environment variables for secrets: environment variables are inherited by every child process, appear in docker inspect, are often included in crash dumps and error reports, and can be read from /proc/<pid>/environ by anything else in the container. A file can be read once at startup and has permissions.

And one image across all environments, configured at run time. If you build a different image per environment, the thing you tested is not the thing you shipped (Chapter 13.7).

6. Logging

Log to standard output and standard error. Never to a file inside the container. The collector reads the stream; a log file inside a container is invisible to your tooling, lost when the container is removed, and fills the writable layer while it runs.

bash
docker logs -f --tail 100 --timestamps api

The default json-file driver has no size limit. A chatty container will fill the host disk over weeks and take down every other container on that machine. Fix it once, globally, in /etc/docker/daemon.json:

json
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

This is the most boring outage in this Part and it happens constantly.

A note that saves confusion later: with any driver other than json-file or journald, docker logs stops working, because the logs went somewhere else entirely. That is expected, not broken.

7. Security flags

bash
docker run \
  --user 10001:10001 \          # (1)
  --read-only \                 # (2)
  --tmpfs /tmp \                # (3)
  --cap-drop ALL \              # (4)
  --cap-add NET_BIND_SERVICE \  # (5)
  --security-opt no-new-privileges \  # (6)
  myapi

(1) Not root. Root inside a container is not root on the host, but it is a much better starting position for an attacker, and it is the first thing an escape needs.

(2) A read-only root filesystem. Code execution becomes an attacker who cannot install anything or persist anything.

(3) Somewhere writable for temporary files, in memory, since the root filesystem is now read-only.

(4) and (5) Drop every capability, then add back only what is needed. NET_BIND_SERVICE is the one for binding a port below 1024 — although running on port 3000 and publishing it as 80 avoids needing it at all.

(6) Blocks privilege escalation through setuid binaries.

And the flag to argue about: --privileged. It restores every capability, grants access to all host devices, and relaxes the seccomp profile. It removes most of the boundary. Nearly every real need is one specific --cap-add.

Never mount the Docker socket into a container (-v /var/run/docker.sock:/var/run/docker.sock). It is root on the host, granted to whatever runs in that container (Chapter 13.6.3).

8. Inspecting a running container

bash
docker ps -a                                        # (1)
docker inspect api | jq '.[0].State'                # (2)
docker inspect -f '{{.NetworkSettings.IPAddress}}' api
docker exec -it api sh                              # (3)
docker stats                                        # (4)
docker top api                                      # (5)
docker diff api                                     # (6)
docker cp api:/app/dump.json ./                     # (7)

(1) Status and exit codes, including stopped containers. (2) The state object holds the answer to most questions: ExitCode, OOMKilled, Error, StartedAt, FinishedAt. (3) A shell inside. -it gives an interactive terminal. (4) Live CPU, memory, network and disk per container. The memory column shows usage against the limit, which is how you catch a container approaching an OOM kill before it happens. (5) The processes inside, as seen from the host. (6) Every file changed since the container started — a fast way to find something writing where it should not. (7) Copy a file out, for a heap dump or a profile.

Exit codes worth recognising on sight:

CodeMeaning
0The process finished normally — the container's job was done
1Application error
125Docker itself failed — a bad flag
126The command was found but not executable
127Command not found — usually a typo or a missing binary in a slim base
137SIGKILLalmost always an OOM kill; check OOMKilled in inspect
139Segmentation fault
143SIGTERM — a normal stop

137 and 143 are the two you will see most, and telling them apart matters: 143 is a clean shutdown, 137 is the kernel killing you for memory or the grace period expiring.

What the interviewer will push on

"A container publishes a port but connections are refused. What is wrong?" The application is bound to 127.0.0.1 inside the container, which is a different loopback from the host's, so the forwarded traffic arrives where nothing is listening. Bind to 0.0.0.0. The good answer names the namespace as the reason rather than just reciting the fix.

"How do two containers talk to each other?" Put them on a user-defined network and use container names, because Docker runs an embedded DNS server on user-defined networks. The detail that matters is that the default bridge has no name resolution, which is why the first attempt with --link or raw IPs is usually what someone tried before asking.

"Volume or bind mount?" Named volumes for data — managed, portable, backed up as a unit, and fast on Mac and Windows because they live inside the Linux VM. Bind mounts for source code in development. Then the seeding difference: an empty named volume is populated from the image, a bind mount hides what was there.

"What is exit code 137?" SIGKILL. Nearly always the memory limit being exceeded, confirmed by OOMKilled in docker inspect. Distinguish it from 143, which is a clean SIGTERM shutdown. Adding that CPU limits throttle rather than kill is the answer that shows the mental model is complete.

"Why is -p 8080:3000 risky on a cloud VM?" It binds to every interface including the public one, and Docker's iptables rules are evaluated before most host firewall rules, so a firewall that looks correct does not stop it. Bind explicitly to 127.0.0.1 or to a private address.

"What does --network host buy and cost?" It removes the NAT hop and the extra namespace, so it is faster and lets an agent see the host's real interfaces. It costs all network isolation and creates port conflicts, and on Mac or Windows "the host" is a hidden VM, so it does not mean what people expect.

One thing to volunteer: mention that the default json-file log driver has no rotation, so one chatty container fills the host disk and takes every other container on that host with it. Two lines in daemon.json prevent it, and it is a failure that looks like a platform problem rather than a configuration one.

Recall

  • A container lives exactly as long as its foreground process. Exit 0 immediately means the main process finished — a start script that backgrounds the server ends the container.
  • docker stop = SIGTERM, wait 10 s, SIGKILL. docker kill skips the wait. exited still holds disk; use --rm for short-lived containers.
  • Use a user-defined network, always — the default bridge has no DNS by container name. Names resolve via an embedded server at 127.0.0.11; container IPs change, names do not.
  • -p host:container binds 0.0.0.0 by default, past most host firewalls. Bind to 127.0.0.1 explicitly. Publishing is an iptables DNAT rule, not a proxy.
  • Modes: bridge (default, veth pair into a software switch) · host (no namespace, no isolation) · none (loopback only, for untrusted code) · container:<name> shares one stack — this is what a Kubernetes pod is.
  • Named volume = Docker-managed, right for data, seeded from the image when empty, fast on Mac/Windows. Bind mount = host path, right for source, hides what was there, slow across the Desktop VM. tmpfs = memory only.
  • Memory over limit kills (137, OOMKilled); CPU over limit throttles invisibly. --pids-limit stops fork bombs. --restart=unless-stopped for services.
  • Secrets as mounted files, not environment variables — env vars are inherited by children, appear in docker inspect and in crash dumps. One image, configured at run time.
  • Set max-size on the log driver or a chatty container fills the host disk. Harden with --user, --read-only, --cap-drop ALL, no-new-privileges. Never mount the Docker socket.
  • Exit codes: 137 = killed, usually memory · 143 = clean SIGTERM · 127 = command not found · 125 = Docker itself.

Self-test: Why does a published port refuse connections when the app binds to loopback? · What does a user-defined network give you that the default bridge does not? · What is the difference in behaviour between an empty named volume and a bind mount over a populated path? · What distinguishes exit 137 from 143? · Which network mode is the same idea as a Kubernetes pod? · What fills a host's disk silently over weeks?

Next: 13.6.7 runs several containers at once — Compose key by key, why depends_on does not wait for readiness, and how one file gives you a local environment that matches production.