Docs/ Infrastructure/ Volumes and Mounts

Volumes and Mounts

Keep data across restarts and deployments by mounting a Docker volume, a host directory or a single file into the app container.

Why mounts

Every deployment starts a new container. Anything the app wrote into its own filesystem disappears with the previous container: uploads, SQLite files, generated caches. A mount attaches storage that lives outside the container, so the next container finds the same data at the same path.

Note
Mounts take effect on the next deployment. After adding or removing one, redeploy the app (sh0 deploy my-api --wait). Database servers created by sh0 manage their own storage; you do not add mounts for them here.

Mount types

  • Volume -- a Docker volume named sh0-<app>-<name>, created by sh0. The right choice for application data.
  • Bind Mount -- a directory of the host mounted into the container. Only directories under an allowed root are accepted (see below).
  • File Mount -- a single file whose content you type in the dashboard, for a configuration file. sh0 stores the content and writes the file on the host at deployment.

Adding a mount

  1. Open the app and go to the Volumes tab.
  2. Click Add Mount and pick the type.
  3. Fill in Mount Name (for a volume, it becomes part of the Docker volume name) and Container Path, an absolute path such as /data.
  4. For a bind mount, give the Host Path; for a file mount, the File Content. Tick Read-only if the app must not write to it.
  5. Confirm with Add Mount, then redeploy the app.

The same through the API (actual response; source is required and stays empty for a new volume):

Terminal
$ curl -X POST https://panel.example.com/api/v1/apps/<app-id>/mounts \
    -H "Authorization: Bearer $SH0_TOKEN" -H "Content-Type: application/json" \
    -d '{"mount_type":"volume","name":"data","source":"","target":"/data"}'
{"data":{"app_id":"<app-id>","created_at":"2026-10-02 02:49:02","id":"0aa5cb7d-d97a-4cce-90c3-c61c338f0055","mount_type":"volume","name":"data","read_only":false,"source":"sh0-my-api-data","target":"/data"}}

mount_type is volume, bind or file. source is empty for a new volume, the host path for a bind mount, and the file content for a file mount. read_only defaults to false. Creating a mount requires the developer role on the app's project.

Where the data lives

  • Volume -- in Docker's own directory, /var/lib/docker/volumes/sh0-<app>-<name>/_data on a default Docker installation.
  • File Mount -- /var/lib/sh0/file-mounts/<app-id>/<mount-id>/<file name>, bind-mounted onto the container path.
  • Bind Mount -- wherever you point it, provided the path is under an allowed root: /var/lib/sh0/mounts by default for an installation running as root. The environment variable SH0_BIND_MOUNT_ROOTS of the sh0 service replaces that list (colon-separated paths); set to an empty value, it refuses every bind mount.
Terminal
$ docker volume inspect sh0-my-api-data --format '{{.Mountpoint}} {{json .Labels}}'
/var/lib/docker/volumes/sh0-my-api-data/_data {"sh0.app_name":"my-api","sh0.managed":"true","sh0.mount_path":"/data"}

A host path outside the allowed roots is refused when the mount is created (actual response):

Response
{"error":{"code":"VALIDATION_ERROR","message":"Bind mount source '/etc' is outside the allowed roots (/var/lib/sh0/mounts). Set SH0_BIND_MOUNT_ROOTS to authorise another root."}}

sh0 does not change ownership or permissions of mounted data. The container writes as the user its image declares: an image that runs as root writes as root; an image with a non-root USER needs a directory that user can write to.

Importing data from another platform

To move an app's files from another host, archive them there, then unpack the archive into the sh0 volume with sh0 volumes <app> import. The archive (tar, plain or gzip/bzip2/xz compressed, up to 500 MB) goes through Docker into the volume itself, whether the app is running, stopped or not deployed yet. The usual migration order is: sh0 apps create, sh0 volumes <app> create /data, the import, then the first sh0 deploy.

Terminal
$ sh0 apps create my-site --image nginx:1.27-alpine --port 80
[ok] Created 'my-site' (25fc66d6-82b4-4aea-998b-d3cb0844162e), not deployed: set its variables, then `sh0 deploy my-site`
$ sh0 volumes my-site create /usr/share/nginx/html
[ok] Volume sh0-my-site-25fc66d6-usr-share-nginx-html created, mounted on /usr/share/nginx/html at the next deploy
$ sh0 volumes my-site import /usr/share/nginx/html site.tar.gz
[ok] Imported site.tar.gz (215 bytes) into sh0-my-site-25fc66d6-usr-share-nginx-html at /
$ sh0 deploy my-site --wait
Warning
The bind mount trap. On the source platform, check what the container really mounts before archiving. Many panels (Easypanel among them) mount a host directory (Type: bind) rather than a Docker volume: the files live in that directory, and a Docker volume of a similar name -- its /var/lib/docker/volumes/<name>/_data -- can be empty, all the more visibly when the container is stopped and nothing is being written. Archiving _data then migrates an empty directory without a single error. The same applies on the sh0 side: never copy files into _data by hand, use the import.

Read the source of each mount from the container itself, and archive from that path:

Terminal
$ docker inspect <container> --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}}{{println}}{{end}}'
$ tar -czf data.tar.gz -C <Source of the mount, as printed above> .

Removing a mount

Deleting a mount from the Volumes tab removes it from the next deployment. For a volume, sh0 also tries to delete the Docker volume, and Docker refuses as long as a container still uses it, which is the case while the app is running. The data then stays on disk and the volume reappears in the list with a Docker badge and no delete button. To really delete the data:

  1. Delete the mount in the Volumes tab.
  2. Redeploy the app, so that no container uses the volume any more.
  3. Delete the volume through the API:
Terminal
$ curl -X DELETE https://panel.example.com/api/v1/apps/<app-id>/volumes/sh0-my-api-data \
    -H "Authorization: Bearer $SH0_TOKEN"
Warning
Deleting an app does not delete its volumes, neither from the dashboard nor with sh0 delete. Only the API call DELETE /api/v1/apps/<id>?delete_volumes=true removes them. Otherwise a later app with the same name and the same mount name finds the old data again.

Limits

  • No size limit and no resizing. A volume grows until the host disk is full. Watch the disk of the server.
  • One mount per container path. A second mount on the same path is refused: “A mount already targets '/data'. Delete it first, or choose another target.”
  • No automatic snapshot. A mount is not backed up by itself; backups are configured separately, from the Backups page.
  • Local only. Volumes live on the server that runs the app; there is no network or object-storage backend for mounts.