Lesson 03 of 13 · Level 1 — Containers & images
Networking & storage
Containers need to talk to each other and to keep data after they're replaced. Learn bridge networks and built-in DNS, what -p really does, and when to use a named volume, a bind mount or tmpfs.
Networks: how containers find each other
Docker creates a Linux bridge (docker0) and gives each container a virtual interface and a private IP (by default from 172.17.0.0/16). Containers reach the internet through NAT on the host.
The important detail: create your own network. On a user-defined bridge network, Docker runs an embedded DNS server, so containers find each other by name. On the old default bridge they can't.
$ docker network create shopnet
$ docker run -d --name db --network shopnet -e POSTGRES_PASSWORD=devonly postgres:16
$ docker run --rm --network shopnet alpine:3.20 nslookup db
Name: db
Address: 172.18.0.2
$ docker run --rm --network shopnet postgres:16 pg_isready -h db
db:5432 - accepting connections
Your app's connection string becomes postgres://db:5432/...: no IP addresses anywhere. Docker Compose (next level) creates a network like this for every project automatically.
A network is an office floor with its own internal phone directory. Anyone on the floor can dial "db" and reach the database's desk. Someone on a different floor, or out on the street, can't, unless you open a door to the outside (publish a port).
Publishing ports
Containers are unreachable from outside the host until you publish a port:
$ docker run -d --name web -p 8080:80 nginx:1.27 # host 8080 -> container 80, all interfaces
$ docker run -d --name admin -p 127.0.0.1:9000:80 nginx:1.27 # only from the host itself
$ docker port web
80/tcp -> 0.0.0.0:8080
EXPOSE in a Dockerfile is documentation only. It doesn't publish anything.
Published ports can bypass your host firewall
Docker writes its own iptables rules for published ports, and on many setups those rules are evaluated before ufw or firewalld rules. A database started with -p 5432:5432 on a cloud VM can end up reachable from the internet even though ufw says the port is closed. Publish only what must be public, bind internal tools to 127.0.0.1, and keep databases on an internal network with no -p at all.
| Network mode | Isolation | Use for |
|---|---|---|
| user-defined bridge | Own IP, DNS by name | Almost everything on a single host |
| host | None: shares the host's interfaces | Network tools, very high packet rates |
| none | No network at all | Batch jobs that must not talk to anything |
| overlay / macvlan | Multi-host / appears on the physical LAN | Swarm clusters / legacy apps that need a LAN IP |
Exposing an app to other machines
"It works on my machine" and "my colleague can open it" are different things. Three layers must all allow the traffic:
| Layer | What to check | How |
|---|---|---|
| 1. Publish | The container port is published on an address others can reach | docker port web; ss -ltnp shows 0.0.0.0:8080, not 127.0.0.1:8080 |
| 2. Firewall | The host firewall and, on a cloud VM, the security group / network ACL allow the port | sudo ufw status, sudo firewall-cmd --list-ports, Windows Firewall, cloud console |
| 3. Route | The other machine can reach the host's IP at all | ping / curl from that machine; VPN, subnet, NAT |
$ docker run -d --name web -p 8080:80 nginx:1.27 # 1. publish on all interfaces
$ ss -ltnp | grep 8080
LISTEN 0 4096 0.0.0.0:8080 0.0.0.0:* users:(("docker-proxy",...))
$ sudo ufw allow 8080/tcp # 2a. Ubuntu with ufw
$ sudo firewall-cmd --permanent --add-port=8080/tcp && sudo firewall-cmd --reload # 2b. RHEL family
$ hostname -I # 3. the address to give your colleague
192.168.56.20 172.17.0.1
From the other machine: curl -I http://192.168.56.20:8080 should return HTTP/1.1 200 OK. Test from outside, not from the host itself: the host can reach its own published ports even when the firewall blocks everyone else.
Work through it in order when it fails:
- Does it work inside the network?
docker run --rm --network <net> curlimages/curl http://web:80(tests the app itself). - Does it work on the host?
curl localhost:8080(tests publishing). If not, checkdocker portand that the app listens on0.0.0.0inside the container, not127.0.0.1. - Does it work from another machine? If not, it's the firewall, the security group or routing.
Public traffic: publish on localhost and put HTTPS in front
For anything on the internet, don't publish app ports on 0.0.0.0 at all. Publish them on 127.0.0.1 and put a reverse proxy with a TLS certificate on ports 80/443 in front (nginx, Caddy or Traefik, on the host or as a container on the same network), or use a cloud load balancer. You get HTTPS, one entry point for many apps, and no accidental exposure through Docker's firewall rules. The two practical projects at the end of this track use exactly this front-end/back-end split.
Storage: data that survives
Anything a container writes goes into its writable layer, which disappears with docker rm. For data that must survive, mount storage:
| Type | Syntax | Lives in | Use for |
|---|---|---|---|
| Named volume | -v pgdata:/var/lib/postgresql/data |
/var/lib/docker/volumes/ (managed by Docker) |
Databases and app data |
| Bind mount | -v /srv/app/conf:/etc/app:ro |
Exactly that host path | Config files, source code in development |
| tmpfs | --mount type=tmpfs,target=/tmp |
RAM, gone on stop | Scratch space, sensitive temp files |
$ docker volume create pgdata
$ docker run -d --name db --network shopnet -e POSTGRES_PASSWORD=devonly \
-v pgdata:/var/lib/postgresql/data postgres:16
$ docker rm -f db # container gone
$ docker run -d --name db --network shopnet -e POSTGRES_PASSWORD=devonly \
-v pgdata:/var/lib/postgresql/data postgres:16 # data still there
The longer --mount syntax does the same and is clearer in scripts: --mount type=volume,source=pgdata,target=/var/lib/postgresql/data.
Bind-mount permissions
A bind mount keeps the host's file ownership. If the container runs as UID 10001 and the host folder belongs to UID 1000, the app gets "permission denied". Fix ownership on the host (chown 10001 /srv/app/data) rather than running the container as root. On SELinux hosts (RHEL family), add :z or :Z to the mount so the files get the right label.
Try it: prove where data lives
docker run --rm -it --name t1 alpine:3.20 sh -c 'echo hello > /note; cat /note', then run the same image again andcat /note: it's gone.docker volume create notesand repeat with-v notes:/data, writing to/data/note. Start a fresh container with the same volume and read it back.docker volume inspect notesshows theMountpointon the host.- Back it up with the tar command from the cheat sheet, then
docker volume rm notes.
Going deeper: address pools and overlapping subnets
Docker picks bridge subnets from 172.17.0.0/16 onwards (and later from 192.168.x.0/20 ranges). If your office VPN, a VPC or a customer network uses the same ranges, hosts suddenly can't reach part of the network once a Compose project creates a new bridge. Set bip and default-address-pools in daemon.json to ranges you know are free; the daemon.json lesson in Level 4 shows how.
Recap
- Create a user-defined network; containers on it reach each other by name.
-p host:containerpublishes a port on all interfaces unless you give an IP. Keep databases unpublished.- Exposing to other machines = publish + firewall/security group + route. Test from the other machine, and put HTTPS in front of anything public.
- Named volumes for data, bind mounts for host files and config, tmpfs for scratch.
- The writable layer dies with the container. Anything important goes in a volume.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.