2026.10 / database storage 067
Coolify PostgreSQL permission denied on the data directory
A PostgreSQL resource in Coolify can enter a restart loop after a storage edit, server move, restore, image change, or Compose redeploy. Its log may say Permission denied, could not open file, could not change permissions of directory, or data directory has wrong ownership. The database port never becomes ready because PostgreSQL cannot safely use the directory holding its cluster.
The tempting fix is chmod -R 777. Do not do that. It hides the boundary you need to understand, grants unrelated users write access to database files, and may still fail when the real cause is a read-only mount, the wrong destination, NFS policy, SELinux, or an image running under a different numeric identity.
Preserve the storage first. Prove the effective mount, the PostgreSQL process identity, and the failing path. Then change the narrowest ownership, mode, mount flag, or security policy that is actually wrong.
Start with the exact error and the event that preceded it
Open the PostgreSQL resource logs in Coolify and capture the first storage error from one startup attempt. Later messages such as a failed health check or refused application connection are consequences. Note whether the failure began after any of these events:
- a named volume was replaced with a bind-mounted host directory;
- the resource moved to another Coolify server or destination;
- files were restored or copied as
root; - the PostgreSQL image, major version,
user:, orPGDATAchanged; - a mount became read-only or its source path changed;
- host security policy, user namespaces, or a network filesystem was introduced.
Keep the real host path, container name, database name, internal hostname, and addresses out of public tickets. The generic placeholders below are deliberate.
1. Stop repeated starts without deleting storage
If the container is rapidly restarting, stop the database from the Coolify resource page while you inspect it. Do not delete the resource, its persistent-storage entry, or any Docker volume. Do not initialise an empty replacement directory over the incident. A permission fault usually means the existing files are inaccessible, not absent.
Before changing metadata, take a filesystem-level snapshot or copy if your storage and incident procedure allow it. PostgreSQL must be stopped for a plain file copy to be treated as a consistent cold copy. A crash-state copy can still be valuable forensic and recovery evidence, but it is not a substitute for a tested logical or physical backup.
2. Prove what is mounted where
Coolify normally gives a standalone PostgreSQL resource a Docker-managed persistent volume. A Compose service or manually changed resource may instead use a bind mount. Inspect the stopped container’s effective mounts rather than relying on the label shown in a Compose file or an old screenshot:
docker inspect DATABASE_CONTAINER \
--format '{{range .Mounts}}{{printf "%s\t%s\t%s\t%v\n" .Type .Source .Destination .Mode}}{{end}}'
# Also inspect the configured runtime user and image
docker inspect DATABASE_CONTAINER \
--format 'user={{.Config.User}} image={{.Config.Image}}'
Record the mount type, source, destination, and mode privately. A destination typo matters: a bind mount obscures whatever the image contained at that destination. Mounting the wrong empty directory can therefore look like missing data, while mounting the intended directory read-only can produce a direct write failure.
Check the PostgreSQL image generation before assuming the destination. Coolify documents /var/lib/postgresql/data for PostgreSQL 17 and earlier, but /var/lib/postgresql for PostgreSQL 18 and later. The official image also changed its default PGDATA layout in version 18. Copying a pre-18 mount target into an 18 deployment without a supported upgrade plan is both a path problem and a major-version migration risk.
3. Identify the numeric identity that needs access
Linux storage permissions use numeric user and group IDs. The name postgres inside a container does not make a host directory owned by an unrelated host account named postgres compatible. Coolify’s bind-mount documentation is explicit that Docker does not translate ownership between the host and container.
Use the exact image and configuration from the stopped resource. If the image supports a harmless identity query without starting PostgreSQL, inspect it in a disposable container:
docker run --rm --entrypoint sh POSTGRES_IMAGE \
-lc 'id postgres; getent passwd postgres || true'
If Compose sets user:, or Coolify’s effective container configuration has a non-empty user, that override may be the identity which must traverse and write the mount. Do not assume a universal UID such as 999; image variants and explicit overrides differ. Also distinguish the entrypoint’s initial identity from the long-running PostgreSQL process. Some images start an entrypoint with elevated privileges so it can prepare the directory and then drop privileges; forcing a user can remove that preparation step.
4. Inspect ownership and every parent directory
For a bind mount, inspect the source on the deployment server using numeric IDs. The database user needs execute permission to traverse each parent, and the required access on the data directory itself:
namei -l /srv/example/postgres-data
stat -c 'mode=%a owner=%u group=%g path=%n' \
/srv/example/postgres-data
# Inspect only; do not paste database filenames publicly
find /srv/example/postgres-data -xdev -maxdepth 2 \
-printf '%m %U %G %p\n'
Replace the path with the verified mount source. Do not run a broad recursive ownership command yet. First answer: is only the top directory wrong, are restored files owned by a different numeric user, is one parent untraversable, or is the whole copied tree inconsistent?
For a named volume, get its real mountpoint from Docker rather than guessing under /var/lib/docker:
docker volume inspect DATABASE_VOLUME \
--format '{{.Mountpoint}}'
Treat that path as sensitive operational detail. Avoid manually editing Docker’s storage tree unless the resource is stopped, the volume is unquestionably identified, and a recovery copy exists.
5. Rule out a read-only or unsuitable filesystem
Correct owners cannot override a read-only mount. Check the mount mode from docker inspect, the Compose definition, and the host filesystem:
findmnt -T /srv/example/postgres-data \
-o TARGET,SOURCE,FSTYPE,OPTIONS
df -h /srv/example/postgres-data
df -i /srv/example/postgres-data
A full filesystem normally reports No space left on device, but it is worth checking bytes and inodes before editing ownership. If capacity is the fault, use the separate Coolify Docker disk recovery guide and protect database volumes from broad prune commands.
Network filesystems need extra caution. Root squashing, server-side ACLs, UID mapping, locking semantics, and durability behaviour can make a host-side chown ineffective or unsuitable for a live PostgreSQL data directory. Do not weaken an export globally to make an error disappear. Verify that the chosen storage is supported for PostgreSQL and fix identity and policy at the storage boundary.
6. Check SELinux, AppArmor, ACLs, and user namespaces
Traditional mode bits can look correct while another access-control layer rejects the operation. Check only the mechanisms active on the deployment server:
getfacl -p /srv/example/postgres-data
ls -Zd /srv/example/postgres-data
# Look for recent mandatory-access-control denials privately
journalctl --since '15 minutes ago' | grep -Ei 'denied|apparmor|avc'
On an SELinux host, use a label appropriate to the container runtime and the intended sharing model; do not casually apply recursive relabel options to broad system paths. Docker warns that bind-mount label options change the host path itself and can be dangerous on system directories. With rootless Docker or user-namespace remapping, the visible container UID may map to a different host UID, so establish the mapping before changing owners.
7. Repair the narrowest proven cause
The repair depends on the evidence:
| Proven cause | Narrow repair |
|---|---|
| Wrong owner after a stopped restore or copy | Change ownership only on the verified database tree to the verified numeric runtime identity. |
| Parent directory blocks traversal | Grant the required identity traversal without making the parent world-writable. |
| Mount is accidentally read-only | Correct the Coolify or Compose storage definition and recreate the container. |
Wrong destination or PGDATA | Restore the image-version-appropriate mount and data path; do not initialise over old files. |
| SELinux/AppArmor/ACL denial | Apply a scoped policy or label supported by the host and container runtime. |
| NFS or user-namespace mapping mismatch | Correct the storage-side or namespace mapping; do not compensate with 777. |
A generic ownership command is intentionally not provided here because the correct IDs must come from the running image and its effective configuration. The safe shape, after stopping PostgreSQL and taking a recovery copy, is:
# Shape only — replace every placeholder from verified evidence
chown -R VERIFIED_UID:VERIFIED_GID VERIFIED_DATABASE_SOURCE
# Apply only the restrictive directory/file modes required by
# the image and PostgreSQL; never use chmod -R 777.
For a new production deployment that does not need a predictable host path, consider returning to a Docker-managed named volume. Coolify recommends volume mounts as the default when no specific server directory is required because they reduce the chance of unsafe paths and incorrect server permissions. Moving existing data still requires a controlled stopped copy, preserved ownership, correct destination, and restore verification.
8. Start once and prove that the original cluster is live
- Start the PostgreSQL resource once in Coolify and follow the current logs.
- Wait for a terminal healthy state; make sure the restart count is stable.
- Confirm the log identifies the expected existing cluster rather than running first-time initialisation.
- Connect through the private Internal URL without printing credentials.
- Check the expected databases, schemas, extensions, and representative row counts.
- Check record recency so an old or empty cluster cannot pass by name alone.
- Load one application route that requires a real database read.
- If safe, perform one disposable write, read it back, and remove it.
- Run a fresh backup, restore it into isolation, and verify the restored data.
An accepting port and a green pg_isready check prove availability, not identity or completeness. If the application still cannot connect after PostgreSQL is healthy, move to the Coolify PostgreSQL connection guide. If the backup fails after recovery, verify the client/server tool boundary with the pg_dump version-mismatch guide.
Common wrong fixes
chmod -R 777: creates unnecessary write access and does not fix read-only mounts or policy denials.- Assuming UID 999: numeric identities vary by image and overrides; inspect the effective deployment.
- Deleting the volume: converts an access problem into data loss.
- Letting PostgreSQL initialise an empty path: a healthy new cluster is not recovery of the old one.
- Changing the image major to “see if it starts”: PostgreSQL data directories require a supported major-version upgrade, not tag roulette.
- Changing only the host directory name: the container destination and
PGDATAstill decide where PostgreSQL reads and writes. - Running recursive changes while the database writes: stop it and preserve recovery evidence first.
- Trusting health alone: prove expected, recent data and a database-backed application path.
Compact incident order
- Capture the first permission error and recent change.
- Stop the restart loop without deleting storage.
- Preserve a snapshot or controlled copy.
- Inspect the effective mount type, source, destination, mode, image, user, and
PGDATA. - Resolve the image’s numeric runtime identity.
- Inspect numeric ownership, parent traversal, filesystem flags, capacity, ACLs, and active security policy.
- Repair only the proven mismatch.
- Start once and verify the expected existing cluster and application data.
- Create and restore-test a fresh backup.
- Record the storage destination and identity requirements in the deployment runbook.
The durable lesson is that a PostgreSQL container is replaceable but its storage contract is not. In Coolify, that contract includes the exact source, destination, image generation, PGDATA, numeric identity, mount mode, and host security policy. Make those facts explicit and a permission incident becomes a bounded storage repair instead of a dangerous guessing exercise.
Authoritative references: Coolify documents bind mounts, numeric ownership, and safer volume defaults and its PostgreSQL data paths for versions before and after 18. Docker explains bind-mount behaviour, read-only options, obscured image data, and SELinux cautions. The official PostgreSQL image documents its PGDATA and volume-path changes, while PostgreSQL recommends a dedicated operating-system account that owns only database-managed data.