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
Plain-English First
Imagine a hotel. Each guest room (container) is freshly reset every time a new guest checks in — great for hygiene, terrible if you left your laptop there. A volume is like a hotel safe that persists between guests: your stuff stays even after the room is cleaned. Networking is like the hotel's internal phone system — rooms can call each other by room number without anyone outside knowing the extension, and the front desk (host) can choose which calls to forward to the outside world.
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.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/bash
# ── STEP1: Create a named volume Docker will manage ──────────────────────────
docker volume create postgres_data
# ── STEP2: 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/...
# ── STEP3: RunPostgres and mount our volume to its data directory ───────────
# The -v flag maps: <volume_name>:<path_inside_container>
docker run -d \n --name postgres_primary \n -e POSTGRES_USER=appuser \n -e POSTGRES_PASSWORD=securepass123 \n -e POSTGRES_DB=appdb \n -v postgres_data:/var/lib/postgresql/data \n postgres:15-alpine
# ── STEP4: Write some data into the database ─────────────────────────────────
docker exec -it postgres_primary psql -U appuser -d appdb \n -c "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);"
docker exec -it postgres_primary psql -U appuser -d appdb \n -c "INSERT INTO users (name) VALUES ('Alice'), ('Bob');"
# ── STEP5: Destroy the container completely ──────────────────────────────────
# Without a volume this would delete all our data. Watch what happens instead.
docker rm -f postgres_primary
# ── STEP6: Start a BRANDNEW container using the SAME volume ─────────────────
docker run -d \n --name postgres_restored \n -e POSTGRES_USER=appuser \n -e POSTGRES_PASSWORD=securepass123 \n -e POSTGRES_DB=appdb \n -v postgres_data:/var/lib/postgresql/data \n postgres:15-alpine
# ── STEP7: Prove the data survived the container deletion ────────────────────
docker exec -it postgres_restored psql -U appuser -d appdb \n -c "SELECT * FROM users;"
# ── BACKUP a named volume ─────────────────────────────────────────────────────
# Run a temporary container that mounts the volume and creates a tar archive
docker run --rm \n -v postgres_data:/source:ro \n -v $(pwd)/backups:/backup \n 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 \n -v postgres_data:/target \n -v $(pwd)/backups:/backup:ro \n 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
# Data survived the container being completely deleted and recreated ✓
Volumes as External Hard Drives
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.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#!/bin/bash
# ── STEP1: Create a user-defined bridge network ──────────────────────────────
# This is what enables DNS-based container discovery
docker network create \n --driver bridge \n --subnet 172.28.0.0/16 \n app_internal_network
# ── STEP2: Start a Redis cache container on our custom network ───────────────
# Notice: we doNOT 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 \n --name redis_cache \n --network app_internal_network \n redis:7-alpine
# ── STEP3: Start an API container on the same network ───────────────────────
# It can reach Redis using the hostname 'redis_cache' — not an IP address
docker run -d \n --name api_server \n --network app_internal_network \n -p 3000:3000 \n -e REDIS_HOST=redis_cache \n -e REDIS_PORT=6379 \n node:20-alpine \n sh -c "npm install -g redis-cli && sleep infinity"
# ── STEP4: ProveDNS 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
# ── STEP5: Inspect the network to see both containers connected ──────────────
docker network inspect app_internal_network \n --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{"\n"}}{{end}}'
# ── STEP6: VerifyRedis 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:63792>&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 \n --name nginx_frontend \n --network frontend_network \n -p 80:80 \n 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
* connect to 127.0.0.1 port 6379 failed: Connection refused
# Redis correctly unreachable from host — only accessible inside the network
Networks as Office Floors with Locked Doors
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.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# 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:
# ── PostgresDatabase ─────────────────────────────────────────────────────
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\"]\n interval: 10s # Check every 10 seconds\n timeout: 5s # Fail check if no response in 5s\n retries: 5 # Mark unhealthy after 5 failures\n start_period: 30s # Grace period before checks start\n # NO ports: section — database is intentionally unreachable from host\n\n # ── Node.js REST API ──────────────────────────────────────────────────────\n api:\n build:\n context: ./api\n dockerfile: Dockerfile\n container_name: node_api\n restart: unless-stopped\n ports:\n - \"3000:3000\" # Only the API is exposed to the outside world\n environment:\n # Uses the service name 'database' as the hostname — DockerDNS resolves it\n DATABASE_URL: postgresql://appuser:securepass123@database:5432/appdb\n NODE_ENV: production\n PORT: 3000\n volumes:\n # Bind mount for uploaded files — you want these on the host for backups\n - ./uploads:/app/uploads\n networks:\n - backend_network\n depends_on:\n database:\n # 'service_healthy' waits for the healthcheck to pass — not just container start\n condition: service_healthy\n\n# ── NamedVolumes ─────────────────────────────────────────────────────────────\n# Declared here so DockerCompose manages their lifecycle\nvolumes:\n postgres_data:\n driver: local\n # Optional: add 'external: true'ifthis volume is managed outside Compose\n\n# ── Networks ──────────────────────────────────────────────────────────────────\nnetworks:\n backend_network:\n driver: bridge\n # ipam config is optional but useful when you need predictable subnets\n ipam:\n config:\n - subnet: 172.29.0.0/16",
"output": "$ docker compose -f io/thecodeforge/docker-compose.production.yml up -d\n\n[+] Running 4/4\n ✔ Network app_backend_network Created 0.1s\n ✔ Volume postgres_data Created 0.1s\n ✔ Container postgres_primary Healthy 18.3s\n ✔ Container node_api Started 18.5s\n\n# Notice: postgres_primary reaches 'Healthy'BEFORE node_api starts\n# That's the healthcheck + depends_on working as intended\n\n$ docker compose ps\nNAME IMAGESTATUS\npostgres_primary postgres:15-alpine Up25seconds (healthy)\nnode_api api-api Up7 seconds\n\n$ curl http://localhost:3000/health\n{\"status\":\"ok\",\"database\":\"connected\",\"uptime\":12.4}"
},
"callout": {
"type": "mental_model",
"title": "depends_on as a Bouncer at a Club",
"text": "Think of depends_on as a bouncer at a club. Without condition: service_healthy, the bouncer only checks that the club door is open (container started) — they do not check if the DJ has set up (process ready). With condition: service_healthy, the bouncer actually walks inside and confirms the DJ is playing music (healthcheck passes) before letting the next guest in.",
"hook": "What is the difference between depends_on with no condition and depends_on with condition: service_healthy?",
"bullets": [
"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.",
"decision_tree": {
"title": "Startup Ordering Strategy",
"items": [
{
"condition": "Database dependency (API depends on Postgres/MySQL)",
"result": "depends_on with condition: service_healthy + healthcheck using pg_isready/mysqladmin ping. Always."
},
{
"condition": "Cache dependency (API depends on Redis)",
"result": "depends_on with condition: service_healthy + healthcheck using redis-cli ping."
},
{
"condition": "Independent services with no dependency",
"result": "No depends_on needed. Let Docker start them in any order."
},
{
"condition": "Complex startup with multiple dependencies",
"result": "Use a wait-for-it.sh or dockerize script in the entrypoint that polls all dependencies before starting the application."
}
]
},
"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."
},
{
"heading": "Comparing Bridge, Host, and None Networking Modes",
"content": "Docker provides three built-in network drivers that serve fundamentally different purposes. Choosing the wrong one causes connectivity issues, performance bottlenecks, or security holes. Here's a direct comparison of `bridge`, `host`, and `none` — when to use each and what you give up.\n\n**Bridge (default) — isolated network with NAT:** Every container gets its own network namespace. Traffic flows through a virtual bridge (`docker0` by default). Containers can communicate with each other (on a user-defined bridge) via embedded DNS. Published ports (`-p`) create iptables rules to forward host traffic into the container. Isolation is preserved: unless you explicitly publish a port, nothing from outside the network can reach the container. Use bridge for 90% of workloads — it's the safe default that balances isolation and connectivity.\n\n**Host — container shares the host's network stack:** The container's network namespace is the same as the host's. No bridge, no NAT, no iptables overhead. The container can bind to any host port and access any host network interface. Performance is identical to running the process directly on the host. The trade-off is zero isolation — every container on host network competes for ports and can see all host traffic. Use `--network host` only when you need maximum performance (e.g., high-throughput packet processing, real-time financial services) and you trust the container not to interfere with other services.\n\n**None — complete network isolation:** The container has no network interfaces except loopback (`lo`). It cannot reach any other container or the outside world, and nothing can reach it. This is not a bug — it's the strongest security profile Docker offers. Use `--network none` for batch jobs that process local files, for security audit tools that should have no network access, or for containers that only need to communicate via shared volumes or Unix sockets.\n\n**Failure scenario — host network collision:** A team moved a Redis container from bridge to host network to reduce latency. They didn't realize another service on the host was already using port 6379. Redis failed to start because the port was taken. Even after freeing the port, the Redis container was now visible to all processes on the host, bypassing Docker's network security. Lesson: only use host mode on dedicated hosts where no other container or host service uses the same ports.",
"code": {
"language": "bash",
"filename": "io/thecodeforge/network_mode_demo.sh",
"code": "#!/bin/bash\n# ── 1. Bridge mode (default) — isolated with NAT ────────────────────────────\ndocker run -d --name web_bridge --network bridge -p 8080:80 nginx:alpine\n# Inside the container: ifconfig shows eth0 with 172.17.0.x IP\n# Host sees port 8080 mapped to container's port 80\n\n# ── 2. Host mode — shares host network stack ──────────────────────────────────\ndocker run -d --name web_host --network host nginx:alpine\n# Inside the container: ifconfig shows the host's interfaces (eth0, lo, etc.)\n# Port80 now directly on host — no -p flag needed (it binds to host's port 80)\n\n# ── 3. None mode — zero network access ───────────────────────────────────────\ndocker run -it --name job_isolated --network none alpine sh\n# Inside the container: only lo interface exists\n# ping google.com fails: ping: bad address 'google.com'\n# exit\n\n# ── Verify differences ───────────────────────────────────────────────────────\ndocker exec web_bridge hostname -I # Shows container's privateIP\ndocker exec web_host hostname -I # Shows host's IP (same as host)\ndocker exec job_isolated hostname -I # Showsnothing (127.0.0.1 only)\n\n# ── Cleanup ───────────────────────────────────────────────────────────────────\ndocker rm -f web_bridge web_host job_isolated",
"output": "$ docker exec web_bridge hostname -I\n172.17.0.2\n\n$ docker exec web_host hostname -I\n192.168.1.100\n\n$ docker exec job_isolated hostname -I\n\n# (empty output — container has no external IP)"
},
"callout": {
"type": "warning",
"title": "Host Mode Security Risk",
"text": "Never use --network host on a shared host or in a multi-tenant environment. The container can listen on any port, access any interface, and potentially interfere with other containers and host processes. Only use host mode on dedicated single-purpose servers after auditing the host's port usage."
},
"production_insight": "The host network mode bypasses Docker's NAT overhead, which can reduce latency by 1-3ms per packet. For most applications this difference is negligible. Only invest in host mode if your profiling shows network overhead is a bottleneck. Even then, consider using --network host with a read-only root filesystem and no cap-add to minimize the security blast radius.",
"key_takeaway": "Bridge (default) provides network isolation with NAT. Host mode gives maximum performance at the cost of isolation. None mode completely air-gaps the container. Choose bridge for general workloads, host for performance-critical services on dedicated hosts, and none for security-sensitive batch jobs."
},
{
"heading": "Internal DNS and Service Discovery in Docker",
"content": "Docker's embedded DNS is the glue that makes service discovery work in user-defined networks. Without it, you'd have to hard-code IP addresses that change every time a container restarts. Here's how Docker DNS works under the hood and how to debug it when it breaks.\n\n**How Docker DNS works:** When you create a user-defined network (e.g., `docker network create mynet`), Docker starts an internal DNS resolver on the network's gateway IP (typically the bridge IP, like 172.18.0.1). Each container connected to that network registers its name (the `--name` or container name) as an A record. The resolver runs inside the Docker engine, not inside any container. Containers are configured to use this resolver via `/etc/resolv.conf` inside the container, which points to 127.0.0.11 — a small DNS proxy in each container that forwards queries to the engine's resolver.\n\n**Resolution scope:** DNS resolution only works for containers on the same user-defined network. Containers on different networks cannot resolve each other by name unless they are connected to both networks. The default bridge network does NOT have embedded DNS — this is a frequent source of confusion.\n\n**Custom DNS entries:** Docker DNS also resolves service names from Compose (the `service` name) and aliases specified with `--network-alias`. For example, `docker run --network mynet --network-alias db-primary --name postgres_1 ...` allows other containers to reach it as `db-primary`.\n\n**Debugging DNS:** If a container cannot resolve a peer's hostname, first verify they are on the same network: `docker network inspect <network>`. Then test DNS inside the container: `docker exec <container> nslookup <target>`. If nslookup fails with 'server can't find', the peer isn't registered. If nslookup succeeds but ping fails, the issue is a firewall or port binding (the peer exists but isn't listening).\n\n**Failure scenario — DNS proxy misconfiguration:** A team ran a custom DNS server inside a container that listened on port 53. When they tried to start the container, Docker's internal DNSproxy (127.0.0.11) could not start because port 53 was already taken inside the container network namespace. The container started but DNS resolution for other containers failed. Fix: avoid binding to port 53 inside containers; use a different port and remap in the application.",
"code": {
"language": "bash",
"filename": "io/thecodeforge/dns_debug_demo.sh",
"code": "#!/bin/bash\n# ── Set up a test network ───────────────────────────────────────────────────\ndocker network create dns_demo\n\n# ── Start a Redis container ──────────────────────────────────────────────────\ndocker run -d --name redis-cache --network dns_demo redis:7-alpine\n\n# ── Start an Alpine container for testing ────────────────────────────────────\ndocker run -it --name tester --network dns_demo alpine sh -c \"\n # Check internal resolver config\n cat /etc/resolv.conf\n echo '---'\n # QueryDNSfor redis-cache\n nslookup redis-cache 2>&1 || echo 'nslookup not available'\n # Tryping (built-in busybox)\n ping -c 2 redis-cache\n # CheckifDockerDNS proxy is running\n ss -tlnp | grep 53 || echo 'No listener on port 53 inside container'\n\"\n\n# ── Inspect network to see registered names ──────────────────────────────────\ndocker network inspect dns_demo --format '{{range .Containers}}{{.Name}}: {{.IPv4Address}}{{\"\\n\"}}{{end}}'\n\n# ── Test resolution from outside the network (should fail) ───────────────────\ndocker run --rm alpine nslookup redis-cache 2>&1 || echo 'DNS resolution fails from default bridge'\n\n# ── Cleanup ──────────────────────────────────────────────────────────────────\ndocker rm -f redis-cache tester\ndocker network rm dns_demo",
"output": "$ docker exec tester cat /etc/resolv.conf\nnameserver 127.0.0.11\noptions ndots:0\n\n$ docker exec tester nslookup redis-cache\nName: redis-cache\nAddress 1: 172.19.0.2 redis-cache.dns_demo\n\n$ docker exec tester ping -c 2 redis-cache\nPING redis-cache (172.19.0.2): 56 data bytes\n64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.1 ms\n64 bytes from 172.19.0.2: seq=1 ttl=64 time=0.1 ms\n\n$ docker network inspect dns_demo --format '...'\nredis-cache: 172.19.0.2/16\ntester: 172.19.0.3/16"
},
"callout": {
"type": "info",
"title": "DNS Resolution Only on User-Defined Networks",
"text": "The default bridge network does not support DNS-based service discovery. If you see 'could not resolve hostname' errors, the most likely cause is that your containers are on the default bridge. Create a user-defined network with `docker network create` and attach both containers."
},
"production_insight": "In production Swarm or Kubernetes clusters, service discovery is handled differently (by the orchestrator's DNS system). In Docker Compose, the embedded DNS is sufficient. For multi-host deployments, use an overlay network with `--opt encrypted` to secure DNS traffic between nodes. The default Docker DNS resolver has a TTL of 600 seconds — if you need faster propagation of network changes, adjust the `--dns-opt` flags or use a third-party solution like Consul DNS.",
"key_takeaway": "Docker's embedded DNS on user-defined networks resolves container names automatically. The resolver runs inside the Docker engine, not in containers. DNS only works within the same user-defined network. Debug resolution with `nslookup` inside the container. Never rely on default bridge DNS — it doesn't exist."
},
{
"heading": "Volume Types Compared: Named Volume vs Bind Mount vs tmpfs",
"content": "Docker offers three distinct storage options. Each behaves differently in terms of lifecycle, performance, and security. Choosing the right one depends on whether you need persistence, speed, or isolation from the host filesystem.\n\n**NamedVolume** — Managed by Docker, stored under `/var/lib/docker/volumes/`. Docker handles creation, deletion, and permissions. This is the production defaultfor stateful services like databases. The volume can be backed up using `docker volume inspect` and restored via helper containers. Named volumes are portable across hosts when using remote volume drivers.\n\n**BindMount** — Maps an exact host path into the container. You control the host-side location and permissions. Bind mounts bypass Docker's volume management — they are direct filesystem mounts. This is ideal for development (live code reload, config injection) but introduces host filesystem dependency and permission issues. A bind mount is also the only way to mount a directory from the host that exists outside Docker's control.\n\n**tmpfs Mount** — Stored in host memory only. Never written to disk. Data is lost when the container stops. This is perfect for secrets, session tokens, or any data that must never be persisted. Performance is extremely fast (RAM speed), but limited by available memory. Use `--tmpfs /app/secrets:size=100m` to control the maximum size.\n\n**Decision table:**
| Property | NamedVolume | BindMount | tmpfs |
|---|---|---|---|
| Management | Docker | User | Docker |
| Persistence | Yes (until `docker volume rm`) | Yes (host file) | No (lost on container stop) |
| Host path visibility | Hidden (under /var/lib/docker) | Explicit (you set the path) | N/A (memory) |
| Backup method | Container + tar | Standard filesystem tools | Not applicable |
| Performance (Linux) | Native | Native | RAM-speed |
| Security | Good (Docker manages permissions) | Caution (depends on host permissions) | Excellent (no disk) |
| Usecase | Production databases, stateful services | Development code, config files | Secrets, session data |\n\n**Failure scenario — tmpfs size exceeded:** A team used tmpfs for a session store without setting a `size` limit. The application created large session objects (base64-encoded binary data). The tmpfs filled up and the container started crashing with 'no space left on device'. The host had plenty of free RAM, but the tmpfs mount was limited to 64MB by default. Fix: set `--tmpfs /session:size=500m` to give the session store adequate room.",
"callout": {
"type": "mental_model",
"title": "Three Types of Storage, Three Mindsets",
"text": "Named volumes are like a managed storage locker (safe, abstracted). Bind mounts are like a desk drawer on your host (convenient but you control the organization). tmpfs is like a whiteboard (fast, temporary, erased when you leave the room). Never put durable data on tmpfs, and never put secrets in bind mounts that might leak to disk.",
"hook": "What happens when you mount a named volume over a directory that already has data from the image?",
"bullets": [
"If the volume is empty (first use), Docker copies the image's content into the volume before mounting.",
"If the volume already has data (from a previous mount), the image's content is hidden under the mount — the container sees only the volume's data.",
"This is why you can reattach a volume after a container rebuild and still see old data, but init scripts that expect an empty directory may not re-run."
]
},
"production_insight": "For production databases, always use named volumes with the local driver unless you have specific multi-host requirements. Bind mounts should be avoided in production due to permission complexity and lack of portability. tmpfs is excellent for temporary caches and secrets, but monitor memory usage — a rogue process can fill tmpfs and cause OOM-like failures. Set explicit size limits on all tmpfs mounts.",
"key_takeaway": "Named volumes are for production persistence (managed by Docker), bind mounts are for development (direct host access), and tmpfs is for ephemeral in-memory data (secrets, caches). Choose based on lifecycle requirements: do you need data to survive container removal, or is it okay to lose it?"
}
]
● Production incidentPOST-MORTEMseverity: high
Postgres Data Lost After docker compose down -v — 3 Days of User Data Gone
Symptom
After 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).
Assumption
The 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 cause
A 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.
Fix
1. 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.6 entries
Symptom · 01
Container starts but data directory is empty despite mounting a volume.
→
Fix
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.
Symptom · 02
Container cannot connect to another container by hostname — 'ECONNREFUSED' or 'could not translate host name'.
→
Fix
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>.
Symptom · 03
Port is published but connection is refused from the host.
→
Fix
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>.
Symptom · 04
Volume permissions denied — container cannot write to mounted directory.
→
Fix
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).
Symptom · 05
Named volume data is corrupted after a container crash.
→
Fix
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.
Symptom · 06
docker compose down -v accidentally deleted volumes.
→
Fix
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.
★ Docker Volumes and Networking Triage Cheat SheetFirst-response commands when volume persistence or container connectivity issues are reported.
Container data disappeared after restart.−
Immediate action
Check if a volume was mounted and if it still exists.
Commands
docker volume ls
docker volume inspect <volume-name>
Fix now
If 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 action
Verify both containers are on the same user-defined network.
If 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 action
Verify port mapping and container process binding.
Commands
docker port <container>
docker exec <container> ss -tlnp
Fix now
If 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 action
Check container user ID and volume ownership.
Commands
docker exec <container> id
docker exec <container> ls -la <mount-path>
Fix now
If UID mismatch, add chown in Dockerfile or startup script. Never chmod 777.
docker compose down -v deleted production volumes.+
Immediate action
Stop all changes and assess backup availability.
Commands
docker volume ls | grep <project>
ls -la ./backups/ /var/backups/docker/
Fix now
If 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 action
Check if VXLAN encapsulation overhead is the bottleneck.