Docker 05 — Data & Networking
In Docker 04 you learned that docker rm deletes a container. Here’s the part that bites everyone exactly once: it also deletes everything the container wrote. Run Postgres in a container, save your SplitEase expenses, docker rm the container — and your data is gone, permanently. This module is the fix: where state actually lives (volumes), and how two containers find each other to talk (networking) so SplitEase can reach db:5432 instead of localhost.
The Goal
By the end of this module you can:
- Explain why a container’s writable layer is the wrong place for any data you care about
- Persist a Postgres database across
docker rmwith a named volume — and prove the data survived - Choose between a named volume (databases, prod) and a bind mount (live-editing code in dev) without guessing
- Connect two containers by service name on a user-defined network — the reason SplitEase talks to
db, notlocalhost - Distinguish published ports (
-p, host→container) from inter-container traffic (same network, no-pneeded) - Reach the host machine from inside a container on Docker Desktop with
host.docker.internal
The Lesson
This module has two halves that solve two different “where” problems: where does my data live and where does my traffic go. Do them in order — the networking half assumes you’ve stopped losing data.
Part A — Data: containers forget everything
The writable layer dies with the container
Recall the lifecycle from Docker 04: an image is read-only, and when you docker run it, Docker stacks a thin writable layer on top. Every byte the process writes — log files, an uploaded receipt, a whole Postgres database — lands in that layer. It survives stop and start. It does not survive rm.
Watch it happen. This visualizer walks an image → created → running → stopped → removed, and shows exactly what the writable layer holds at each step:
The last step is the whole reason this module exists. rm deletes the container and its writable layer. If your database lived inside that layer, it’s gone — no recycle bin, no undo.
So the rule is blunt: state must live OUTSIDE the container. Docker gives you three doors to the outside. You’ll use two of them constantly.
The three options
| Type | What it is | Lives where | Use it for |
|---|---|---|---|
| Named volume | Docker-managed storage with a name | Inside Docker’s own area, off your filesystem | Databases, anything that must persist. The right default. |
| Bind mount | A folder on YOUR machine mapped into the container | Wherever you point it on the host | Live-editing source code in dev — edit on host, container sees it instantly |
| tmpfs | A scratchpad in RAM | Memory only, never disk | Secrets/temp data you want to vanish when the container stops |
All three use the same -v (or --mount) flag. The difference is the left side of the colon.
Named volumes — the right default for databases
A named volume is storage Docker creates and manages for you. You give it a name; Docker decides where the bytes actually sit. It is not tied to any one container — delete the container, the volume stays, and the next container that mounts it picks up right where the last one left off.
docker volume create pgdata
docker run -d --name splitease-db \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
Read the -v left-to-right: pgdata (the volume name) is mounted at /var/lib/postgresql/data (the exact directory Postgres writes its database files to). Now Postgres thinks it’s writing to a normal folder — but those bytes land in the volume, outside the container’s mortal writable layer.
docker rm splitease-db now? The container dies, pgdata lives. Mount it into a fresh Postgres container and every expense is still there. That’s the entire point.
Why this matters in a real job: the single most common “my data disappeared” support ticket is a Postgres or MySQL container run without a volume. In production you always name the volume so a redeploy (which replaces the container) never touches the data. Forgetting this once, on real customer data, is the kind of mistake you make exactly one time.
Bind mounts — your code, live, in the container
A bind mount maps a real folder on your machine straight into the container. The container reads and writes the same files you see in your editor. Change a file on the host, the container sees it immediately — no rebuild.
# from your SplitEase project folder
docker run -it --rm \
-v "$(pwd):/app" \
-w /app \
maven:3.9-eclipse-temurin-21 \
mvn -q compile
$(pwd) is your current host folder; it’s mounted at /app inside the container, and -w /app makes that the working directory. The container compiles your live source. This is the dev superpower: a throwaway container with the exact JDK/Maven you need, operating on files that never leave your laptop.
The trade-off vs a volume:
| Named volume | Bind mount | |
|---|---|---|
| Who manages the path | Docker | You (an explicit host path) |
| Portable across machines | Yes | No — depends on your folder layout |
| Best for | Databases, persistent state | Live-editing code in dev |
| Surprise factor | Low | The host folder overlays whatever was in that container path |
That last row is a classic trap: bind-mount your project over /app and you hide anything the image baked into /app. Great when you mean it, confusing when you don’t.
tmpfs — the throwaway scratchpad
A tmpfs mount lives in RAM and never touches disk. It vanishes when the container stops. Use it for a decrypted secret or a hot temp file you actively don’t want persisted:
docker run --tmpfs /tmp:size=64m alpine sh -c "echo gone-on-stop > /tmp/x"
That’s the whole story — you’ll reach for it rarely, but knowing it exists keeps you from abusing a volume for things that shouldn’t survive.
Part B — Networking: how containers find each other
The localhost trap
Here’s the mistake every beginner makes. Your SplitEase app runs in one container, Postgres in another. The app config says jdbc:postgresql://localhost:5432/splitease. It fails: Connection refused.
Why? Inside a container, localhost means this container itself — its own private network namespace (remember namespaces from Docker 01). The app looks for Postgres inside its own container, finds nothing, and gives up. Postgres is a different container with a different localhost. They are two separate machines as far as the network is concerned.
The fix isn’t an IP address (container IPs change every restart). The fix is a user-defined network, where containers find each other by name.
User-defined bridge networks + service DNS
Create a network and attach both containers to it. Docker runs a tiny built-in DNS server on that network: every container is reachable by its --name as a hostname.
docker network create splitease-net
docker run -d --name db --network splitease-net \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
docker run -d --name app --network splitease-net \
-e DB_URL=jdbc:postgresql://db:5432/postgres \
splitease-api
Now the app connects to db:5432 — not an IP, not localhost, just the name of the other container. Docker’s DNS resolves db to whatever IP that container currently has. This is exactly why your application.yml for Dockerised SplitEase says host: db: db is the container name, and the network turns that name into a working address.
flowchart LR
H["Host laptop"] -->|"-p 8080:8080"| A["app container"]
A -->|"db:5432 over the network"| D["db container"]
subgraph net ["splitease-net bridge"]
A
D
end
The app talks to db directly across the bridge. No -p was needed between them — they’re on the same network, so they talk freely. The only -p in the picture is the one publishing the app to the host.
Published ports vs inter-container traffic
This is the distinction that confuses people for weeks, so make it crisp:
Published port (-p 8080:8080) | Inter-container traffic | |
|---|---|---|
| Direction | Host → container | Container → container |
| Who uses it | You, your browser, the outside world | Your other containers |
Needs -p? | Yes — it pokes a hole from host into the container | No — same network = already connected |
| SplitEase example | You hit localhost:8080 in your browser | app reaches db:5432 |
So -p is only for letting the host (or the internet) reach into a container. Postgres needs no -p for the app to use it — they share splitease-net. In fact, not publishing Postgres is good practice: it means the database is reachable by your app but not exposed to your whole laptop’s network. (The default bridge network is the exception — containers on it can’t resolve each other by name. That’s why you always create your own network. Compose, in the next module, does this for you automatically.)
Reaching the host from inside a container (Docker Desktop)
Sometimes you need the reverse: a container that wants to reach a service running on your host — say a Postgres you installed natively on Windows, or another dev server. From inside the container, localhost won’t work (it means the container). On Docker Desktop (Windows/Mac), Docker provides a special hostname:
docker run --rm curlimages/curl curl http://host.docker.internal:5432
host.docker.internal resolves to your host machine from inside the container. It’s a Docker Desktop convenience — on plain Linux you’d use the host’s actual IP or add --add-host. You’ll hit this the day you try to connect a container to a tool running on Windows directly.
Build This
Two drills on the real SplitEase database. First prove data loss, then prove persistence. Then prove service-name DNS. Run every command and read the output — the point is to see the difference with your own eyes.
Drill 1 — lose data (the lesson you only need once):
# start postgres with NO volume
docker run -d --name pg-temp -e POSTGRES_PASSWORD=secret postgres:16
# write a row
docker exec pg-temp psql -U postgres -c "CREATE TABLE expense(amt bigint);"
docker exec pg-temp psql -U postgres -c "INSERT INTO expense VALUES (45000);"
docker exec pg-temp psql -U postgres -c "SELECT * FROM expense;" # shows 45000
# destroy the container
docker rm -f pg-temp
# bring postgres back the same way and look
docker run -d --name pg-temp -e POSTGRES_PASSWORD=secret postgres:16
docker exec pg-temp psql -U postgres -c "SELECT * FROM expense;"
Expected: the last command errors with relation "expense" does not exist. The table — and the 45000 paise — died with the container. Clean up: docker rm -f pg-temp.
Drill 2 — survive rm with a named volume:
docker volume create pgdata
docker run -d --name pg-keep -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:16
docker exec pg-keep psql -U postgres -c "CREATE TABLE expense(amt bigint);"
docker exec pg-keep psql -U postgres -c "INSERT INTO expense VALUES (45000);"
# destroy the container
docker rm -f pg-keep
# new container, SAME volume
docker run -d --name pg-keep -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:16
docker exec pg-keep psql -U postgres -c "SELECT * FROM expense;"
Expected: the last command prints 45000. Same rm, opposite outcome — because the data lived in pgdata, not the container. That single difference is the whole module.
Drill 3 — talk by name across a network:
docker network create splitease-net
docker run -d --name db --network splitease-net \
-e POSTGRES_PASSWORD=secret postgres:16
# a throwaway container on the SAME network pings the db BY NAME
docker run --rm --network splitease-net postgres:16 \
psql "postgresql://postgres:secret@db:5432/postgres" -c "SELECT 1;"
Expected: it connects and prints 1. You never used an IP — db resolved to the Postgres container because they share splitease-net. Now try it without --network splitease-net on the second command: it fails to resolve db. That failure is the localhost trap, proven.
Clean up: docker rm -f db; docker network rm splitease-net; docker volume rm pgdata
Definition of done: you can explain, in one sentence each, (1) why a named volume survives rm but the writable layer doesn’t, (2) when you’d pick a bind mount over a volume, and (3) why app can reach db:5432 with no -p between them.
Where to Practice
| What | Where | Time |
|---|---|---|
| Run the three drills above on your own machine | Your Docker Desktop | 30 min |
| Volumes + networks with zero local setup | Play with Docker | 20 min |
| Official volumes walkthrough | docs.docker.com — Manage data in Docker | 15 min |
| Official networking walkthrough | docs.docker.com — Networking overview | 15 min |
| Hands-on volume/network labs (free tier) | KodeKloud free Docker labs | 30 min |
How to Practice
Do Drill 1 and Drill 2 back-to-back, out loud: narrate why one loses data and the other doesn’t as you run them. The contrast is what sticks. Then break Drill 3 on purpose — drop the --network flag and watch db fail to resolve — because seeing the error makes “service-name DNS” a real thing instead of a phrase. Re-run the whole sequence cold on day 3 and day 7 with no notes; if you can rebuild it from memory, you own it.
Check Yourself
1. You run Postgres with no volume, write data, then `docker rm` it. Where did the data go?
Into the container’s writable layer — which rm deletes along with the container. The data is gone permanently. That layer only ever survives stop/start, never rm.
2. What's the one-line difference between a named volume and a bind mount?
A named volume is managed by Docker (you give a name, Docker owns the location) and is the default for databases; a bind mount maps a specific folder from your host into the container and is for live-editing code in dev.
3. Your app config says `localhost:5432` and can't reach Postgres. Why?
Inside a container, localhost means that container itself. Postgres is a different container with its own localhost. You need both on a user-defined network and must connect to Postgres by its container name (db:5432), not localhost.
4. How does the name `db` turn into a working address?
On a user-defined network, Docker runs a built-in DNS server that resolves each container’s --name to its current IP. So db resolves to the Postgres container automatically, even though its IP changes between restarts.
5. Does Postgres need `-p 5432:5432` for the app container to use it?
No. -p publishes a port from the host into a container. Two containers on the same network already reach each other directly. You’d only add -p to Postgres if you wanted to connect from your laptop — and usually you deliberately don’t, to keep the DB unexposed.
6. What does `-p 8080:8080` actually do, and in which direction?
It publishes the container’s port 8080 to the host’s port 8080 — direction is host → container. It’s what lets your browser hit localhost:8080 to reach the app. It has nothing to do with container-to-container traffic.
7. A container needs to reach a service running natively on your Windows host. What hostname do you use?
host.docker.internal — a Docker Desktop hostname that resolves to your host machine from inside a container. localhost won’t work because it means the container itself.
8. Why is the default `bridge` network not good enough, so you always `docker network create` your own?
On the default bridge network, containers can’t resolve each other by name — only on a user-defined network does Docker’s DNS map container names to IPs. So you create your own network (or let Compose do it) to get service-name DNS.
Still Unclear?
Copy any of these into Claude to go deeper:
- “Explain Docker named volumes vs bind mounts vs tmpfs with one concrete example each, and tell me which a Postgres container in production should use and why.”
- “I run two containers, an app and Postgres, and the app gets ‘Connection refused’ on localhost:5432. Walk me through exactly why, and the docker commands to fix it with a user-defined network.”
- “On Docker Desktop for Windows, my container can’t reach a Postgres running natively on my host. Explain host.docker.internal and show the connection string.”
Why AI Can’t Do This For You
AI will happily generate a docker run with a volume — but it can’t feel the gut-punch of losing real data to docker rm, which is the only thing that makes you never forget the volume again. And when SplitEase throws “Connection refused” at 1 a.m., no prompt knows whether your problem is the wrong hostname, a missing network, an unpublished port, or a firewall — that triage is a judgment you build by breaking it yourself in the drills above. The commands are cheap; the instinct for where the data lives and where the traffic goes is the part you have to earn.
Module done? Add it to today’s tracker