Docker Volumes and Networking Explained — Data, Containers, and Real-World Patterns
- 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.
- Containers are ephemeral — their writable layer is deleted on docker rm
- Named volumes survive container deletion and are managed by Docker
- Bind mounts map a host directory into the container — good for development
- tmpfs mounts store data in memory only — never touches disk
- Default bridge network has NO DNS resolution — containers cannot reach each other by name
- User-defined bridge network provides embedded DNS — containers resolve each other by service name
- Only publish (-p) ports that need external access — databases should never be exposed to the host
Container data disappeared after restart.
docker volume lsdocker volume inspect <volume-name>Container A cannot reach Container B by hostname.
docker network inspect <network-name> --format '{{range .Containers}}{{.Name}} {{end}}'docker exec <container-a> nslookup <container-b-name>Published port is unreachable from host.
docker port <container>docker exec <container> ss -tlnpPermission denied writing to volume mount.
docker exec <container> iddocker exec <container> ls -la <mount-path>docker compose down -v deleted production volumes.
docker volume ls | grep <project>ls -la ./backups/ /var/backups/docker/Container-to-container latency is high on overlay network.
docker exec <container> ping -c 10 <target-container>docker network inspect <network> --format '{{.Driver}}'Production Incident
Production Debug GuideFrom missing data to unreachable containers — systematic debugging paths.
Two problems surface immediately when running containers in production: data disappears when containers stop, and containers cannot find each other by name. These are not bugs — they are deliberate design decisions. Containers are ephemeral by default, and network isolation is the security baseline.
Volumes solve persistence by mounting a storage location that lives outside the container's lifecycle. Named volumes are managed by Docker and are the production default. Bind mounts map exact host paths and are best for development. tmpfs mounts store data in memory for sensitive data that should never touch disk.
Networking solves connectivity by providing isolated channels between containers. The default bridge network has no DNS resolution — containers can only reach each other by dynamic IP, which changes on restart. User-defined bridge networks provide embedded DNS, allowing containers to resolve each other by service name. This is the foundation of every Docker Compose deployment.
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.
Volume driver ecosystem: The default local driver stores data on the host filesystem. For production, consider volume drivers that provide redundancy and remote storage: rexray (AWS EBS, GCE PD), portworx (multi-cloud), longhorn (Kubernetes-native), or NFS for shared access across nodes. These drivers handle replication, snapshots, and failover — capabilities the local driver lacks.
Failure scenario — volume driver mismatch on Swarm migration: A team migrated from a single-host Docker setup to Docker Swarm. Their named volumes used the local driver, which stores data on the host where the volume was created. When Swarm rescheduled the database container to a different node, the volume was not available — it existed only on the original node. The database started with an empty data directory. The fix: use a volume driver that supports multi-host storage (NFS, rexray, or portworx) so volumes are accessible from any node.
#!/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;" # ── BACKUP a named volume ───────────────────────────────────────────────────── # Run a temporary container that mounts the volume and creates a tar archive docker run --rm \ -v postgres_data:/source:ro \ -v $(pwd)/backups:/backup \ alpine tar czf /backup/postgres_data_$(date +%Y%m%d).tar.gz -C /source . # The :ro flag makes the volume read-only inside this container # ── RESTORE a named volume from backup ──────────────────────────────────────── # Stop the container using the volume first docker stop postgres_primary # Extract the backup into the volume docker run --rm \ -v postgres_data:/target \ -v $(pwd)/backups:/backup:ro \ alpine tar xzf /backup/postgres_data_20260405.tar.gz -C /target # Restart the container docker start postgres_primary # ── 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 ✓
- The container's writable layer is part of the container's lifecycle — it exists only while the container exists.
- A volume is a separate filesystem object managed by Docker. Its lifecycle is independent of any container.
- This separation is intentional: it makes containers reproducible (no leftover state) while allowing persistent data where needed.
- The trade-off: you must explicitly manage volume lifecycle (backups, cleanup) or orphaned volumes accumulate.
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 <host_port>:<container_port>, 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.
Network isolation as a security boundary: User-defined networks provide network isolation by default. Containers on different user-defined networks cannot communicate unless explicitly connected to both. This is a security feature, not a limitation. Use it to separate frontend containers from backend databases — the frontend network has no route to the database network.
The host driver trade-off: The host driver (--network host) removes network isolation entirely. The container shares the host's network stack, so it can bind to any host port and access any host network interface. This provides the best performance (no NAT, no virtual bridge) but the worst security. Use it only for performance-critical services that need raw network access, and only on dedicated hosts.
Failure scenario — default bridge DNS confusion: A developer ran two containers on the default bridge network and tried to connect the API container to the database using the hostname 'postgres_db'. The connection failed with 'could not translate host name'. The developer spent 2 hours debugging DNS, firewall rules, and container networking. The root cause: the default bridge network does not have an embedded DNS resolver. Only user-defined bridge networks provide DNS resolution. The fix: docker network create app_net && docker network connect app_net <api> && docker network connect app_net <postgres>.
#!/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 # ── Network isolation: create a second network for the frontend ─────────────── docker network create frontend_network # Frontend container on frontend_network — CANNOT reach redis_cache docker run -d \ --name nginx_frontend \ --network frontend_network \ -p 80:80 \ nginx:alpine # Verify isolation: nginx_frontend cannot reach redis_cache docker exec nginx_frontend ping -c 1 redis_cache 2>&1 # ping: bad address 'redis_cache' — DNS resolution fails across networks # Connect nginx to BOTH networks to enable communication docker network connect app_internal_network nginx_frontend # Now nginx can resolve and reach redis_cache # ── CLEANUP ─────────────────────────────────────────────────────────────────── # docker rm -f redis_cache api_server nginx_frontend # docker network rm app_internal_network frontend_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
- The default bridge is a legacy design from before Docker had user-defined networks.
- Docker chose not to add DNS to the default bridge to avoid breaking backward compatibility.
- User-defined networks were introduced later with DNS as a built-in feature.
- The default bridge is effectively deprecated for production use — always create a user-defined 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.
The depends_on trap: depends_on without a condition only waits for the container to START — not for the process inside to be READY. Postgres takes 5-15 seconds to initialize after the container starts. If your API starts before Postgres is ready, it will fail to connect and crash. The fix: depends_on with condition: service_healthy combined with a healthcheck block on the database service.
Failure scenario — depends_on without healthcheck: A team's docker-compose.yml had depends_on: [database] for the API service. The API started immediately after the database container started, but Postgres was still initializing. The API failed to connect, logged 'connection refused', and exited. Docker's restart policy restarted the API, which failed again. After 60 seconds, Docker gave up (restart-policy: on-failure:3). The team had to manually restart the API after Postgres finished initializing. The fix: added healthcheck with pg_isready and depends_on with condition: service_healthy.
# io/thecodeforge/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}
- No condition: waits for the container to START (docker start returns). The process inside may still be initializing.
- service_healthy: waits for the healthcheck to PASS. The process inside must be fully ready and accepting connections.
- For databases, message queues, and any service with startup time, always use service_healthy.
- For services that start instantly (static file servers, simple APIs), no condition is sufficient.
| 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 <name> | 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.- docker compose down preserves volumes. docker compose down -v deletes them. Always back up volumes before any destructive operation.
- Network isolation between frontend and backend is a security boundary. A compromised frontend container cannot reach the database if they are on separate networks.
⚠ Common Mistakes to Avoid
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: - databasefor your API service, does that guarantee the database is accepting connections before the API starts? If not, how do you actually guarantee it? - QYour team's database container lost all data after a server reboot. Walk me through the possible causes and how you would prevent this from happening again.
- QHow would you implement a backup strategy for Docker named volumes? What are the trade-offs between backing up a running volume versus stopping the container first?
- QExplain how Docker network isolation can be used as a security boundary. How would you architect a multi-tier application (frontend, API, database) with network isolation?
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.
Why can't my containers reach each other by hostname?
You are likely using the default bridge network, which does not have an embedded DNS resolver. Create a user-defined bridge network: docker network create app_net. Then connect your containers to it: docker run --network app_net ... On a user-defined network, Docker runs a DNS server that resolves container names to their IP addresses automatically.
How do I share a volume between containers on different hosts in Docker Swarm?
The default local volume driver stores data on a single host. For multi-host access, use a volume driver that supports remote storage: NFS for shared filesystems, rexray for cloud block storage (AWS EBS, GCE PD), or portworx for multi-cloud. Configure the driver in your docker-compose.yml or docker service create command.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.