Every Docker container running in your homelab generates data — database files, uploaded assets, configuration, logs, user content. Lose that data when a container restarts or moves to another host, and you’ve lost everything your service was built on.

Containers are ephemeral by design. Their filesystems vanish on docker rm. Persistent data lives in volumes and bind mounts — but choosing the wrong approach can leave you with slow storage, broken permissions, backup gaps, or containers that can’t migrate between hosts.

This guide covers the four Docker storage options available in every homelab — named volumes, bind mounts, tmpfs, and NFS volumes — with practical patterns for databases, configs, media, and logs. You’ll learn which to use, when, and how to back it all up without thinking about it.


Docker Named Volumes — The Default Choice

Named volumes are Docker-managed storage. Docker creates them, tracks them in its own metadata, and stores them under /var/lib/docker/volumes/ on the host. You reference them by name, and Docker handles the path resolution.

Create and use a named volume in Docker Compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# docker-compose.yml
services:
  postgres:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: homelab

volumes:
  pgdata:

Docker creates the volume automatically on docker compose up -d. The volume persists after docker compose down. Remove it explicitly with:

1
docker volume rm homelab_pgdata

When named volumes shine:

  • Databases (PostgreSQL, MySQL, MariaDB, MongoDB) — these need fast, Docker-managed storage without host path dependencies
  • Secrets and app data generated by the container, not edited from the host
  • Multi-node setups — volumes are easier to reference in Swarm or Nomad stacks

When they don’t:

  • You need to inspect or edit files directly from the host (bind mounts are better)
  • You want explicit control over the filesystem path
  • You’re sharing data between multiple containers that need specific directory structures

Named volumes are the default for a reason — they’re portable, Docker-managed, and work across environments without path assumptions.


Bind Mounts — Direct Host Access

Bind mounts map an arbitrary host directory into the container. You control exactly where data lives on the host filesystem.

Bind mount in Docker Compose:

1
2
3
4
5
6
7
8
services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - /home/gntech/docker/nginx/html:/usr/share/nginx/html:ro
      - /home/gntech/docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro

The :ro flag mounts read-only for security — the container can’t modify the configuration file.

When bind mounts win:

  • Configuration files — edit nginx.conf or prometheus.yml from the host with your text editor
  • Development — live-reload containers mount your working directory
  • Media libraries — point Plex, Jellyfin, or Navidrome at your existing media folders without moving anything
  • Log directories — collect container logs into a known host path your log collector watches

Bind mount checklist for the homelab:

  • Always use absolute paths to avoid surprises
  • Add :ro for files the container shouldn’t write (config, certs)
  • Check host permissions — the user ID inside the container must have access to the host directory
  • Pre-create directories with correct ownership before starting the container

Named Volumes vs Bind Mounts — When to Use Each

The most common Docker storage question has a straightforward answer:

Aspect Named Volume Bind Mount
Management Docker creates, names, destroys You manage paths manually
Backup Back up /var/lib/docker/volumes/ Back up the host directory
Permission safety Docker sets initial ownership to the container user Host permissions apply directly
Editing files Requires docker exec or root on host Direct file access from host
Performance Native Docker (btrfs, overlay2) Direct host filesystem
Portability Copy volume name to another Docker host Must replicate path structure
Secrets / configs Awkward Ideal

Rule of thumb for the homelab:

Use named volumes for anything the container writes (databases, app state, generated data). Use bind mounts for anything you write (config files, media, development code, log output).

This maps cleanly to real services:

1
2
3
4
5
6
7
8
9
# Named volumes — Docker manages these
docker volume create postgres_data
docker volume create prometheus_data
docker volume create grafana_data

# Bind mounts — you manage these
# /mnt/storage/media → Jellyfin
# /home/gntech/docker/config → container config files
# /var/log/containers → centralized logs

Tmpfs Mounts — In-Memory Storage

Tmpfs mounts store data in RAM. Data disappears when the container stops. Useful for ephemeral, performance-sensitive data that doesn’t need to survive a reboot.

Example — Redis cache with tmpfs:

1
2
3
4
5
services:
  redis:
    image: redis:alpine
    tmpfs:
      - /data:uid=1000,gid=1000,size=100M

Appropriate uses:

  • Redis or Memcached caches (data can be rebuilt)
  • Session stores
  • Temporary processing directories
  • /tmp inside containers (reduces disk writes)

Inappropriate uses:

  • Database data files
  • Uploaded media
  • Configuration state
  • Any data that must survive a restart

Tmpfs is a specialized tool — use it when data loss is acceptable and speed matters.


NFS Volumes — Shared Storage Across Hosts

In a multi-node homelab, volumes on one host aren’t available on another. NFS volumes let containers on any host share the same backing storage.

Set up an NFS share on your Proxmox host or NAS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# On the NFS server (e.g., Proxmox host at 10.0.20.30)
apt install nfs-kernel-server

# Create and export a volume directory
mkdir -p /srv/nfs/docker-volumes
chown -R nobody:nogroup /srv/nfs/docker-volumes
chmod 755 /srv/nfs/docker-volumes

# Edit /etc/exports
echo "/srv/nfs/docker-volumes 10.0.20.0/24(rw,sync,no_subtree_check,no_root_squash)" \
  >> /etc/exports

exportfs -a
systemctl restart nfs-kernel-server

Use NFS volumes in Docker Compose:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    volumes:
      - jellyfin-config:/config
      - jellyfin-cache:/cache
      - /mnt/storage/media:/media:ro
    ports:
      - "8096:8096"

volumes:
  jellyfin-config:
    driver: local
    driver_opts:
      type: nfs
      o: addr=10.0.20.30,rw,nfsvers=4,soft,timeo=100,retrans=3
      device: :/srv/nfs/docker-volumes/jellyfin-config
  jellyfin-cache:
    driver: local
    driver_opts:
      type: nfs
      o: addr=10.0.20.30,rw,nfsvers=4,soft,timeo=100,retrans=3
      device: :/srv/nfs/docker-volumes/jellyfin-cache

Or create NFS volumes from the CLI:

1
2
3
4
5
6
docker volume create \
  --driver local \
  --opt type=nfs \
  --opt o=addr=10.0.20.30,rw,nfsvers=4,soft \
  --opt device=:/srv/nfs/docker-volumes/postgres-data \
  postgres_data_nfs

NFS volume considerations:

  • NFSv4 is the minimum for production — older versions lack locking and security
  • Network latency adds 1-5 ms per I/O operation — don’t put latency- sensitive databases on NFS
  • Use soft mounts to avoid a container hang when the NFS server is unreachable
  • no_root_squash on the server lets container root write files (needed for most containers)
  • Back up the NFS server, not the individual Docker hosts

Docker Volume Backup — Practical Automation

A volume is only useful if you can restore it. Here’s a backup strategy that covers databases, configs, and app data with minimal overhead.

Backup script for named volumes with restic (or rsync):

 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
#!/bin/bash
# docker-volume-backup.sh — backs up named volumes to a remote repo
# Dependencies: restic, docker
# Schedule: daily cron at 03:00

REPO="sftp:[email protected]:/backups/docker-volumes"
PASSWORD="your-restic-password"   # Use env var or vault in production

# Volumes to back up — add your own here
VOLUMES=(
  "postgres_data"
  "mysql_data"
  "prometheus_data"
  "grafana_data"
  "influxdb_data"
)

BACKUP_DIR="/tmp/docker-volume-backup"

mkdir -p "$BACKUP_DIR"

for VOLUME in "${VOLUMES[@]}"; do
  echo "Backing up volume: $VOLUME"
  
  # Spin up a temporary Alpine container, mount the volume, tar it up
  docker run --rm \
    -v "${VOLUME}":/volume \
    -v "${BACKUP_DIR}":/backup \
    alpine:latest \
    tar czf "/backup/${VOLUME}.tar.gz" -C /volume .
done

# Send to restic repo
restic -r "$REPO" backup "$BACKUP_DIR" --tag docker-volumes --tag daily

# Clean up local temp files
rm -rf "$BACKUP_DIR"

echo "Backup complete: $(date)"

For PostgreSQL databases specifically — use pg_dump for consistent snapshots:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#!/bin/bash
# pg-dump-backup.sh — consistent database backup with pg_dump

docker exec postgres \
  pg_dump -U homelab --format=custom \
  -f /tmp/homelab-backup.dump homelab

# Then back up the dump file (which lives in the container's writable layer)
# Better: pipe the dump to a file in a bind mount
docker exec postgres \
  pg_dump -U homelab --format=custom \
  homelab > /backups/postgres/homelab-$(date +%Y%m%d-%H%M%S).dump

Restore a volume:

1
2
3
4
5
6
7
8
# Restore a tar.gz backup into a named volume
docker volume create postgres_data_restored

docker run --rm \
  -v postgres_data_restored:/volume \
  -v /path/to/backups:/backup \
  alpine:latest \
  tar xzf "/backup/postgres_data.tar.gz" -C /volume

Automate with a systemd timer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# /etc/systemd/system/docker-volume-backup.service
[Unit]
Description=Docker Volume Backup
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/docker-volume-backup.sh
User=root

[Install]
WantedBy=multi-user.target
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /etc/systemd/system/docker-volume-backup.timer
[Unit]
Description=Daily Docker volume backup

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

Enable it:

1
2
systemctl daemon-reload
systemctl enable --now docker-volume-backup.timer

Volume Cleanup and Lifecycle Management

Unused volumes accumulate. A year into your homelab, you’ll have orphaned volumes from renamed services, abandoned experiments, and failed updates.

Find and prune old volumes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# List all volumes and their sizes
docker system df --volumes

# Find volumes not used by any container
docker volume ls -qf dangling=true

# Prune all unused volumes (careful!)
docker volume prune -f

# Prune volumes older than 24 hours
docker volume prune --filter "until=24h"

Label-based volume management — tag your volumes so you know what’s safe to remove:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Create volumes with labels
docker volume create \
  --label project=monitoring \
  --label backup=daily \
  prometheus_data

# Filter by label
docker volume ls --filter label=project=monitoring

# Prune everything except labeled volumes
docker volume prune --filter "label!=backup" --filter "label!=keep"

Include volumes in your docker-compose down strategy:

1
2
3
4
5
# This does NOT remove volumes (safe for daily down/up)
docker compose down

# This removes all resources including volumes (destructive)
docker compose down -v

Only use -v when you’re certain you want to destroy data — it’s irreversible.


Practical Homelab Storage Layout

A battle-tested directory structure for Docker services on a single Proxmox VM or bare-metal host:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/docker/
├── config/           # Bind-mounted config files per service
│   ├── nginx/
│   ├── traefik/
│   ├── prometheus/
│   ├── grafana/
│   └── frigate/
├── media/            # Large media files (Plex, Jellyfin, Navidrome)
│   ├── movies/
│   ├── tv/
│   ├── music/
│   └── photos/
├── data/             # Bind-mounted data directories (app state)
│   ├── home-assistant/
│   ├── vaultwarden/
│   └── immich/
└── compose/          # docker-compose.yml per service group
    ├── monitoring/
    ├── media-stack/
    └── infrastructure/

Corresponding Docker Compose for a service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# compose/infrastructure/prometheus.yml
services:
  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - /docker/config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus-data:/prometheus
    command:
      - '--storage.tsdb.retention.time=30d'
      - '--storage.tsdb.path=/prometheus'

volumes:
  prometheus-data:    # Named volume for Docker-managed database storage

The pattern: bind mounts for configs and media, named volumes for database-backed writes. This gives you easy config editing from the host plus Docker-managed storage for the data that needs it.


Summary: Choose Your Storage by Data Type

Data type Storage method Example
Databases, app state Named volume PostgreSQL data, Prometheus TSDB
Configuration files Bind mount (ro) nginx.conf, prometheus.yml
Media / user uploads Bind mount Movies, photos, music
Ephemeral cache Tmpfs Redis cache, processing temp dirs
Multi-node shared data NFS volume Jellyfin config, media libraries
Logs Bind mount Container stdout logs for collection

Key takeaways:

  1. Named volumes are the default — use them for anything the container writes. Docker manages permissions, locations, and lifecycle.
  2. Bind mounts for host-edited files — configs, media, log dirs. Keep absolute paths consistent across environments.
  3. Back up early, back up often — one docker compose down -v mistake and your database is gone. Automate volume backups with restic, borg, or rsync on a systemd timer.
  4. Use NFS volumes for multi-node setups — but don’t put latency-sensitive databases on NFS. Reserve it for media and config.
  5. Label and prune — tag production volumes with backup=daily and prune the rest weekly.

The difference between a well-managed homelab and a brittle one comes down to how you handle data persistence. Containers come and go. Volumes are what survive. Get the storage right, and your services will survive Docker upgrades, host migrations, and the occasional “whoops I deleted the stack” moment.