DEV Community

Cover image for Docker Volumes Explained: Stop Losing Data Every Time You Restart a Container
Teguh Coding
Teguh Coding

Posted on

Docker Volumes Explained: Stop Losing Data Every Time You Restart a Container

You spent two hours setting up your PostgreSQL container, seeded it with test data, ran your app against it — everything works perfectly. Then you restart the container. Gone. All of it. The database is empty again.

If you have been there, you already know the pain. If you have not, consider this your warning. Docker containers are ephemeral by design, and that is a feature — until you forget about it and lose your data.

This article is a practical guide to Docker volumes: what they are, how they work, when to use each type, and the patterns that will save you from the "where did my data go" panic at 2 AM.

Why Containers Lose Data

Every Docker container gets its own writable layer on top of its image. When you write files inside a running container, they go into this layer. The problem: when the container is removed (not just stopped, but removed), that writable layer is deleted with it.

This is intentional. Containers are supposed to be disposable. You should be able to spin up ten copies of your app container and throw them away without ceremony. But your database is not supposed to be disposable.

This is exactly the problem that volumes solve.

The Three Ways to Persist Data

Docker gives you three mechanisms for data persistence:

  1. Volumes — managed by Docker, stored in Docker's own storage area
  2. Bind mounts — map a host directory directly into the container
  3. tmpfs mounts — stored in memory, gone when the container stops (useful for sensitive data)

Let's look at each one.

Docker Volumes: The Recommended Approach

Volumes are managed entirely by Docker. You create them, Docker handles where they live on the host (usually /var/lib/docker/volumes/ on Linux). You do not need to worry about host paths.

Creating and using a volume

# Create a named volume
docker volume create myapp-data

# Run a container with that volume
docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=secret \
  -v myapp-data:/var/lib/postgresql/data \
  postgres:16
Enter fullscreen mode Exit fullscreen mode

Now your PostgreSQL data survives container restarts and even container removal. The volume persists until you explicitly delete it.

Inspect and manage volumes

# List all volumes
docker volume ls

# Inspect a volume (shows the actual host path)
docker volume inspect myapp-data

# Remove a volume (careful -- this deletes the data)
docker volume rm myapp-data

# Remove all unused volumes
docker volume prune
Enter fullscreen mode Exit fullscreen mode

Why volumes beat bind mounts for production

  • Volumes work the same on any OS — no path differences between Linux, macOS, and Windows
  • Docker can manage backups and migrations of volumes
  • Volumes can be shared between containers more safely
  • Named volumes are easy to identify and reason about

Bind Mounts: Direct Host Access

Bind mounts map a specific directory from your host machine into the container. They are perfect for development.

docker run -d \
  --name myapp \
  -v /home/teguh/myapp:/app \
  node:20 node /app/server.js
Enter fullscreen mode Exit fullscreen mode

Anything you change in /home/teguh/myapp on your host is instantly reflected inside the container at /app. This is why bind mounts are the go-to for local development — you edit code in your editor, and the running container sees the changes immediately.

The development workflow

# docker-compose.yml for local development
services:
  app:
    build: .
    ports:
      - "3000:3000"
    volumes:
      # Bind mount for live code changes
      - ./src:/app/src
      # Named volume for node_modules (prevents host/container conflicts)
      - node_modules:/app/node_modules
    command: npm run dev

  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      # Named volume for database persistence
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
  node_modules:
Enter fullscreen mode Exit fullscreen mode

Notice the node_modules trick. If you bind-mount your entire project directory, the container's node_modules (compiled for Linux) gets overwritten by your host's node_modules (compiled for macOS or Windows). Using a named volume for node_modules keeps them separate and avoids the infamous "wrong platform" binary errors.

Sharing Volumes Between Containers

Multiple containers can read from and write to the same volume. This is useful for sidecar patterns — like a log aggregator that reads files written by your application container.

# Container 1: writes logs
docker run -d \
  --name app \
  -v shared-logs:/var/log/app \
  myapp:latest

# Container 2: reads and ships those logs
docker run -d \
  --name log-shipper \
  -v shared-logs:/var/log/app:ro \
  fluent-bit:latest
Enter fullscreen mode Exit fullscreen mode

The :ro flag makes the mount read-only in the second container — a good security practice when a container only needs to read, not write.

Volume Backup and Restore

This is the part people forget until disaster strikes. Always have a backup strategy.

Backup a volume

# Create a backup of the postgres_data volume
docker run --rm \
  -v postgres_data:/source:ro \
  -v $(pwd)/backups:/backup \
  alpine \
  tar czf /backup/postgres_$(date +%Y%m%d_%H%M%S).tar.gz -C /source .
Enter fullscreen mode Exit fullscreen mode

This spins up a minimal Alpine container, mounts your volume as read-only, and tars up the contents into a backups/ folder on your host.

Restore from backup

# Create a fresh volume
docker volume create postgres_data_restored

# Extract your backup into it
docker run --rm \
  -v postgres_data_restored:/target \
  -v $(pwd)/backups:/backup \
  alpine \
  tar xzf /backup/postgres_20260303_180000.tar.gz -C /target
Enter fullscreen mode Exit fullscreen mode

Common Mistakes and How to Avoid Them

Mistake 1: Anonymous volumes

# Bad: Docker generates a random ID as the volume name
docker run -v /var/lib/postgresql/data postgres:16

# Good: Always use named volumes
docker run -v postgres_data:/var/lib/postgresql/data postgres:16
Enter fullscreen mode Exit fullscreen mode

Anonymous volumes accumulate silently. You will end up with dozens of docker volume ls entries that look like a3f2b8c1d4e... and have no idea what they contain.

Mistake 2: Forgetting volumes exist when rebuilding

When you run docker-compose down, volumes are preserved by default. This is usually what you want. But when you want a completely clean slate:

# Remove containers AND volumes
docker-compose down -v

# Or remove specific volumes
docker volume rm myproject_postgres_data
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Wrong permissions

If your container runs as a non-root user (it should), the volume mount might have permission issues.

FROM node:20-alpine

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Create and own the data directory
RUN mkdir -p /app/data && chown appuser:appgroup /app/data

USER appuser
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here is a production-ready Docker Compose setup combining everything:

services:
  app:
    build:
      context: .
      target: production
    ports:
      - "3000:3000"
    volumes:
      - uploads:/app/uploads
    depends_on:
      - postgres
      - redis
    environment:
      DATABASE_URL: postgresql://user:secret@postgres:5432/myapp
      REDIS_URL: redis://redis:6379

  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql:ro

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
    command: redis-server --appendonly yes

volumes:
  postgres_data:
  redis_data:
  uploads:
Enter fullscreen mode Exit fullscreen mode

Notice: every stateful service (postgres, redis, file uploads) has a named volume. The init SQL script uses a bind mount with :ro because it is static config, not data.

The Mental Model

Think of it this way:

  • Named volumes for any data that needs to survive: databases, user uploads, application state
  • Bind mounts for development: source code, config files you want to edit live
  • Read-only mounts for config: seed data, init scripts, certificates
  • No mounts for anything truly ephemeral: temp files, build artifacts, logs you do not care about

Once this becomes habit, you stop losing data and start treating your containers with appropriate disposability — stateless app containers come and go freely, while your precious data sits safely in volumes.

Wrapping Up

Docker volumes are one of those things that seem complicated until they click, and then they seem obvious. The key insight is simple: separate your stateless compute (containers) from your stateful data (volumes).

Named volumes for production, bind mounts for development, always back up your volumes before doing anything destructive, and never use anonymous volumes if you can help it.

Now go check your running containers — you might have some orphaned anonymous volumes sitting around, taking up space and holding data you have already forgotten about.

docker volume ls
docker volume prune
Enter fullscreen mode Exit fullscreen mode

You have been warned.

Top comments (0)