--- title: "How to Persist Data with Docker Volumes on Ubuntu 24.04" description: "Keep container data safe on Ubuntu 24.04 with Docker named volumes, bind mounts and tmpfs, then back up, restore and migrate volumes with plain tar commands." category: "Docker Kubernetes" language: en source: https://cubepath.com/docs/docker-kubernetes/docker-volumes-data-persistence updated: 2026-09-25 --- # How to Persist Data with Docker Volumes on Ubuntu 24.04 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 | Type | Where the data lives | Managed by Docker | Best for | |---|---|---|---| | Named volume | `/var/lib/docker/volumes//_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: ```bash docker volume create pgdata ``` Inspect it to see where Docker stores it: ```bash docker volume inspect pgdata ``` ```output [ { "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: ```bash docker run -d --name db \ -e POSTGRES_PASSWORD=your_strong_password \ -v pgdata:/var/lib/postgresql/data \ postgres:16 ``` > Note: The mount path depends on the image. The official `postgres` images 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: ```bash 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: ```bash 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: ```bash docker exec db psql -U postgres -c 'SELECT * FROM notes;' ``` ```output 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: ```bash docker run --rm -v nginx-html:/usr/share/nginx/html nginx:alpine ls /usr/share/nginx/html ``` ```output 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: ```bash mkdir -p ~/site echo '

Served from a bind mount

' > ~/site/index.html ``` Mount it read-only into Nginx: ```bash 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: ```bash curl -s http://127.0.0.1:8080 echo '

Edited on the host

' > ~/site/index.html curl -s http://127.0.0.1:8080 ``` ```output

Served from a bind mount

Edited on the host

``` 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: ```bash docker exec site touch /usr/share/nginx/html/test ``` ```output 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: ```bash 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 ``` ```output -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: ```bash docker run --rm --tmpfs /app/cache:rw,size=64m alpine df -h /app/cache ``` ```output 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: ```bash mkdir -p ~/pgstack && cd ~/pgstack nano compose.yaml ``` ```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: ```bash mkdir -p initdb docker compose up -d docker volume ls --filter label=com.example.backup ``` ```output 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: ```yaml 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: ```bash 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: ```bash ls -lh ~/backups ``` ```output -rw-r--r-- 1 root root 6.9M Sep 25 10:41 pgdata-2026-09-25.tar.gz ``` > Tip: For 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: ```bash 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: ```bash 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;' ``` ```output count ------- 1 (1 row) ``` ### Migrating a volume to another server Copy the archive to the new server and run the same restore commands there: ```bash 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: ```bash 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): ```bash 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: ```bash 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.