Control panels

Docker persistent storage for non-root users on a Linux VPS

Set up Docker persistent storage so container data survives deletion, and fix ownership so a non-root user inside the container can read and write it.

IntermediateLinux server6 min read

Docker persistent storage keeps your data after a container is deleted. Anything a container writes outside a volume or bind mount lives in the container layer and disappears with docker rm, and when the process inside the container runs as a non-root user it usually fails with "permission denied" on a freshly created volume. This guide sets up both storage types and fixes the ownership so the container can actually write.

Before you start

You need:

  • A Linux VPS running Ubuntu 22.04 or 24.04, Debian 12, or AlmaLinux/Rocky 8–9.
  • Docker Engine installed, with a normal login user such as deploy who can use sudo.
  • Shell access over SSH. Check your server IP, username, and root password in your VPS provider's control panel if you are not sure.
  • A rough idea of which path inside the container holds the data you care about, for example /var/lib/postgresql/data or /app/uploads. Check the image documentation if you do not know.

Warning: Adding a user to the docker group gives that user root-equivalent access to the host. Only add accounts you trust. See Docker security checklist for a public VPS before you widen access.

Docker persistent storage: volumes or bind mounts

There are two ways to keep data, and the ownership rules differ.

  • Named volume — Docker creates the directory under /var/lib/docker/volumes/<name>/_data and owns it as root. It survives docker rm and docker compose down unless you pass -v. Good for databases and app state.
  • Bind mount — you point at an existing host directory such as /srv/app/data. You control the ownership with normal Linux tools, which makes it easier to share with a non-root container user.

If you are still deciding, Docker volumes vs bind mounts: store container data safely covers the trade-offs in more detail.

Give the login user access to Docker

On a fresh VPS the docker group usually exists. Confirm it and add your user:

Bash
getent group docker
sudo usermod -aG docker deploy

Log out and back in, then check that the group is active:

Bash
id deploy
docker ps

If docker ps still says permission denied, see Fix "permission denied" on the Docker socket.

Tip: Groups are applied at login. newgrp docker opens a shell with the new group immediately, but a full re-login is cleaner.

Create a named volume and give the container user ownership

Create the volume first, then look at where Docker put it:

Bash
docker volume create appdata
docker volume inspect appdata

The Mountpoint field shows something like /var/lib/docker/volumes/appdata/_data. That directory is owned by root:root, so a container running as UID 1000 cannot write to it.

Find out which UID the image uses:

Bash
docker run --rm nginx:alpine id

Run the container as a fixed UID and GID so the behaviour is predictable:

Bash
docker run -d --name web \
  --user 1000:1000 \
  -v appdata:/data \
  nginx:alpine

Now hand ownership of the volume to that UID using a short-lived helper container:

Bash
docker run --rm -v appdata:/data alpine chown -R 1000:1000 /data

The helper runs as root by default, which is why it can change ownership. Do not add --user to that command.

Use a bind mount instead

A bind mount puts you in charge of the host directory:

Bash
sudo mkdir -p /srv/app/data
sudo chown -R 1000:1000 /srv/app/data
sudo chmod 750 /srv/app/data

Then mount it read-write:

Bash
docker run -d --name app \
  --user 1000:1000 \
  -v /srv/app/data:/data \
  nginx:alpine

The container user and the host directory owner now match, so writes succeed.

Warning: Never run chown -R across all of /var/lib/docker. Changing ownership of unrelated volume or overlay directories breaks running containers and can stop the daemon from starting. Target one volume path at a time.

On AlmaLinux and Rocky with SELinux enforcing, add the :z suffix so the directory gets the right label:

Bash
docker run -d --name app \
  -v /srv/app/data:/data:z \
  nginx:alpine

Fix ownership on an existing volume

If data already exists and the container suddenly cannot write, check the ownership first:

Bash
docker run --rm -v appdata:/data alpine ls -ln /data

Then repair it for the UID the container uses:

Bash
docker run --rm -v appdata:/data alpine chown -R 1000:1000 /data

If two services must share one folder with different UIDs, put them in a shared group and use ACLs rather than opening permissions to 777:

Bash
sudo setfacl -R -m g:developers:rwx /srv/app/data
sudo setfacl -R -d -m g:developers:rwx /srv/app/data

The pattern is the same as Create a shared folder several users can edit.

Check it works

Write a file inside the container, then confirm it is on the host:

Bash
docker exec web sh -c 'echo hello > /data/test.txt'
docker exec web cat /data/test.txt

Now destroy the container and mount the same volume in a new one:

Bash
docker rm -f web
docker run --rm --user 1000:1000 -v appdata:/data alpine cat /data/test.txt

If you see hello, the data outlived the container and the non-root user can read it. Repeat the same test for your bind mount path.

Troubleshooting

  • mkdir: cannot create directory '/data/x': Permission denied — the container user's UID does not match the volume owner. Run id inside the container, then chown the volume to that UID with a root helper container.
  • The volume looks empty inside the container — a bind mount covers whatever was in the image at that path, and the host directory takes priority. Either copy the image's default files out first or start from an empty directory.
  • Data vanished after docker rm — it was written to the container layer, not to a volume. Add -v name:/path or a bind mount and recreate the container. docker volume ls shows what actually exists.
  • permission denied when running any docker command — the login user is not in the docker group or has not logged in since being added. Re-check with id, then re-login.
  • Works on Debian but not Rocky — SELinux is blocking the bind mount. Add :z to the mount and check sudo ausearch -m avc -ts recent for denials.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.