Docker Volumes and Networking Explained — Data, Containers, and Real-World Patterns
Every production system running Docker eventually hits the same two walls: 'Where did my data go?' and 'Why can't container A talk to container B?' These aren't beginner slip-ups — they're fundamental questions about how Docker deliberately isolates processes. Understanding volumes and networking isn't optional knowledge you can defer; it's the difference between a container setup that works in demos and one that survives production. Netflix, Shopify, and virtually every cloud-native company build their infrastructure on these exact primitives.
Docker was designed with a beautiful but initially confusing principle: containers are ephemeral. The moment a container stops, its internal filesystem is gone. That's intentional — it makes containers reproducible and stateless. But real applications need to write files: databases store records, apps generate logs, upload directories fill with user content. Volumes solve the persistence problem by mounting a storage location that lives outside the container's lifecycle. Networking solves the communication problem by giving containers a private, controllable channel to speak to each other and the outside world without dangerous exposure.
By the end of this article you'll be able to create named volumes and mount them correctly, build a custom bridge network so containers find each other by name (not fragile IP address), wire up a real Postgres + Node.js stack using both, and avoid the three mistakes that trip up almost every developer moving from 'docker run' experiments to actual deployments.
Why Container Filesystems Vanish — and How Volumes Fix It
When Docker builds a container it layers a thin writable layer on top of the image's read-only layers. Every file you create inside a running container lives in that writable layer. The second the container is removed with docker rm, that layer is deleted — permanently. This isn't a bug. It's what makes containers reproducible: you can spin up a thousand identical containers from the same image because none of them carry state from previous runs.
The problem is obvious the moment you run a database inside a container. You write a thousand rows, stop the container, restart it, and the table is empty. The fix is a Docker volume — a directory managed by Docker that exists on the host filesystem (or a remote storage backend) and is mounted into the container at a specified path. The container reads and writes to that path normally, but the data actually lives outside the container. Remove the container, the volume survives. Spin up a new container, mount the same volume, and all the data is exactly where you left it.
There are three volume types: named volumes (Docker manages the location — recommended for most cases), bind mounts (you specify an exact host path — great for development), and tmpfs mounts (in-memory only — useful for sensitive data you never want on disk). Named volumes are the production default because Docker handles permissions and the storage path is abstracted away from your machine's directory structure.
#!/bin/bash # ── STEP 1: Create a named volume Docker will manage ────────────────────────── docker volume create postgres_data # ── STEP 2: Inspect the volume to see where Docker actually stores it ───────── docker volume inspect postgres_data # Output shows the 'Mountpoint' on the host — usually /var/lib/docker/volumes/... # ── STEP 3: Run Postgres and mount our volume to its data directory ─────────── # The -v flag maps: <volume_name>:<path_inside_container> docker run -d \ --name postgres_primary \ -e POSTGRES_USER=appuser \ -e POSTGRES_PASSWORD=securepass123 \ -e POSTGRES_DB=appdb \ -v postgres_data:/var/lib/postgresql/data \ postgres:15-alpine # ── STEP 4: Write some data into the database ───────────────────────────────── docker exec -it postgres_primary psql -U appuser -d appdb \ -c "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);" docker exec -it postgres_primary psql -U appuser -d appdb \ -c "INSERT INTO users (name) VALUES ('Alice'), ('Bob');" # ── STEP 5: Destroy the container completely ────────────────────────────────── # Without a volume this would delete all our data. Watch what happens instead. docker rm -f postgres_primary # ── STEP 6: Start a BRAND NEW container using the SAME volume ───────────────── docker run -d \ --name postgres_restored \ -e POSTGRES_USER=appuser \ -e POSTGRES_PASSWORD=securepass123 \ -e POSTGRES_DB=appdb \ -v postgres_data:/var/lib/postgresql/data \ postgres:15-alpine # ── STEP 7: Prove the data survived the container deletion ──────────────────── docker exec -it postgres_restored psql -U appuser -d appdb \ -c "SELECT * FROM users;" # ── CLEANUP (optional — only run when you're done experimenting) ────────────── # docker rm -f postgres_restored && docker volume rm postgres_data
postgres_data
$ docker volume inspect postgres_data
[
{
"CreatedAt": "2024-01-15T10:23:11Z",
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/postgres_data/_data",
"Name": "postgres_data",
"Scope": "local"
}
]
$ docker exec -it postgres_restored psql -U appuser -d appdb -c "SELECT * FROM users;"
id | name
----+-------
1 | Alice
2 | Bob
(2 rows)
# Data survived the container being completely deleted and recreated ✓
Docker Networking — How Containers Find Each Other Without Hard-Coded IPs
By default, every container gets its own network namespace — a private stack with its own interfaces, routing tables, and firewall rules. Containers are isolated from each other unless you explicitly connect them. Docker ships with three built-in network drivers you'll encounter constantly: bridge (the default for single-host setups), host (container shares the host's network stack — fast but removes isolation), and none (completely air-gapped — useful for batch jobs that need zero network access).
The default bridge network (bridge) has a nasty limitation: containers on it can talk to each other by IP, but not by name. IPs are assigned dynamically — restart a container and it might get a different IP. Hardcoding IPs in config files is a recipe for 3 AM incidents. The fix is creating a user-defined bridge network. On a user-defined network, Docker runs an embedded DNS server that resolves container names automatically. Container A can reach container B simply by using b as the hostname. This is the pattern every real Docker Compose setup uses under the hood.
Ports are a separate concept. By default a container's ports are not accessible from outside the Docker network. You publish a port with -p , which tells Docker to forward traffic from the host machine's port to the container's internal port. You want to be surgical here — only publish the ports that genuinely need outside access.
#!/bin/bash # ── STEP 1: Create a user-defined bridge network ────────────────────────────── # This is what enables DNS-based container discovery docker network create \ --driver bridge \ --subnet 172.28.0.0/16 \ app_internal_network # ── STEP 2: Start a Redis cache container on our custom network ─────────────── # Notice: we do NOT publish Redis's port to the host (no -p flag) # Only containers on app_internal_network can reach it — that's intentional docker run -d \ --name redis_cache \ --network app_internal_network \ redis:7-alpine # ── STEP 3: Start an API container on the same network ─────────────────────── # It can reach Redis using the hostname 'redis_cache' — not an IP address docker run -d \ --name api_server \ --network app_internal_network \ -p 3000:3000 \ -e REDIS_HOST=redis_cache \ -e REDIS_PORT=6379 \ node:20-alpine \ sh -c "npm install -g redis-cli && sleep infinity" # ── STEP 4: Prove DNS resolution works inside the network ───────────────────── # From inside api_server, ping redis_cache by NAME (not IP) docker exec api_server ping -c 3 redis_cache # ── STEP 5: Inspect the network to see both containers connected ────────────── docker network inspect app_internal_network \ --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}' # ── STEP 6: Verify Redis is NOT reachable from your host machine directly ───── # This should fail — Redis is internal only, which is exactly what we want curl -v telnet://localhost:6379 2>&1 | head -5 # ── CLEANUP ─────────────────────────────────────────────────────────────────── # docker rm -f redis_cache api_server && docker network rm app_internal_network
a7f3c819e0b2d45f8a1e2c3d4b5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3
$ docker exec api_server ping -c 3 redis_cache
PING redis_cache (172.28.0.2): 56 data bytes
64 bytes from 172.28.0.2: icmp_seq=0 ttl=64 time=0.112 ms
64 bytes from 172.28.0.2: icmp_seq=1 ttl=64 time=0.098 ms
64 bytes from 172.28.0.2: icmp_seq=2 ttl=64 time=0.104 ms
--- redis_cache ping statistics ---
3 packets transmitted, 3 received, 0% packet loss
$ docker network inspect app_internal_network --format '...'
redis_cache: 172.28.0.2/16
api_server: 172.28.0.3/16
$ curl -v telnet://localhost:6379 2>&1 | head -5
* Trying 127.0.0.1:6379...
* connect to 127.0.0.1 port 6379 failed: Connection refused
# ✓ Redis correctly unreachable from host — only accessible inside the network
Putting It All Together — A Real Postgres + Node API Stack
Knowing volumes and networking in isolation is like knowing how to swing a hammer and drive a nail separately — useful only when combined. Let's wire up a realistic two-container stack: a Node.js API that reads and writes to a Postgres database. The database data will persist in a named volume so it survives restarts. Both containers will live on a private bridge network so they find each other by hostname. Only the API will be exposed to the outside world.
This pattern is exactly what Docker Compose generates for you behind the scenes. Understanding the raw commands first means you'll never be confused by what Compose is actually doing, and you'll be able to debug it when the abstraction leaks — which it will.
The key insight here is the startup order problem. Your Node.js app will crash on startup if Postgres isn't ready to accept connections yet. Docker doesn't automatically wait for a container's process to be healthy — it just starts them in order. You handle this with a health check on the Postgres container and a retry loop or wait-for-it script on the Node side. We'll show both approaches in the Compose file below because one of them is always the right answer depending on your situation.
# docker-compose.production.yml # Runs a Node.js API backed by Postgres with persistent storage # and a private network — no exposed database ports version: "3.9" services: # ── Postgres Database ───────────────────────────────────────────────────── database: image: postgres:15-alpine container_name: postgres_primary restart: unless-stopped # Restart on crash, but respect manual stops environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: securepass123 POSTGRES_DB: appdb volumes: # Named volume keeps data safe across container replacements - postgres_data:/var/lib/postgresql/data # Seed script runs once on first volume initialization - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro networks: - backend_network healthcheck: # pg_isready exits 0 when Postgres is accepting connections test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"] interval: 10s # Check every 10 seconds timeout: 5s # Fail check if no response in 5s retries: 5 # Mark unhealthy after 5 failures start_period: 30s # Grace period before checks start # NO ports: section — database is intentionally unreachable from host # ── Node.js REST API ────────────────────────────────────────────────────── api: build: context: ./api dockerfile: Dockerfile container_name: node_api restart: unless-stopped ports: - "3000:3000" # Only the API is exposed to the outside world environment: # Uses the service name 'database' as the hostname — Docker DNS resolves it DATABASE_URL: postgresql://appuser:securepass123@database:5432/appdb NODE_ENV: production PORT: 3000 volumes: # Bind mount for uploaded files — you want these on the host for backups - ./uploads:/app/uploads networks: - backend_network depends_on: database: # 'service_healthy' waits for the healthcheck to pass — not just container start condition: service_healthy # ── Named Volumes ───────────────────────────────────────────────────────────── # Declared here so Docker Compose manages their lifecycle volumes: postgres_data: driver: local # Optional: add 'external: true' if this volume is managed outside Compose # ── Networks ────────────────────────────────────────────────────────────────── networks: backend_network: driver: bridge # ipam config is optional but useful when you need predictable subnets ipam: config: - subnet: 172.29.0.0/16
[+] Running 4/4
✔ Network app_backend_network Created 0.1s
✔ Volume postgres_data Created 0.1s
✔ Container postgres_primary Healthy 18.3s
✔ Container node_api Started 18.5s
# Notice: postgres_primary reaches 'Healthy' BEFORE node_api starts
# That's the healthcheck + depends_on working as intended
$ docker compose ps
NAME IMAGE STATUS
postgres_primary postgres:15-alpine Up 25 seconds (healthy)
node_api api-api Up 7 seconds
$ curl http://localhost:3000/health
{"status":"ok","database":"connected","uptime":12.4}
| Feature / Aspect | Named Volume | Bind Mount |
|---|---|---|
| Storage location | Managed by Docker under /var/lib/docker/volumes/ | Exact host path you specify (e.g. ./data) |
| Best for | Production databases, stateful services | Development — live code reload, local config files |
| Portability | Portable — works the same on any Docker host | Not portable — depends on specific host directory structure |
| Permissions | Docker sets up ownership — usually just works | Inherits host filesystem permissions — frequent source of errors |
| Performance (Linux) | Native — fastest option | Native on Linux, slower on Mac/Windows due to file-sync overhead |
| Backups | docker volume export or third-party drivers | Standard filesystem backup tools work directly |
| Survives docker rm | Yes — must explicitly run docker volume rm | Yes — host directory is never touched by Docker rm |
| Inspect storage location | docker volume inspect | You already know it — you specified it |
🎯 Key Takeaways
- Named volumes are managed by Docker and are the right default for production data — they survive container deletion and work the same regardless of host directory structure.
- The default
bridgenetwork does NOT support DNS — always create a user-defined network if you want containers to find each other by service name instead of fragile dynamic IPs. - Only publish (
-p) the ports that need external access. Databases and caches should live on internal networks with zero host-side port mappings — this is a security requirement, not a nicety. depends_onalone is not enough for startup ordering — combine it withcondition: service_healthyand a properhealthcheckblock to guarantee your app doesn't crash into an unready database.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Using the default bridge network and expecting DNS resolution — Symptom: your app crashes with 'ECONNREFUSED redis:6379' or 'could not translate host name database' even though both containers are running — Fix: create a user-defined bridge network with
docker network create app_netand attach both containers to it using--network app_net. Docker's embedded DNS only works on user-defined networks, not the defaultbridgenetwork. - ✕Mistake 2: Mounting a named volume over a directory the image already populated — Symptom: your Postgres container starts but the database directory appears empty and init scripts don't run, because Docker copies image data into a volume only on first creation, and if you attached an existing non-empty volume from a different image it may have conflicting contents — Fix: if you need a fresh start, run
docker volume rmfirst (after backing up if needed), or use a different volume name for each major version upgrade. - ✕Mistake 3: Publishing every container port to the host for 'easier debugging' — Symptom: your database port (5432, 3306, 27017) is exposed on 0.0.0.0, which means anyone who can reach your server can attempt connections — Fix: only publish ports that genuinely need external access (your API, your web server). Keep databases, caches, and message queues on a private network with no
-pmapping. If you need to connect locally for debugging, usedocker exec -itinstead.psql
Interview Questions on This Topic
- QWhat's the difference between a Docker named volume and a bind mount, and when would you choose one over the other in a production environment?
- QExplain why two containers on the default bridge network can't reach each other by hostname, but containers on a user-defined bridge network can. What is Docker doing differently under the hood?
- QIf your docker-compose.yml has `depends_on: - database` for your API service, does that guarantee the database is accepting connections before the API starts? If not, how do you actually guarantee it?
Frequently Asked Questions
How do I back up a Docker named volume?
Spin up a temporary container that mounts both your volume and a host directory, then use tar to archive it: docker run --rm -v postgres_data:/source -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz -C /source .. This works without stopping your running container for most databases, though for full consistency you should quiesce writes or use the database's own dump tool like pg_dump.
Can two Docker containers share the same volume at the same time?
Yes — multiple containers can mount the same named volume simultaneously. For read-only shared config or static assets this works fine. For databases or anything with concurrent writes, you're responsible for handling write contention — Docker itself provides no locking. Databases like Postgres handle this internally, which is why you should never run two Postgres containers writing to the same data directory volume.
What happens to my volumes when I run docker compose down?
docker compose down stops and removes containers and networks, but it deliberately leaves volumes untouched — your data is safe. To also delete volumes you must add the -v flag: docker compose down -v. This is a common point of confusion; people expect the data to be gone and are surprised it isn't, or they accidentally nuke it with -v not realising what that flag does.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.