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
sudoprivileges, added to thedockergroup. - Docker Engine and the Docker Compose plugin installed from Docker's official repository.
Choosing a storage type
| Type | Where the data lives | Managed by Docker | Best for |
|---|---|---|---|
| Named volume | /var/lib/docker/volumes/<name>/_data | Yes | Databases and any application data in production |
| Bind mount | Any path you choose on the host | No | Configuration files, source code during development |
| tmpfs mount | Host memory, never written to disk | Yes | Temporary 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
NoteThe mount path depends on the image. The official
postgresimages up to version 17 store data in/var/lib/postgresql/data. Always check the image documentation for the directory to persist, because mounting the wrong path means the data is still written to the container layer.
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
TipFor PostgreSQL and MySQL, a logical dump is usually a better backup than a file copy because it does not require downtime. For example:
docker exec db pg_dump -U postgres postgres > ~/backups/postgres.sql.
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.
