Skip to content
Home DevOps Docker Volumes and Networking Explained — Data, Containers, and Real-World Patterns

Docker Volumes and Networking Explained — Data, Containers, and Real-World Patterns

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Docker → Topic 8 of 18
Docker Volumes and Networking demystified: learn why data disappears without volumes, how container networks isolate services, and real-world patterns with working examples.
⚙️ Intermediate — basic DevOps knowledge assumed
In this tutorial, you'll learn
Docker Volumes and Networking demystified: learn why data disappears without volumes, how container networks isolate services, and real-world patterns with working examples.
  • 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 bridge network 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.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • 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
🚨 START HERE
Docker Volumes and Networking Triage Cheat Sheet
First-response commands when volume persistence or container connectivity issues are reported.
🟡Container data disappeared after restart.
Immediate ActionCheck if a volume was mounted and if it still exists.
Commands
docker volume ls
docker volume inspect <volume-name>
Fix NowIf volume does not exist, it was deleted (possibly by docker compose down -v). Check backups. If volume exists but is empty, check container user permissions.
🟡Container A cannot reach Container B by hostname.
Immediate ActionVerify both containers are on the same user-defined network.
Commands
docker network inspect <network-name> --format '{{range .Containers}}{{.Name}} {{end}}'
docker exec <container-a> nslookup <container-b-name>
Fix NowIf container-b is missing from the network, connect it: docker network connect <network> <container-b>. If using default bridge, create a user-defined network and reconnect both.
🟡Published port is unreachable from host.
Immediate ActionVerify port mapping and container process binding.
Commands
docker port <container>
docker exec <container> ss -tlnp
Fix NowIf container process is not listening, check the application config. If listening on 127.0.0.1 inside container, change to 0.0.0.0. Check host firewall: sudo iptables -L -n.
🟡Permission denied writing to volume mount.
Immediate ActionCheck container user ID and volume ownership.
Commands
docker exec <container> id
docker exec <container> ls -la <mount-path>
Fix NowIf UID mismatch, add chown in Dockerfile or startup script. Never chmod 777.
🟡docker compose down -v deleted production volumes.
Immediate ActionStop all changes and assess backup availability.
Commands
docker volume ls | grep <project>
ls -la ./backups/ /var/backups/docker/
Fix NowIf backups exist, restore immediately. If no backups, the data is lost. Implement automated backups before any further operations.
🟠Container-to-container latency is high on overlay network.
Immediate ActionCheck if VXLAN encapsulation overhead is the bottleneck.
Commands
docker exec <container> ping -c 10 <target-container>
docker network inspect <network> --format '{{.Driver}}'
Fix NowIf using overlay network on a single host, switch to bridge driver. If across hosts, consider host-mode networking for latency-sensitive services.
Production IncidentPostgres Data Lost After docker compose down -v — 3 Days of User Data GoneA developer ran docker compose down -v during a local environment reset, expecting it to only remove containers. The -v flag deleted the named volume containing 3 days of user registration data from a shared development database. No backup existed.
SymptomAfter running docker compose down -v and docker compose up -d, the application started successfully but all user accounts were gone. The registration endpoint started returning user IDs from 1 again. The team noticed within 2 hours when new users reported duplicate email conflicts (emails that were already registered were being accepted again).
AssumptionThe team assumed a database migration failure or a bug in the registration endpoint. They checked the application logs — no errors. They checked the migration status — all migrations had run. They ran SELECT count(*) FROM users — returned 0. The database was completely empty.
Root causeA developer ran docker compose down -v to reset the local environment after a configuration change. The -v flag removes containers, networks, AND volumes. The postgres_data named volume was deleted, destroying all database files. The team had no automated backups of the development database. The developer did not know that docker compose down (without -v) preserves volumes by default.
Fix1. Added docker compose down (without -v) as the standard reset command in the team's runbook. 2. Added a nightly pg_dump cron job that backs up the development database to S3. 3. Added an alias in the team's shell profiles: alias dcd='docker compose down' (never include -v). 4. Added a .env warning comment above the database volume: '# WARNING: docker compose down -v will DELETE this volume and all data'. 5. Implemented a pre-commit hook that scans shell history for 'docker compose down -v' and warns the developer.
Key Lesson
docker compose down preserves volumes. docker compose down -v deletes them. The -v flag is destructive and irreversible.Always back up named volumes before running any destructive docker command. Use: docker run --rm -v <volume>:/data -v $(pwd):/backup alpine tar czf /backup/backup.tar.gz -C /data .Development databases should have automated backups. Treat development data as production data until proven otherwise.Never alias docker compose down to include -v. The risk of accidental data loss is too high.Educate the team on the difference between docker compose down and docker compose down -v during onboarding.
Production Debug GuideFrom missing data to unreachable containers — systematic debugging paths.
Container starts but data directory is empty despite mounting a volume.Check if the volume actually exists: docker volume ls. Inspect it: docker volume inspect <volume>. If the Mountpoint is empty or the volume was created fresh, the data was never persisted. Check if the container runs as a non-root user that lacks write permissions to the volume mount point. Fix with chown in the Dockerfile or startup script.
Container cannot connect to another container by hostname — 'ECONNREFUSED' or 'could not translate host name'.Check if both containers are on the same user-defined network: docker network inspect <network>. If one container is on the default bridge, it has no DNS resolution. Connect it to the user-defined network: docker network connect <network> <container>. Verify DNS resolution: docker exec <container> nslookup <target-container-name>.
Port is published but connection is refused from the host.Verify the port mapping: docker port <container>. Check if the container process is actually listening on the published port: docker exec <container> ss -tlnp. Check if the host firewall (ufw, iptables) is blocking the port. Check if another process on the host is already using the port: ss -tlnp | grep <port>.
Volume permissions denied — container cannot write to mounted directory.Check the container's user: docker exec <container> id. Check the volume ownership: docker exec <container> ls -la <mount-path>. If the container runs as UID 1000 but the volume is owned by root, the container cannot write. Fix: add chown 1000:1000 <mount-path> in the Dockerfile or use --user 0:0 to run as root (not recommended for production).
Named volume data is corrupted after a container crash.Check if the volume was mounted by multiple containers with concurrent write access: docker volume inspect <volume>. If a database was writing when the container crashed, use the database's recovery tools (pg_resetwal for Postgres). For future prevention, use the database's built-in replication instead of sharing volumes.
docker compose down -v accidentally deleted volumes.Check if any backups exist: ls -la ./backups/. Check if the volume still exists: docker volume ls. If the volume is gone, data is unrecoverable unless backed up. Restore from the latest backup. Prevent recurrence by removing -v from all scripts and aliases.

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.

io/thecodeforge/volume_demo.sh · BASH
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
#!/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
▶ Output
$ docker volume create 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 ✓
Mental Model
Volumes as External Hard Drives
Why does Docker delete the container's filesystem but not the volume?
  • 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.
📊 Production Insight
Orphaned volumes (volumes not attached to any container) accumulate silently and consume disk space. Run docker volume ls to list them and docker volume prune to remove unused volumes. In production, integrate volume cleanup into your CI/CD pipeline or use a scheduled cron job. Never run docker volume prune without verifying which volumes will be deleted — it removes ALL unused volumes, including those with data.
🎯 Key Takeaway
Named volumes are the production default — Docker manages the path and handles permissions. Bind mounts are for development. tmpfs is for sensitive data. Always back up named volumes before destructive operations. Orphaned volumes accumulate silently — prune them regularly.
Volume Type Selection
IfProduction database or stateful service
UseNamed volume (docker volume create). Docker manages the path. Portable across hosts.
IfDevelopment — live code reload, config files
UseBind mount (-v ./src:/app/src). Direct access to host files. Fast iteration.
IfSensitive data that should never touch disk (secrets, session tokens)
Usetmpfs mount (--tmpfs /secrets:size=1m). In-memory only. Deleted on container stop.
IfMulti-host Swarm or Kubernetes deployment
UseRemote volume driver (NFS, rexray, portworx). Volumes accessible from any node.

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>.

io/thecodeforge/custom_network_demo.sh · BASH
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
#!/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
▶ Output
$ docker network create --driver bridge --subnet 172.28.0.0/16 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
Mental Model
Networks as Office Floors with Locked Doors
Why does the default bridge network not support DNS resolution?
  • 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.
📊 Production Insight
Network isolation between frontend and backend networks is a security boundary. A compromised frontend container cannot reach the database if they are on separate networks. Use docker network connect to grant access only to the containers that need it. This is defense in depth at the network layer — even if the frontend is exploited, the database is unreachable.
🎯 Key Takeaway
The default bridge network has NO DNS resolution — always use a user-defined bridge network. Network isolation between frontend and backend is a security boundary. Only publish (-p) ports that need external access. The host driver removes all isolation — use it only for performance-critical services on dedicated hosts.
Network Driver Selection
IfSingle-host deployment with multiple containers that need to communicate
UseUser-defined bridge network. Provides DNS resolution and network isolation.
IfPerformance-critical service that needs raw network access
UseHost network (--network host). Best performance but no isolation. Use on dedicated hosts only.
IfBatch job or security-sensitive container that needs zero network access
UseNone network (--network none). Completely air-gapped. No network interfaces at all.
IfMulti-host deployment (Docker Swarm)
UseOverlay network (--driver overlay). VXLAN tunnel between hosts. Use --opt encrypted for cross-data-center traffic.

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 · YAML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
# 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
▶ Output
$ docker compose -f io/thecodeforge/docker-compose.production.yml up -d

[+] 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}
Mental Model
depends_on as a Bouncer at a Club
What is the difference between depends_on with no condition and depends_on with condition: service_healthy?
  • 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.
📊 Production Insight
The start_period: 30s flag on the healthcheck is critical for services with slow startup. Without it, the healthcheck runs immediately and may fail before the service is ready, causing unnecessary container restarts. Set start_period to the expected maximum startup time plus a buffer. For Postgres, 30-60 seconds is typical. For JVM applications, 60-120 seconds may be needed.
🎯 Key Takeaway
depends_on without condition: service_healthy only waits for container start — not process readiness. Always combine depends_on with a healthcheck for databases and services with startup time. The start_period flag prevents false healthcheck failures during initialization. This pattern is the foundation of every reliable Docker Compose deployment.
Startup Ordering Strategy
IfDatabase dependency (API depends on Postgres/MySQL)
Usedepends_on with condition: service_healthy + healthcheck using pg_isready/mysqladmin ping. Always.
IfCache dependency (API depends on Redis)
Usedepends_on with condition: service_healthy + healthcheck using redis-cli ping.
IfIndependent services with no dependency
UseNo depends_on needed. Let Docker start them in any order.
IfComplex startup with multiple dependencies
UseUse a wait-for-it.sh or dockerize script in the entrypoint that polls all dependencies before starting the application.
🗂 Docker Volume Types Compared
Named volumes, bind mounts, and tmpfs — storage location, use cases, and trade-offs.
Feature / AspectNamed VolumeBind Mount
Storage locationManaged by Docker under /var/lib/docker/volumes/Exact host path you specify (e.g. ./data)
Best forProduction databases, stateful servicesDevelopment — live code reload, local config files
PortabilityPortable — works the same on any Docker hostNot portable — depends on specific host directory structure
PermissionsDocker sets up ownership — usually just worksInherits host filesystem permissions — frequent source of errors
Performance (Linux)Native — fastest optionNative on Linux, slower on Mac/Windows due to file-sync overhead
Backupsdocker volume export or third-party driversStandard filesystem backup tools work directly
Survives docker rmYes — must explicitly run docker volume rmYes — host directory is never touched by Docker rm
Inspect storage locationdocker 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 bridge network 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_on alone is not enough for startup ordering — combine it with condition: service_healthy and a proper healthcheck block 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

    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_net and attach both containers to it using --network app_net. Docker's embedded DNS only works on user-defined networks, not the default bridge network.

    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 rm <volume_name> first (after backing up if needed), or use a different volume name for each major version upgrade.

    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 -p mapping. If you need to connect locally for debugging, use docker exec -it <container> psql instead.

    Running docker compose down -v without understanding it deletes volumes
    Symptom

    all database data is gone after a 'quick environment reset' —

    Fix

    use docker compose down (without -v) to preserve volumes. Only use -v when you intentionally want to delete all data. Add comments in docker-compose.yml warning about -v.

    Using chmod 777 to fix volume permission errors
    Symptom

    volume works but any process on the host can read/write the data —

    Fix

    volume permission errors — Symptom: volume works but any process on the host can read/write the data — Fix: set the correct ownership with chown <uid>:<gid> <path>. Match the container's expected user ID. Never use 777 in production.

    Running two containers writing to the same named volume without coordination
    Symptom

    data corruption, race conditions, database corruption —

    Fix

    only one container should write to a volume at a time. For read-only shared data (config files, static assets), mount the volume as :ro (read-only).

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?
  • 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.

🔥
Naren Founder & Author

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.

← PreviousDockerfile ExplainedNext →Docker Compose
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged