Overview and What You Will Learn
Containers are stateless by design. The moment you run docker rm on a container, every file written inside it is gone forever. For a stateless API server that is fine. For a PostgreSQL database holding Razorpay transaction records, that is a disaster waiting to happen.
Docker solves this with three storage mechanisms, and most engineers only learn the difference between them after losing data once in development. This lab makes sure that lesson does not happen to you in production.
By the end of this lab you will:
- Understand exactly why containers lose data on removal and how each storage type avoids that
- Create, inspect, back up, and restore named volumes
- Use bind mounts correctly for local development workflows
- Know when tmpfs is the right (and only) choice
- Configure all three storage types inside docker-compose.yml
- Avoid the most common volume permission and data-loss mistakes
Why This Matters in Production
At Zerodha, the trade ledger database runs inside a container, but the actual data lives on a named volume mounted at /var/lib/postgresql/data. When the container is upgraded to a new Postgres minor version, the container is destroyed and recreated — but the volume survives untouched. If that data had been written to the container's own writable layer instead of a volume, every deployment would wipe the ledger.
This is the single most important mental model in this lab: the container is disposable, the volume is not. Engineers who skip this lesson eventually run docker compose down thinking it only stops containers, not realizing docker compose down --volumes deletes data too. Understanding the three storage types prevents that 2 AM incident.
Core Principles
The three storage types compared:
+------------------------+ +------------------------+ +------------------------+| Named Volume | | Bind Mount | | tmpfs || | | | | || Managed by Docker | | Managed by you (OS path) | | RAM only, never disk || /var/lib/docker/volumes| | Any host path you choose | | Mounted in memory || Survives container rm | | Survives container rm | | Lost on container stop || Best for: prod data | | Best for: dev hot reload | | Best for: secrets/cache|+------------------------+ +------------------------+ +------------------------+Why containers lose data without a volume:
+------------------------------------------+| Container runs, writes file to /data | <- file lives in container's+------------------------------------------+ writable layer only | v+------------------------------------------+| docker rm container_name runs |+------------------------------------------+ | v+------------------------------------------+| Writable layer destroyed, file gone | <- no volume = no recovery+------------------------------------------+With a volume attached, the data path changes:
+------------------------------------------+| Container writes to /var/lib/postgresql || /data (mounted from named volume) |+------------------------------------------+ | v+------------------------------------------+| Write goes to host-managed volume | <- outside container's| /var/lib/docker/volumes/pgdata/_data | writable layer+------------------------------------------+ | v+------------------------------------------+| docker rm container_name runs || Volume pgdata is untouched, data intact | <- survives removal+------------------------------------------+Detailed Step-by-Step Practical Lab
Milestone 1 — Create, inspect, and use a named volume
## Create a named volume explicitly (optional -- Docker can also auto-create)docker volume create pgdata-mumbai-prod ## List all volumes on this hostdocker volume ls ## Inspect a volume -- shows the real path on diskdocker volume inspect pgdata-mumbai-prod[ { "CreatedAt": "2026-06-10T09:12:00Z", "Driver": "local", "Mountpoint": "/var/lib/docker/volumes/pgdata-mumbai-prod/_data", "Name": "pgdata-mumbai-prod", "Scope": "local" }]## Run Postgres with the named volume attacheddocker run -d \ --name razorpay-ledger-db \ -e POSTGRES_PASSWORD=changeme_in_prod \ -v pgdata-mumbai-prod:/var/lib/postgresql/data \ postgres:16-alpine ## Confirm the volume is mounted correctlydocker inspect razorpay-ledger-db --format '{{ json .Mounts }}' ## Stop and remove ONLY the container -- volume survivesdocker stop razorpay-ledger-dbdocker rm razorpay-ledger-db ## Recreate the container pointing at the same volume -- data is backdocker run -d \ --name razorpay-ledger-db \ -e POSTGRES_PASSWORD=changeme_in_prod \ -v pgdata-mumbai-prod:/var/lib/postgresql/data \ postgres:16-alpineMilestone 2 — Use a bind mount for local development hot reload
## Bind mount the current directory into the container## Code changes on host are reflected instantly inside the containerdocker run -d \ --name swiggy-api-dev \ -p 4000:4000 \ -v /home/rahul/projects/swiggy-api:/app \ -w /app \ node:20-alpine \ npm run dev ## Using the newer --mount syntax (more explicit, recommended for production scripts)docker run -d \ --name swiggy-api-dev \ -p 4000:4000 \ --mount type=bind,source=/home/rahul/projects/swiggy-api,target=/app \ -w /app \ node:20-alpine \ npm run dev ## Verify the bind mount with docker inspectdocker inspect swiggy-api-dev --format '{{ json .Mounts }}'Milestone 3 — Use tmpfs for in-memory, never-persisted data
## Mount tmpfs for a directory that should never touch disk## Useful for session caches, temp upload buffers, secret materialdocker run -d \ --name session-cache \ --tmpfs /app/cache:rw,size=128m \ phonepe/session-service:latest ## Confirm tmpfs is active inside the containerdocker exec session-cache mount | grep tmpfs## tmpfs on /app/cache type tmpfs (rw,size=131072k) ## Data written to /app/cache disappears the moment the container stopsdocker stop session-cachedocker rm session-cache## Cache content is gone -- this is expected and desired behaviourMilestone 4 — Back up and restore a named volume
## Back up a volume's contents to a tar file on the host## Uses a throwaway container that mounts both the volume and a host directorydocker run --rm \ -v pgdata-mumbai-prod:/source \ -v /home/rahul/backups:/backup \ alpine \ tar czf /backup/pgdata-backup-2026-06-18.tar.gz -C /source . ## Restore the backup into a fresh volumedocker volume create pgdata-mumbai-restored docker run --rm \ -v pgdata-mumbai-restored:/target \ -v /home/rahul/backups:/backup \ alpine \ tar xzf /backup/pgdata-backup-2026-06-18.tar.gz -C /target ## Verify restored data by mounting it into a temporary inspection containerdocker run --rm -v pgdata-mumbai-restored:/data alpine ls -la /dataMilestone 5 — Use volumes in docker-compose.yml
services: ledger-db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: changeme_in_prod volumes: # Named volume -- managed by Docker, survives compose down - pgdata:/var/lib/postgresql/data api: build: . volumes: # Bind mount -- source code for hot reload in development - ./src:/app/src # tmpfs -- in-memory only, never written to disk tmpfs: - /app/tmp volumes: # Declaring the named volume at the top level pgdata: driver: local## Start the stackdocker compose up -d ## Stop containers but KEEP volumes (default, safe)docker compose down ## Stop containers AND delete volumes (destructive -- use with caution)docker compose down --volumesMilestone 6 — Clean up unused volumes safely
## List volumes not currently used by any containerdocker volume ls -f dangling=true ## Remove a specific unused volumedocker volume rm old-test-volume ## Remove ALL unused volumes -- review the dangling list firstdocker volume prune ## Prune with a filter -- only volumes older than 72 hoursdocker volume prune --filter "label!=keep" -aProduction Best Practices and Common Pitfalls
| Scenario | Wrong | Correct |
|---|---|---|
| Database storage | Write directly to container filesystem | Mount a named volume at the data directory |
| Local dev code changes | Rebuild image on every change | Bind mount source directory for hot reload |
| Temporary secret material | Write to a regular volume | Use tmpfs so it never touches disk |
| Removing a stack | docker compose down --volumes by habit | docker compose down (keeps volumes by default) |
| Backing up data | Hope the volume is fine | Scheduled tar backup via throwaway container |
| Bind mount in production | Bind mount application code into prod containers | Bake code into the image, reserve bind mounts for dev only |
Quick Reference and Troubleshooting Commands
| Task | Command |
|---|---|
| Create a named volume | docker volume create name |
| List volumes | docker volume ls |
| Inspect a volume | docker volume inspect name |
| Remove a volume | docker volume rm name |
| Remove unused volumes | docker volume prune |
| Mount named volume | docker run -v name:/path image |
| Mount bind path | docker run -v /host/path:/path image |
| Mount tmpfs | docker run --tmpfs /path:rw,size=128m image |
| Back up a volume | docker run --rm -v name:/source -v $(pwd):/backup alpine tar czf /backup/out.tar.gz -C /source . |
PLACEMENT PRO TIP**Tip:** Use `docker run --mount` instead of `-v` when scripting backups or production deploys. The `--mount` syntax is more verbose but fails loudly on typos instead of silently creating an unexpected anonymous volume.
REMEMBER THIS**Remember:** `docker compose down` does NOT delete named volumes by default. You must explicitly pass `--volumes` to remove them. This default exists specifically to protect production data.
COMMON MISTAKE / WARNING**Common Mistake:** Forgetting the trailing slash difference between `-v /data:/app/data` and `-v ./data:/app/data`. The first is an absolute host path; the second is relative to the current directory. A typo here can silently create an anonymous volume instead of mounting the directory you intended.
COMMON MISTAKE / WARNING**Security:** Never bind mount the Docker socket (`-v /var/run/docker.sock:/var/run/docker.sock`) into a container unless that container is a trusted, audited tool. Any process with access to the socket has root-equivalent control over the entire host.