PostgreSQL 18 Docker: Fix Existing Volume Startup Errors
Separate a Docker mount failure from a PostgreSQL major upgrade, preserve the original volume and prove the restored database survives a restart.
TL;DR#
The official PostgreSQL 18 Docker image stores its cluster at /var/lib/postgresql/18/docker and declares its persistent volume at /var/lib/postgresql. A legacy mount at /var/lib/postgresql/data can now prevent startup. For a fresh database, mount the parent directory. For existing PostgreSQL data, preserve the old volume and perform a real major upgrade into a separate target volume. Moving files, changing PGDATA or changing the image tag does not upgrade the database format.
The old mount and the current diagnostic#
Docker image issue #1370 reports a container-runtime mount failure after changing to PostgreSQL 18. The report describes the old mount destination being absent. Treat that as the reported symptom, rather than a promise that every current image produces the same runc message.
The current image also detects old layouts itself. Reproduced on 2 November 2026 using the official Alpine images, PostgreSQL 17.11 and PostgreSQL 18.6, the latter printed this opening when the old cluster was mounted at the legacy path:
Error: in 18+, these Docker images are configured to store database data in a
format which is compatible with "pg_ctlcluster" (specifically, using
major-version-specific directory names). This better reflects how
PostgreSQL itself works, and how upgrades are to be performed.Further down, it identified /var/lib/postgresql/data. With a completely empty volume mounted at that same legacy destination, it instead identified:
/var/lib/postgresql/data (unused mount/volume)That distinction matters. The diagnostic can indicate an unused mount, not necessarily a database that needs conversion. Read the entire message and inspect the actual volume before choosing a migration procedure.
The reproduction used isolated named volumes and no published host ports. Removing just the unused legacy mount, while keeping the same PostgreSQL 18 image and parent volume, allowed a fresh database to initialise. Separately, restoring a logical backup from the old cluster preserved the test data across a restart.
Why the image layout changed#
The official image documentation specifies both the new data directory and the parent mount. Keeping major-version directories beneath one filesystem makes it possible to arrange old and new clusters for pg_upgrade --link without a mount boundary between them. That layout enables an upgrade arrangement; the entrypoint does not run the upgrade for you.
| Image configuration | Cluster directory by default | Persistent mount destination |
|---|---|---|
| PostgreSQL 17 and earlier, historical layout | /var/lib/postgresql/data | /var/lib/postgresql/data |
| PostgreSQL 18 | /var/lib/postgresql/18/docker | /var/lib/postgresql |
The directory named after a major version is organisational. Renaming an old cluster directory to the new version number changes its location, not its contents. The PostgreSQL upgrade documentation explains that major releases can change the internal storage format and require an upgrade method such as logical dump/restore or pg_upgrade.
As a negative control, pointing the new server directly at the old data directory produced this PostgreSQL error, after the entrypoint skipped initialisation:
2026-11-02 14:11:19.712 UTC [1] FATAL: database files are incompatible with server
2026-11-02 14:11:19.712 UTC [1] DETAIL: The data directory was initialized by PostgreSQL version 17, which is not compatible with this version 18.6.The old volume was read-only for that test. The server rejected its version; a different mount name or a permissions change would not have converted it.
Fix: preserve the source and restore into a new volume#
The following runbook is for a small, single-database installation where a maintenance window for dump/restore is acceptable. Its example database is app, owned by postgres. Substitute your actual container and database names. For multiple databases, repeat the database backup/restore and inventory roles, ownership, tablespaces and extensions before cutover. Install compatible extension libraries in the target image before restoring databases that require them.
1. Identify the original cluster before recreating anything#
Keep the old container configuration and image identity. If you already changed the tag, stop the failed new container and bring the original major version back with its original mount and settings. Do not run old and new servers against one data directory simultaneously.
For a running source container named db-old, inspect its mounts and ask the server which directory it uses:
docker inspect db-old --format '{{json .Mounts}}'
docker exec db-old psql -U postgres -d app -Atc 'SELECT version(); SHOW data_directory;'Read the mount's source name as well as its destination. In Compose, a project-name change can select a different named volume even when the YAML key looks identical. Record the real source volume, image digest, database names and extension versions with your recovery notes.
Pause application writes, background workers and scheduled jobs before the final backup. A logical dump is consistent, but writes accepted afterwards are absent from that dump. Keep writers paused through verification and the connection switch. Leaving the source available for recovery does not mean letting applications continue writing to it.
2. Create backups outside the disposable container#
Use the source container's tools for this demonstrated path. PostgreSQL recommends the newer dump client where available; the upgrade documentation also permits backing up with the version currently running. The local reproduction used the source-version client and restored successfully to the newer server.
docker exec db-old pg_dump -U postgres -d app --format=custom --file=/tmp/app.dump
docker cp db-old:/tmp/app.dump ./app.dump
docker exec db-old sh -c 'pg_dumpall -U postgres --globals-only > /tmp/globals.sql'
docker cp db-old:/tmp/globals.sql ./globals.sqlCheck every exit status before proceeding. Writing the archive inside the container and copying it out avoids routing binary dump bytes through a host shell's text pipeline. Preserve the host copies and protect the globals file: it can contain role password hashes.
pg_dump backs up one database, not every database or the cluster's global roles. The globals backup is an additional recovery artefact. In this example the only owner is the already-created postgres role, so it needs no globals restore. With application roles, restore the required role definitions before the database archive, accounting for existing bootstrap roles and tablespace paths. Do not blindly ignore restore errors from an unreviewed globals script.
3. Initialise an independent PostgreSQL 18 target#
Keep the original volume intact. Create a fresh target, using a strong password supplied in your shell environment:
docker volume create pg18-restored
docker run -d --name db18-restore -e POSTGRES_PASSWORD -e POSTGRES_DB=app --mount type=volume,src=pg18-restored,dst=/var/lib/postgresql postgres:18-alpine
docker exec db18-restore pg_isready -U postgres -d appThe new volume name must be unused for this migration. Repeat the readiness command until the server accepts connections; inspect logs if it exits instead. Supplying -e POSTGRES_PASSWORD forwards the host environment value, so set it first and check that it is non-empty. This example publishes no host port; administrative commands use docker exec.
For a fresh Compose deployment, the equivalent mount shape is:
services:
db:
image: postgres:18-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- pg18-restored:/var/lib/postgresql
volumes:
pg18-restored:This is a fresh target definition, not an instruction to attach the old database's files under a new path. If Compose rejects the environment section before starting anything, check mapping versus list syntax for environment variables; that YAML error happens before PostgreSQL examines any volume.
4. Inspect the archive and restore with error handling#
Copy the host archive into the new container, list its contents and restore into the empty example database:
docker cp ./app.dump db18-restore:/tmp/app.dump
docker exec db18-restore pg_restore --list /tmp/app.dump
docker exec db18-restore pg_restore -U postgres -d app --exit-on-error /tmp/app.dumpThe pg_restore documentation defines the archive listing and error behaviour. Listing an archive checks that its catalogue can be read; a successful restore and application checks establish much more. Without the explicit error option, restore normally continues after SQL errors and reports a count afterwards.
Do not restore over a target that has already received application migrations or writes. Use an empty target so object-exists errors do not obscure missing objects or ownership failures. If restoration fails, investigate and recreate only the disposable target when appropriate; the preserved source remains your recovery path.
This pre-created-database example verifies the fixture's schema and rows; it does not restore database-level comments, access privileges or database-specific configuration. The documented --create option also restores those database properties, including settings made with ALTER DATABASE ... SET and ALTER ROLE ... IN DATABASE ... SET. If your database uses them, choose that mode from the outset: initialise a fresh target without POSTGRES_DB=app, restore required roles first, and connect the restore tool to its maintenance database:
docker exec db18-restore pg_restore -U postgres -d postgres --create --exit-on-error /tmp/app.dumpThat alternative creates the database named in the archive; it requires that database to be absent. Do not run it after the preceding restore into an already-created app database. Verify database permissions, comments and configuration as part of acceptance, alongside application data.
Verify the fix#
For the actual reproduction containers, these commands confirmed the target's directory and checked the restored fixture before and after restarting it:
docker exec aotd-pg18-volume-new psql -U postgres -d app -Atc 'SELECT version(); SHOW data_directory;'
docker exec aotd-pg18-volume-new psql -U postgres -d app -Atc "SELECT count(*), md5(string_agg(id::text || ':' || note, ',' ORDER BY id)) FROM upgrade_probe;"
docker restart aotd-pg18-volume-new
docker exec aotd-pg18-volume-new pg_isready -U postgres -d app
docker exec aotd-pg18-volume-new psql -U postgres -d app -Atc "SELECT count(*), md5(string_agg(id::text || ':' || note, ',' ORDER BY id)) FROM upgrade_probe;"Wait for readiness after restart before issuing the final query. The fixture contained three rows, and its aggregate matched on the source, restored target and restarted target:
3|a0ba80a04d840103877da6f6cdcf2f44For your db18-restore container, adapt those commands and replace the fixture query with checks appropriate to your application. Compare important table counts, representative records, sequences, ownership and extension availability. Run application reads and a controlled write test. A successful pg_isready says the server accepts connections; it does not prove your database objects or data were restored.
Only after those checks should you switch the application connection, initially keeping writers controlled. Preserve the old cluster and backups until the migration has been accepted. Once writes enter the new database, switching back to the old server loses those changes unless you arrange a reverse migration or another reconciliation procedure.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
An empty database can look like a successful path fix#
The current entrypoint source scans the parent directory as well as the legacy and version-specific directories for PG_VERSION. Reparenting a standard old volume therefore triggers its old-database rejection; it does not normally initialise a fresh cluster beside those old files. The additional reproduction with the original volume mounted directly at /var/lib/postgresql confirmed that rejection.
A different or missing volume, or old files outside the recognised layouts, can still leave the configured data directory empty. A newly initialised server is therefore not proof that old data was found. Check the actual mount source and database contents before restarting application workers or running schema migrations against what may be an empty target.
For large databases, plan pg_upgrade separately using its compatibility checks and both server versions. The new directory layout helps arrange that work, but moving directories alone never performs it. Avoid deleting volumes as a generic response to this startup error; removing an unused legacy mount from a fresh deployment and deleting an existing database volume are very different operations.
If the migrated server starts but the application reports password authentication failed, check the stored role credentials rather than changing the initialisation password variable on an existing cluster. If the next message is ECONNREFUSED at the app container's loopback address, correct the Compose service hostname and readiness handling. Those failures occur after the volume and major-version problems have been resolved.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix ECONNREFUSED 127.0.0.1:5432 in Docker Compose (Node)
Your Node app and Postgres both start, yet every query fails with connection refused. Read the address in the error, fix the host, and gate startup on pg_isready.
Fix Postgres 5432 bind: address already in use in Docker
Tell the Docker messages apart, find what owns port 5432 on Linux, macOS or Windows, and pick between stopping it and moving the published port.
Fix PostgreSQL Server Won't Start on Mac OS X (2026)
A fresh Homebrew install of PostgreSQL on a Mac completes without errors, then psql answers connection refused because the server was never started. Which command starts it depends on


