Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Volumes

Container filesystems are ephemeral by default — once a container is removed, anything written to its layers is gone. Production deployments need volumes for the data that should survive container restarts + upgrades: database storage, uploaded files, generated config, etc.

Perry supports the three Compose-spec volume modes:

ModeSpec exampleUse case
Named volume["app-pgdata:/var/lib/postgresql/data"]Database state, durable per-app data.
Bind mount["./config:/app/config:ro"]Host-supplied config or secrets.
System pass-through["/etc/timezone:/etc/timezone:ro"]Read-only access to host system files.

Declaring named volumes

Named volumes must be declared at the spec root and referenced by name in each service’s volumes array:

const stack = await up({
  services: {
    db: {
      image: "postgres:16-alpine",
      volumes: ["app-pgdata:/var/lib/postgresql/data"],
    },
  },
  volumes: {
    "app-pgdata": { driver: "local" },
  },
});

Recognised ComposeVolume fields:

FieldTypeEffect
driverstringVolume driver ("local" is the default).
externalbooleanDon’t create — assume the volume already exists.
namestringOverride the volume’s runtime name.

Bind mounts

For host-supplied data, use the host:container[:options] form:

volumes: [
  "./config:/app/config:ro",     // read-only config dir from host
  "/var/log/myapp:/app/logs",    // bidirectional logs
],

Permissions are governed by the host filesystem and the container’s running UID. If the container runs as a non-root user (as it should — see Security), make sure the host directory is owned by a matching UID, or explicitly set the container UID via USER_UID / USER_GID env vars in the image (the Forgejo image does this).

System pass-throughs

Read-only mounts of host system files are common for time / DNS / locale alignment:

volumes: [
  "/etc/timezone:/etc/timezone:ro",
  "/etc/localtime:/etc/localtime:ro",
],

Best-effort: hosts where the source path doesn’t exist (e.g. some minimal Alpine VMs) just see a missing mount source — docker tolerates it; the container falls back to UTC / system defaults.

Preservation on down()

By default, down(handle) preserves named volumes:

await down(stack);                       // containers + networks gone, volumes survive
await down(stack, { volumes: false });   // same — explicit preserve
await down(stack, { volumes: true });    // ⚠ volumes ALSO removed (DESTROYS DATA)

This matches docker compose down semantics:

CommandContainersNetworksVolumes
down(handle)removedremovedkept
down(handle, { volumes: true })removedremovedremoved

After a down(handle), you can up(spec) again with the same volume declarations and the database / file state from before is still there. That’s how the Forgejo example supports “deploy → tear-down → redeploy” cycles without data loss.

⚠️ Forgejo / Postgres redeploy gotcha: if you used randomly generated passwords or secret keys on the first deploy, the next redeploy with new random secrets will fail because postgres authenticates against the old password and Forgejo can’t decrypt the existing config dir with a different SECRET_KEY. For redeploys against the same volumes, set FORGEJO_DB_PASSWORD / FORGEJO_SECRET_KEY / FORGEJO_INTERNAL_TOKEN to stable values (e.g. via an .env file). The Forgejo example’s doc-comment has the canonical pattern.

External volumes

Mark a volume external: true to share it across stacks or to use a volume created by a different process (e.g. docker volume create team-shared-cache ahead of time):

volumes: {
  "shared-cache": { external: true, name: "team-shared-cache" },
},

External volumes are never removed by down(handle, { volumes: true }) — that flag only drops volumes the engine itself created. This matches docker-compose semantics; if you want the external volume gone, remove it explicitly with docker volume rm team-shared-cache.

Volume naming and ownership

Perry doesn’t currently namespace volume names by project — the name you write in the spec is the literal docker volume name. So forgejo-pgdata is created as the docker volume forgejo-pgdata, and two stacks both declaring forgejo-pgdata would share it.

For multi-stack isolation, prefix the volume name with the project / stack identifier:

volumes: {
  "myapp-staging-pgdata":   { driver: "local" },
  "myapp-production-pgdata": { driver: "local" },
},

Inspecting volume state

The perry/container and perry/compose modules don’t expose a JS inspectVolume() helper today — for now, inspect with the underlying runtime CLI:

docker volume ls --filter name=app-       # list app-prefixed volumes
docker volume inspect app-pgdata          # mountpoint, driver, labels
docker run --rm -v app-pgdata:/data \      # mount + inspect contents
  alpine ls -la /data

Backup patterns

The standard “tar the volume into the host” backup recipe:

docker run --rm -v app-pgdata:/data:ro -v $(pwd):/backup alpine \
  tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .

For a pure-Perry approach, drive that with perry/container.run():

await run({
  image: "alpine:3.19",
  cmd: ["sh", "-c",
    "tar czf /backup/pgdata-$(date +%F).tar.gz -C /data ."],
  volumes: [
    "app-pgdata:/data:ro",
    "./backups:/backup",
  ],
  rm: true,
});

See also