Persistent Storage in Docker: Volumes, Bind Mounts, and Backup Strategies

Persistent Storage in Docker: Volumes, Bind Mounts, and Backup Strategies

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

One of the first painful lessons every self-hoster learns is that Docker containers are ephemeral by default — delete or recreate a container and everything inside the writable layer is gone. I learned this the hard way when I upgraded a Gitea container and lost three weeks of issue comments because I hadn't wired up proper storage. In this tutorial I'll walk you through Docker's three storage mechanisms — named volumes, bind mounts, and tmpfs — explain exactly when I reach for each one, and then cover practical backup strategies you can automate today.

Understanding the Three Storage Types

Before writing a single docker run command, it helps to understand what Docker actually offers:

I use named volumes for databases and application state where I don't need to inspect files from the host regularly. I use bind mounts for config files and media libraries where I want direct host access — nano /opt/jellyfin/config/jellyfin.xml rather than docker exec-ing into a container. tmpfs is reserved for things like Vaultwarden's token cache or Authentik's session data.

Named Volumes in Practice

Named volumes are the Docker-recommended approach for databases. Here's a realistic Postgres setup that I use as the backend for several self-hosted apps:

# Create the volume explicitly (optional but good practice — it makes intent clear)
docker volume create pgdata

# Run Postgres with the named volume
docker run -d \
  --name postgres \
  --restart unless-stopped \
  -e POSTGRES_USER=appuser \
  -e POSTGRES_DB=appdb \
  -e POSTGRES_PASSWORD_FILE=/run/secrets/pg_password \
  -v pgdata:/var/lib/postgresql/data \
  -p 127.0.0.1:5432:5432 \
  postgres:16-alpine

# Inspect volume metadata
docker volume inspect pgdata

Notice I bind port 5432 only to 127.0.0.1 — I never expose database ports to the public interface. The docker volume inspect command will show you the actual host path under /var/lib/docker/volumes/pgdata/_data if you ever need to access the raw files.

Bind Mounts in Practice

For something like Jellyfin or Immich, I strongly prefer bind mounts for media directories. If Docker ever breaks, I still have my files at a known host path. Here's a Docker Compose snippet I use for a Jellyfin deployment:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    network_mode: host
    volumes:
      # Bind mount: config lives at a known path on the host
      - /opt/jellyfin/config:/config
      # Bind mount: cache on a fast SSD path
      - /opt/jellyfin/cache:/cache
      # Bind mount: media on NAS mount or dedicated drive
      - /mnt/media:/media:ro
    environment:
      - JELLYFIN_PublishedServerUrl=https://jellyfin.yourdomain.com

The :ro flag on the media mount is something I always add for app containers that only need to read files — Jellyfin doesn't need to write to my media library, so there's no reason to give it write access. Least privilege applies to mounts too.

Watch out: When using bind mounts, the directory on the host must already exist before starting the container, or Docker will create it as root:root with permissions 755. This can cause permission errors inside the container if the app runs as a non-root UID. Pre-create the directory and chown it to the correct UID before running docker compose up.

tmpfs for Sensitive Ephemeral Data

For anything that should never persist to disk — temporary session tokens, encryption key material, runtime secrets — tmpfs is the right tool. You can add it in Compose like this:

services:
  myapp:
    image: myapp:latest
    tmpfs:
      - /run/secrets:size=64m,mode=0700
      - /tmp:size=128m

The size limit prevents a runaway process from filling your RAM. The mode=0700 ensures only the process owner can read the mount.

Backup Strategies That Actually Work

Here's my rule: if data matters, it needs at least two copies, and one of them should be off the host. I'll show you two approaches — a quick one-liner for ad-hoc backups, and a cron-scheduled script I run nightly on my self-hosted stack.

Quick Volume Backup to a Tar Archive

# Backup a named volume to a compressed tar archive
# Spins up a temporary Alpine container, mounts the volume read-only,
# and writes the archive to the current directory on the host
docker run --rm \
  -v pgdata:/data:ro \
  -v "$(pwd)":/backup \
  alpine \
  tar czf /backup/pgdata-$(date +%Y%m%d-%H%M%S).tar.gz -C /data .

# Restore from that archive later
docker run --rm \
  -v pgdata:/data \
  -v "$(pwd)":/backup \
  alpine \
  sh -c "cd /data && tar xzf /backup/pgdata-20260429-020001.tar.gz"

This pattern works for any named volume and doesn't require stopping the container for most use cases. For Postgres specifically, I still prefer pg_dump over a raw filesystem backup because it's transactionally consistent — but for something like a Vaultwarden SQLite database or Gitea repo data, the tar approach is perfectly reliable.

Automated Nightly Backup Script

#!/usr/bin/env bash
# /opt/scripts/backup-volumes.sh
# Runs nightly via cron, backs up named volumes, prunes old archives

set -euo pipefail

BACKUP_DIR="/mnt/backups/docker-volumes"
RETENTION_DAYS=14
DATE=$(date +%Y%m%d-%H%M%S)

mkdir -p "$BACKUP_DIR"

VOLUMES=(pgdata vaultwarden_data gitea_data immich_db)

for VOL in "${VOLUMES[@]}"; do
  echo "[$(date)] Backing up volume: $VOL"
  docker run --rm \
    -v "${VOL}:/data:ro" \
    -v "${BACKUP_DIR}:/backup" \
    alpine \
    tar czf "/backup/${VOL}-${DATE}.tar.gz" -C /data .
  echo "[$(date)] Done: ${VOL}-${DATE}.tar.gz"
done

# Prune archives older than RETENTION_DAYS
find "$BACKUP_DIR" -name "*.tar.gz" -mtime "+${RETENTION_DAYS}" -delete
echo "[$(date)] Pruned archives older than ${RETENTION_DAYS} days"

Drop this script at /opt/scripts/backup-volumes.sh, make it executable with chmod +x, and add it to cron:

chmod +x /opt/scripts/backup-volumes.sh

# Add to root's crontab — run at 2 AM every night
crontab -e
# Add this line:
0 2 * * * /opt/scripts/backup-volumes.sh >> /var/log/docker-backup.log 2>&1
Tip: Mount /mnt/backups to a separate physical drive, NFS share, or an S3-compatible object store like Hetzner's Storage Box or Backblaze B2. Backups stored on the same disk as your volumes won't save you from a drive failure. I use rclone in a second cron job to sync /mnt/backups to off-site storage every morning after the backup script finishes.

Volumes vs Bind Mounts: My Decision Framework

After running dozens of self-hosted services over the years, here's the quick mental model I apply:

One practical tip I'd add: for bind mounts, I keep everything under /opt/<appname>/ on the host. It makes it trivially easy to back up the entire configuration for a service with a single tar or rsync call, independent of the Docker volume system entirely.

Running Your Stack in the Cloud

If you're running this kind of setup on a VPS rather than local hardware, you'll want a provider with reliable block storage and affordable bandwidth for off-site backup syncing. Create your DigitalOcean account today — their Droplets pair nicely with Spaces (S3-compatible object storage) so you can keep your volume backups entirely within one platform. Get dependable uptime with their 99.99% SLA, simple security tools, and predictable monthly pricing with DigitalOcean's virtual machines, called Droplets.

Wrapping Up

Persistent storage in Docker comes down to three decisions: who manages the path (Docker with named volumes, or you with bind mounts), what lifetime the data has, and how you protect it. The backup script above is the most important thing in this article — set it up today and test a restore before you need one. The worst time to discover your backup script has a typo is at 2 AM when a container migration goes wrong.

Next steps: wire your backup directory into rclone for off-site sync, and consider adding docker-compose down before the backup loop if you're running SQLite-heavy apps like Vaultwarden to ensure a clean filesystem snapshot. From there, check out our guide on deploying a VPS with Docker and automated backups for end-to-end production hardening.

Discussion