Docker Compose Volumes: The Complete Setup Guide

Docker Compose Volumes: A Practical Guide to Persistent Data

Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

If you’ve ever restarted a container and watched your database vanish into thin air, you already understand why docker compose volumes matter. Containers are ephemeral by design — anything written inside a container’s writable layer disappears the moment that container is removed. Volumes are Docker’s answer to this problem, and getting them right is one of the first things you need to master before running anything stateful in production.

This guide covers everything you need to know about docker compose volumes: named volumes, bind mounts, tmpfs mounts, permissions, backups, and the common mistakes that cause data loss. We’ll use real, runnable docker-compose.yml examples throughout.

Why Volumes Exist

Docker containers are meant to be disposable. You should be able to destroy a container and recreate it from its image without worrying about what happens to your application’s state. But that raises an obvious problem: where does your database, your uploaded files, or your configuration live if the container itself is throwaway?

The answer is volumes — storage that exists independently of the container lifecycle. Docker supports three main mechanisms for persisting data:

  • Named volumes — managed by Docker, stored under /var/lib/docker/volumes/ on the host
  • Bind mounts — a direct mapping between a host path and a container path
  • tmpfs mounts — in-memory storage that never touches disk, useful for secrets or caches
  • Each has different tradeoffs, and Compose makes it easy to declare any of them declaratively in a single YAML file.

    Named Volumes: The Default Choice

    Named volumes are the recommended approach for most production workloads. Docker manages the storage location for you, and volumes persist independently of any container — even if you run docker compose down, the volume survives unless you explicitly pass -v or --volumes.

    Here’s a basic example using PostgreSQL:

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD: examplepass
        volumes:
          - pgdata:/var/lib/postgresql/data
        ports:
          - "5432:5432"
    
    volumes:
      pgdata:

    Notice the two-part structure: the volumes: key under the service maps a named volume (pgdata) to a path inside the container. The top-level volumes: block declares that pgdata exists and lets Compose create it if it doesn’t already.

    You can inspect the volume directly:

    docker volume inspect pgdata
    docker volume ls

    Named volumes are portable across host filesystems, get automatic driver support (local, NFS, cloud-backed drivers), and are the correct choice when you don’t need to browse the files from the host directly.

    Bind Mounts: Direct Host Access

    Bind mounts map a specific directory or file on the host machine into the container. They’re most useful during development, when you want code changes on your host to reflect immediately inside the container without rebuilding the image.

    services:
      app:
        build: .
        volumes:
          - ./src:/usr/src/app
          - ./config/nginx.conf:/etc/nginx/nginx.conf:ro
        ports:
          - "3000:3000"

    The :ro suffix mounts the file read-only inside the container, which is a good habit for configuration files you don’t want the app accidentally overwriting. Bind mounts are also the standard way to expose the Docker socket for tools that need to manage containers, though that comes with security implications worth understanding before you do it in a shared environment — see Docker’s own guidance on socket security if you’re considering it.

    The downside of bind mounts is portability: the host path has to exist and be correct on every machine that runs the Compose file, which makes them a poor fit for production deployments across different servers.

    tmpfs Mounts: Ephemeral In-Memory Storage

    tmpfs mounts never persist to disk. They’re wiped when the container stops, which makes them ideal for temporary secrets, session data, or scratch space that shouldn’t leak onto the host filesystem.

    services:
      cache:
        image: redis:7
        tmpfs:
          - /data

    This is a niche use case compared to named volumes and bind mounts, but it’s worth knowing it exists — especially for compliance-sensitive workloads where you need a guarantee that certain data never touches persistent storage.

    A Real-World Multi-Service Example

    Here’s a more complete example combining a web app, a database, and a reverse proxy — a common pattern for anything you’d deploy on a VPS:

    services:
      web:
        build: .
        volumes:
          - ./app:/usr/src/app
          - static_files:/usr/src/app/static
        depends_on:
          - db
    
      db:
        image: postgres:16
        volumes:
          - pgdata:/var/lib/postgresql/data
        environment:
          POSTGRES_PASSWORD: examplepass
    
      nginx:
        image: nginx:alpine
        volumes:
          - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
          - static_files:/usr/src/app/static:ro
        ports:
          - "80:80"
    
    volumes:
      pgdata:
      static_files:

    This pattern shares the static_files volume between the web and nginx services, so nginx can serve static assets directly without proxying every request through the app server. If you’re setting up a similar stack on your own infrastructure, our guide on deploying Docker Compose to a production VPS walks through the full process including firewall and TLS setup.

    Permissions and Ownership Gotchas

    One of the most common issues developers hit with docker compose volumes is file permission mismatches. If your container runs as a non-root user (which it should, for security reasons), files written by that user inside a bind-mounted directory will be owned by the corresponding UID on the host — which may not match any real user on your system.

    A few practical fixes:

  • Match the container’s UID to your host user with the user: directive in Compose
  • Use a startup script that runs chown on the mounted directory before dropping privileges
  • For named volumes, let Docker manage ownership — it’s usually less painful than bind mounts for this exact reason
  • If you’re chasing a permission denied error, check docker compose exec <service> id and compare it against the ownership of the files with ls -la on the host. Mismatched UIDs are the cause more often than any actual bug in your application.

    Backing Up and Restoring Volumes

    Since named volumes live outside your source-controlled Compose file, you need a separate backup strategy. The simplest approach uses a throwaway container to tar up the volume contents:

    docker run --rm 
      -v pgdata:/data 
      -v $(pwd):/backup 
      alpine 
      tar czf /backup/pgdata-backup.tar.gz -C /data .

    To restore, reverse the process:

    docker run --rm 
      -v pgdata:/data 
      -v $(pwd):/backup 
      alpine 
      tar xzf /backup/pgdata-backup.tar.gz -C /data

    For anything running in production, don’t rely on manual backups alone. Automated monitoring that alerts you before a disk fills up or a backup job silently fails is worth the setup time — BetterStack offers uptime and log monitoring that integrates cleanly with Dockerized stacks if you want alerts wired up without building your own tooling from scratch.

    Choosing the Right Storage Backend for Your Host

    Where your volumes physically live matters more than most people realize until they hit an I/O bottleneck. If you’re running database-heavy workloads in Compose, the underlying disk speed of your VPS provider directly affects query latency. Providers like Hetzner and DigitalOcean both offer NVMe-backed instances that handle volume-heavy workloads noticeably better than budget shared hosting — worth considering if you’re scaling past a hobby project. If your Compose stack sits behind a domain and you want DDoS protection and caching in front of it, Cloudflare is a standard, low-friction addition to the stack.

    Common Mistakes to Avoid

  • Forgetting the top-level volumes: declaration — Compose will still create an anonymous volume if you skip it, but you lose the ability to reference it by name elsewhere
  • Using bind mounts for production databases — path mismatches between environments cause subtle, hard-to-debug failures
  • Running docker compose down -v without thinking — this deletes named volumes along with the containers, which has destroyed more than one person’s local database
  • Not setting :ro on config files — an app with a bug that writes to its own config file can silently corrupt your setup
  • Ignoring volume drivers — if you need multi-host storage (NFS, cloud block storage), you need a volume driver configured; the default local driver only works on a single host
  • If you’re moving from a single-container setup to a full multi-service stack, it’s also worth reading through our breakdown of Docker networking modes, since volumes and networking decisions often need to be made together when you’re designing how services talk to each other and to the outside world.

    Wrapping Up

    Docker Compose volumes aren’t complicated once you understand the distinction between named volumes, bind mounts, and tmpfs — the hard part is picking the right one for each use case and avoiding the permission and lifecycle mistakes that catch people off guard. For production database storage, default to named volumes. For local development where you need live code reloading, bind mounts make sense. Reserve tmpfs for genuinely ephemeral data that should never touch disk.

    Get comfortable inspecting volumes with docker volume ls and docker volume inspect, build a backup habit early, and pay attention to the underlying disk performance of whatever host you’re running on — it matters more than most Compose tutorials mention.

    Recommended: Ready to put this into practice? DigitalOcean is a tool we use for exactly this, and we have a real, disclosed affiliate relationship with them.

    FAQ

    Q: What’s the difference between a named volume and a bind mount?
    A: A named volume is managed entirely by Docker and stored in Docker’s own directory structure on the host, while a bind mount maps a specific host path directly into the container. Named volumes are more portable; bind mounts give you direct host-side access to the files.

    Q: Does docker compose down delete my volumes?
    A: No, by default docker compose down stops and removes containers but leaves named volumes intact. You have to explicitly add the -v flag (docker compose down -v) to remove volumes as well.

    Q: Can I share one volume between multiple services in the same Compose file?
    A: Yes. Declare the volume once in the top-level volumes: block and reference it in the volumes: section of each service that needs it, as shown in the nginx/web example above.

    Q: Why do I get permission denied errors when writing to a mounted volume?
    A: This usually happens because the UID the container process runs as doesn’t match the ownership of the files on the host. Check with docker compose exec <service> id and adjust ownership or the container’s user: directive accordingly.

    Q: How do I back up a named volume?
    A: Run a temporary container that mounts the volume alongside a host directory, then use tar to archive the volume’s contents to that host directory, as shown in the backup example above.

    Q: Should I use volumes for stateless services?
    A: Generally no. If a service doesn’t need to retain data across restarts, skip the volume entirely and let the container’s writable layer handle it — adding a volume you don’t need just adds cleanup overhead later.

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *