A container's writable layer disappears when the container is removed, so anything a database or application writes there is lost on the next docker rm or image upgrade. Docker offers three ways to store data outside that layer: named volumes, bind mounts and tmpfs mounts. In this tutorial you will use each of them on Ubuntu 24.04, run PostgreSQL with a persistent volume, declare volumes in Docker Compose, and back up, restore and migrate a volume.

Prerequisites

To follow this tutorial you need:

  • A server running Ubuntu 24.04 LTS, for example a CubePath VPS.
  • A non-root user with sudo privileges, added to the docker group.
  • Docker Engine and the Docker Compose plugin installed from Docker's official repository.

Choosing a storage type

TypeWhere the data livesManaged by DockerBest for
Named volume/var/lib/docker/volumes/<name>/_dataYesDatabases and any application data in production
Bind mountAny path you choose on the hostNoConfiguration files, source code during development
tmpfs mountHost memory, never written to diskYesTemporary or sensitive files that must not persist

Named volumes are the default choice: they do not depend on the host's directory layout, Docker creates them with correct ownership, and they are easy to list, inspect and back up.

Docker accepts two syntaxes for mounts. The short -v source:destination form is compact; the --mount type=...,source=...,target=... form is more explicit and fails loudly if a bind-mount source does not exist, instead of silently creating an empty directory. This tutorial uses --mount for bind mounts and -v where it is unambiguous.

Step 1 - Creating and inspecting a named volume

Create a volume:

docker volume create pgdata

Inspect it to see where Docker stores it:

docker volume inspect pgdata
[
    {
        "CreatedAt": "2026-09-25T10:12:44Z",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/pgdata/_data",
        "Name": "pgdata",
        "Options": null,
        "Scope": "local"
    }
]

Do not edit files under /var/lib/docker/volumes directly while a container is using them. Access the data through a container instead, as the backup step shows.

Step 2 - Running PostgreSQL with a persistent volume

Start PostgreSQL 16 with the volume mounted at the image's data directory. Replace your_strong_password with a real password:

docker run -d --name db \
  -e POSTGRES_PASSWORD=your_strong_password \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

Create a table and insert a row:

docker exec -i db psql -U postgres <<'SQL'
CREATE TABLE notes (id serial PRIMARY KEY, body text);
INSERT INTO notes (body) VALUES ('stored in a volume');
SQL

Now remove the container completely and start a new one on the same volume:

docker rm -f db
docker run -d --name db \
  -e POSTGRES_PASSWORD=your_strong_password \
  -v pgdata:/var/lib/postgresql/data \
  postgres:16

Wait a few seconds for PostgreSQL to start, then query the table:

docker exec db psql -U postgres -c 'SELECT * FROM notes;'
 id |        body
----+--------------------
  1 | stored in a volume
(1 row)

The row survived because it was written to pgdata, not to the container. The same approach lets you upgrade to a newer minor image (for example postgres:16 to a newer 16.x) by recreating the container.

How an empty volume is populated

When you mount an empty named volume on a directory that already contains files in the image, Docker copies those files into the volume first. Bind mounts never do this: they hide whatever the image had at that path. You can see the copy in action with Nginx:

docker run --rm -v nginx-html:/usr/share/nginx/html nginx:alpine ls /usr/share/nginx/html
50x.html
index.html

The files now live in the nginx-html volume. Remove it with docker volume rm nginx-html.

Step 3 - Using bind mounts

A bind mount maps a specific host path into the container. It is the right tool when you manage the files from the host, such as a configuration file or a static site.

Create a directory with a page:

mkdir -p ~/site
echo '<h1>Served from a bind mount</h1>' > ~/site/index.html

Mount it read-only into Nginx:

docker run -d --name site -p 127.0.0.1:8080:80 \
  --mount type=bind,source="$HOME/site",target=/usr/share/nginx/html,readonly \
  nginx:alpine

Test it, then change the file on the host and test again:

curl -s http://127.0.0.1:8080
echo '<h1>Edited on the host</h1>' > ~/site/index.html
curl -s http://127.0.0.1:8080
<h1>Served from a bind mount</h1>
<h1>Edited on the host</h1>

The change is visible immediately, with no rebuild. Because the mount is readonly, a compromised process in the container cannot modify your files. Confirm it:

docker exec site touch /usr/share/nginx/html/test
touch: /usr/share/nginx/html/test: Read-only file system

Remove the container with docker rm -f site.

Bind mount permissions

Files in a bind mount keep their numeric owner (UID and GID) on both sides. If a container process runs as UID 1000 and the host directory belongs to root, the container cannot write to it. Match the user instead of loosening permissions:

mkdir -p ~/output
docker run --rm --user "$(id -u):$(id -g)" \
  --mount type=bind,source="$HOME/output",target=/output \
  alpine sh -c 'echo hello > /output/file.txt'
ls -l ~/output/file.txt
-rw-r--r-- 1 your_user your_user 6 Sep 25 10:30 /home/your_user/output/file.txt

The file belongs to your user rather than root. Never use chmod 777 to work around ownership problems.

Step 4 - Using tmpfs mounts

A tmpfs mount keeps data in memory only. It is gone when the container stops and it is never written to disk, which suits caches, session files and secrets that must not end up in a volume:

docker run --rm --tmpfs /app/cache:rw,size=64m alpine df -h /app/cache
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    64.0M         0     64.0M   0% /app/cache

Always set a size. Without it, Docker enforces no limit of its own and a runaway process can fill the host's memory.

Step 5 - Declaring volumes in Docker Compose

In a compose.yaml file, named volumes are declared under the top-level volumes: key and referenced by name in each service. Relative paths such as ./conf become bind mounts:

mkdir -p ~/pgstack && cd ~/pgstack
nano compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: your_strong_password
    volumes:
      - dbdata:/var/lib/postgresql/data
      - ./initdb:/docker-entrypoint-initdb.d:ro
    tmpfs:
      - /tmp:size=64m
    restart: unless-stopped

volumes:
  dbdata:
    labels:
      com.example.backup: "daily"

Create the init directory the bind mount points to (SQL files placed there run on the first start of an empty database), then start the stack:

mkdir -p initdb
docker compose up -d
docker volume ls --filter label=com.example.backup
DRIVER    VOLUME NAME
local     pgstack_dbdata

Compose prefixes the volume with the project name (pgstack_). docker compose down keeps the volume; only docker compose down -v deletes it.

To reuse a volume created outside Compose, mark it as external so Compose neither creates nor deletes it:

volumes:
  dbdata:
    external: true
    name: pgdata

Step 6 - Backing up and restoring a volume

The portable way to back up a volume is to mount it read-only in a temporary container together with a host directory, and archive it with tar.

For a database, stop the container first so the files on disk are consistent:

cd ~
mkdir -p ~/backups
docker stop db
docker run --rm \
  -v pgdata:/data:ro \
  -v "$HOME/backups":/backup \
  alpine tar czf "/backup/pgdata-$(date +%F).tar.gz" -C /data .
docker start db

Check the archive:

ls -lh ~/backups
-rw-r--r-- 1 root root 6.9M Sep 25 10:41 pgdata-2026-09-25.tar.gz

To restore, create a new, empty volume and extract the archive into it:

docker volume create pgdata-restored
docker run --rm \
  -v pgdata-restored:/data \
  -v "$HOME/backups":/backup:ro \
  alpine tar xzf "/backup/pgdata-$(date +%F).tar.gz" -C /data

Start a test container on the restored volume and verify the data:

docker run -d --name db-restored \
  -e POSTGRES_PASSWORD=your_strong_password \
  -v pgdata-restored:/var/lib/postgresql/data \
  postgres:16
sleep 5
docker exec db-restored psql -U postgres -c 'SELECT count(*) FROM notes;'
 count
-------
     1
(1 row)

Migrating a volume to another server

Copy the archive to the new server and run the same restore commands there:

scp ~/backups/pgdata-2026-09-25.tar.gz your_user@new_server_ip:~/

Make sure the new server runs the same major version of the database image, since data files are not compatible across major PostgreSQL or MySQL versions.

Step 7 - Cleaning up volumes

List volumes and see how much space they use:

docker system df -v | sed -n '/Local Volumes space usage/,/^$/p'

Remove a specific volume (it must not be in use by any container, running or stopped):

docker rm -f db-restored
docker volume rm pgdata-restored

docker volume prune removes unused anonymous volumes. To also remove unused named volumes, add --all. Read the list it prints carefully before confirming, because this deletes data permanently:

docker volume prune --all

Troubleshooting

Data disappears after recreating a container. The mount target does not match the directory the application writes to, or the container used an anonymous volume that got a new random name. Check the mounts with docker inspect --format '{{json .Mounts}}' container_name and use a named volume on the path documented by the image.

Permission denied when writing to a bind mount. The UID inside the container does not own the host directory. Run the container with --user "$(id -u):$(id -g)" or chown the directory to the UID the image uses.

bind source path does not exist. The --mount type=bind syntax requires the host path to exist. Create it first, and use absolute paths ("$HOME/site", "$(pwd)/conf").

volume is in use. A container, possibly a stopped one, still references the volume. Find it with docker ps -a --filter volume=volume_name.

The disk fills up. Check usage with docker system df and remove what you no longer need. Container logs are a frequent cause as well; configure log rotation in /etc/docker/daemon.json.

Conclusion

You stored data outside the container lifecycle with named volumes, served host files through read-only bind mounts, kept temporary data in memory with tmpfs, and backed up, restored and migrated a volume using nothing more than tar. As next steps, schedule the backup commands with a systemd timer and copy the archives off the server, use logical database dumps for zero-downtime backups, and review our Docker Compose guide to manage volumes as part of a complete stack.