Tested with PostgreSQL 18.6 · postgres:18.6-alpine / 18.6-trixie · Docker Engine 29.5 · Compose 2.40 · verified

Updated: 2026-09-16

PostgreSQL is the relational database that self-hosted applications such as Wiki.js and Outline recommend or require, and in Docker it starts within seconds once the image is downloaded. This guide brings up a PostgreSQL 18.6 server from one docker-compose.yml file, keeps its data in a persistent volume and shows what the 18 image does with the old data path. You then set the essential environment variables, connect other containers over the internal network, make backups with pg_dump and add a healthcheck so the database starts up reliably. The same explanation is available in Spanish.

Key takeaways

  • PostgreSQL describes itself as the world’s most advanced open-source relational database. Its newest stable major version is PostgreSQL 18, released on 25 September 2025, and the current minor is 18.6, from 13 August 2026.
  • The official postgres image on Docker Hub comes in Debian variants (trixie by default, bookworm too) and Alpine ones (postgres:18.6-alpine, currently on Alpine 3.24). According to the Docker Hub API, queried on 16 September 2026, the compressed amd64 download is 120.0 MB for Alpine versus 162.4 MB for Debian trixie.
  • Pin the minor version, such as postgres:18.6-alpine, instead of :latest. A floating tag can jump to a new major version on the next docker compose pull, and 18-alpine changes minor version without you deciding it.
  • In PostgreSQL 18 the data path changed: the volume is mounted at /var/lib/postgresql (previously /var/lib/postgresql/data) and the cluster lives in /var/lib/postgresql/18/docker. With the old path, the 18 container refuses to start.
  • The server listens on port 5432, and other containers in the same stack connect to it by the service name (db), without exposing the port to the Internet.

Why run PostgreSQL in Docker?

PostgreSQL is an object-relational database management system with more than 35 years of development behind it. Nextcloud, Paperless-ngx, Wiki.js, Gitea and Outline can all use it as their main store, and Outline requires it. Running it in a container has a clear advantage: instead of installing packages on the host, managing repositories and dragging in dependencies, you start a ready-made, isolated image. All the configuration stays in one file you can version.

The official image handles the heavy lifting for you. The first time it starts, it runs initdb, creates the superuser and the initial database from the environment variables, and leaves the server listening on port 5432. If one day you want to store embeddings and do semantic search, there is a variant with the pgvector extension, which we cover in the PostgreSQL with pgvector guide. Here we focus on plain PostgreSQL as a general-purpose relational database.

What does the docker-compose.yml with a persistent volume look like?

Create a folder for the project and save this docker-compose.yml inside it. Change the cambia_esta_clave_segura value to your own strong password before starting:

services:
  db:
    image: postgres:18.6-alpine
    restart: unless-stopped
    shm_size: 128mb
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: cambia_esta_clave_segura
      POSTGRES_DB: appdb
    volumes:
      - pg_data:/var/lib/postgresql
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  pg_data:

Three details are worth pausing on. The named volume pg_data is mounted at /var/lib/postgresql, the correct path as of PostgreSQL 18, and the image keeps the cluster in its 18/docker subdirectory. If you copy an old guide that mounts /var/lib/postgresql/data, the container never gets as far as starting, as the next section shows. To understand the difference between a named volume and a bind mount, see the guide on Docker volumes and bind mounts.

The shm_size: 128mb option widens the shared memory, because Docker limits /dev/shm to 64 MB by default (I checked with df inside the container) and parallel queries or large indexes can run short. And by publishing the port as 127.0.0.1:5432:5432 you keep it reachable only from the machine itself, never exposed to the Internet.

With the file ready, start the stack in the background and check the status:

docker compose up -d
docker compose ps
docker compose logs -f db

The first time, PostgreSQL initialises the data directory and creates the appdb database. In my tests with 18.6 (arm64, 18 cores, a load average between 1 and 24 from other processes), the log showed database system is ready to accept connections 2 to 4 s after docker compose up -d. From that line on, the server is up.

What happens if you mount /var/lib/postgresql/data with PostgreSQL 18?

The container refuses to start, and since it never writes anything, it loses nothing either. I tested it with postgres:18.6-alpine and postgres:18.6-trixie, with an empty volume and only its line changed to pg_data:/var/lib/postgresql/data. In both variants the process exits with code 1, restart: unless-stopped relaunches it in a loop (Restarting (1)) and docker compose logs --no-log-prefix db starts like this:

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.

       See also https://github.com/docker-library/postgres/pull/1259

       Counter to that, there appears to be PostgreSQL data in:
         /var/lib/postgresql/data (unused mount/volume)

The message comes from the image’s entry script, docker-entrypoint.sh, which is identical in the Alpine and Debian variants of 18.6. When PGDATA has its default value and holds no cluster, the script looks for a PG_VERSION file in /var/lib/postgresql, /var/lib/postgresql/data and /var/lib/postgresql/*/docker. If there is no data but /var/lib/postgresql/data is a mount point, it records it as unused mount/volume and runs exit 1 before initdb.

Meanwhile, Docker creates an anonymous volume for /var/lib/postgresql, the path the image declares as its VOLUME, and all it holds is the empty 18/docker directory. Your pg_data volume stays empty. After docker compose down and up the error repeats, the previous anonymous volume is left orphaned and a new one appears. If you start with docker compose up -d --wait, Compose exits with code 1 and reports the container as unhealthy instead of leaving the loop silent.

A volume that already holds a PostgreSQL 17 cluster gets the same treatment. I created one with postgres:17.11 and another with postgres:17.11-alpine, and after switching to 18.6 the message points at /var/lib/postgresql/data, or at /var/lib/postgresql if you move the volume to the new path. The 18 image does not modify those files: after going back to 17.11, the test row was still there.

Do not switch variants on an existing volume either, because the postgres user has UID 999 on Debian and 70 on Alpine. A volume created with postgres:17.11 and opened with postgres:18.6-alpine failed with mkdir: can't create directory '/var/lib/postgresql/18/': Permission denied on the new path, and with the unused mount/volume notice on the old one.

To move the data from 17 to 18, the route I tested is the logical copy from the backups section. With 17 still running, dump the database with pg_dump -Fc, bring up 18 with a new volume at /var/lib/postgresql and restore with pg_restore. The alternative is pg_upgrade --link, which the new directory layout makes possible according to the error message itself; I did not test it for this guide.

The opposite mistake does lose data. The postgres:17 image still declares its VOLUME at /var/lib/postgresql/data, so with this docker-compose.yml it writes to an anonymous volume. I checked it with 17.11: after down and up, the container initialised an empty database and the query answered relation "notas" does not exist.

The 18 check has one exception, because it only applies with the default PGDATA. An old guide that also sets PGDATA: /var/lib/postgresql/data/pgdata starts on 18.6 and keeps the data across down and up, although each re-creation leaves another unused anonymous volume behind.

What environment variables does the image need?

The image is configured entirely through environment variables on the first boot. Only one is mandatory:

  • POSTGRES_PASSWORD: the superuser password. It has no default, so without it the container refuses to start.
  • POSTGRES_USER: the superuser name. If you omit it, postgres is used.
  • POSTGRES_DB: the database created at startup. If you do not set it, it takes the same name as POSTGRES_USER.
  • POSTGRES_INITDB_ARGS: extra arguments for initdb, for example --no-data-checksums. PostgreSQL 18 already enables the checksums that detect on-disk corruption by default (SHOW data_checksums returned on), and pg_upgrade requires the old and new clusters to match on that setting.

One important detail: these variables only take effect the first time, when the volume is empty and initdb runs. If you change POSTGRES_PASSWORD with data already present, the password does not update by itself: in my test, the new password failed with password authentication failed for user "app", and you will have to change it from inside with ALTER USER. That is why you should not put passwords in plain text inside a production docker-compose.yml: it is better to load them from an .env file or, even better, with environment variables and secrets in Docker.

How do you connect to PostgreSQL from other containers?

This is where Docker shines. All the services defined in the same docker-compose.yml share an internal network and discover each other by their service name. An application in the same stack connects to the database using db as the host and 5432 as the port, with no need to publish anything externally. The typical connection string would be:

postgres://app:cambia_esta_clave_segura@db:5432/appdb

Notice that the host is db, not localhost or an IP address: inside the Compose network, Docker’s internal DNS resolves db to the database container’s IP. For an app that depends on PostgreSQL, ideally it should not start until the database is healthy. Combine the earlier healthcheck with depends_on, as explained in the guide on healthchecks and restart policies; that is how I tested it, with a second service using this connection string.

If you would rather manage the database with a web interface instead of the command line, you can add a client such as Adminer or pgweb to the same network. We cover it in how to manage databases with Adminer and pgweb. And for a quick query from the container itself, psql is at hand:

docker compose exec db psql -U app -d appdb

How do you make backups with pg_dump?

The golden rule is that your data is worth far more than the container. The pg_data volume holds the whole database, but a logical dump with pg_dump is more portable: according to its documentation, it loads into newer major versions, although loading it into an older one is not guaranteed. Generate a dump in the custom format (compressed and suitable for selective restore) straight from the container:

docker compose exec db pg_dump -U app -Fc appdb > appdb-$(date +%F).dump

To restore it on a clean server, use pg_restore against the already-created database, with --if-exists next to --clean:

cat appdb-2026-09-16.dump | docker compose exec -T db \
  pg_restore -U app -d appdb --clean --if-exists

Without --if-exists, pg_restore tries to drop objects that do not exist yet in a freshly created database. In my test it recovered the data, but exited with code 1 and the notice warning: errors ignored on restore: 4. With --if-exists it exited with code 0, both on an empty database and on top of existing data, and that same pair of commands moved a 17.11 database to 18.6.

If you manage more than one database and want to include the global roles and permissions too, use pg_dumpall instead of pg_dump. Automate the dump with a cron job and keep the copies off the server; a copy that lives on the same machine as the database is not a real backup.

How do you tune performance and the healthcheck?

With the default configuration, PostgreSQL reserves a shared_buffers of 128 MB, a conservative value meant to start anywhere. On a server dedicated to the database with 1 GB of RAM or more, the documentation suggests starting at 25% of the system’s memory. You can pass startup parameters without touching postgresql.conf by adding a command to the service:

    command: >
      postgres
      -c shared_buffers=256MB
      -c max_connections=100
      -c work_mem=16MB

The healthcheck we already included uses pg_isready, the official tool that checks whether the server accepts connections. With interval: 10s and retries: 5, Docker marks the container as healthy as soon as the database responds, and as unhealthy if it stops. That status is exactly what depends_on: condition: service_healthy needs so your applications wait for a genuinely ready database, rather than failing to connect during startup.

Docker runs the first check a full interval after the container starts. That is why, in my tests, the server accepted connections at 3 s but the healthy status only arrived at 10 s. To shorten that wait, add start_period and start_interval to the block, which require Docker Engine 25.0 or later:

    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 1s

With those two fields, docker compose up -d --wait finished in 4.3 s instead of 11.6 s, with the machine at a load average of 24.

Frequently asked questions

Why should I not use the latest tag in production?

Because :latest follows the newest stable major version (today it is the same image as 18.6, per Docker Hub) and can drag you into a major version jump on the next docker compose pull. PostgreSQL 19 was at Beta 3 on 13 August 2026. By pinning postgres:18.6-alpine you decide when to upgrade: minor releases of 18 need no dump and restore, so raise the number and re-create the container after reading their release notes. A jump between major versions does require pg_upgrade or a dump and restore, and the 18 image refuses to start on a 17 cluster instead of attempting it on its own.

Is the data lost if I delete the container?

No, as long as you use a named volume mounted at /var/lib/postgresql like in this guide: I checked it with docker compose down and up on 18.6, with both Alpine and Debian. You would only lose the data with docker compose down -v, which also removes volumes. Mounting the old /var/lib/postgresql/data path on 18 deletes nothing, because the container stops with Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster". The case that does lose data is the reverse one: a 17 image with the volume at /var/lib/postgresql writes to an anonymous volume and starts with an empty database when re-created.

Should I expose port 5432 to the Internet?

No. Publishing it as 127.0.0.1:5432:5432 keeps it reachable only from the machine itself, which is the usual case when the app and the database live on the same server. If you really need remote access, do it through a VPN or an SSH tunnel and protect it with a strong password. Exposing PostgreSQL directly on the public network is one of the most common causes of data compromise.

Conclusion

With a single docker-compose.yml you have a persistent PostgreSQL 18.6 server, with its volume on the path 18 expects, its environment variables and a healthcheck that guarantees reliable startups. From here, the database is ready to serve the rest of your self-hosted infrastructure.

Connect your applications by the service name and add a web manager with Adminer or pgweb. Above all, schedule regular backups with pg_dump and keep them off the server. That is the difference between having a database and having a database you can recover.

Sources

  1. Official PostgreSQL image on Docker Hub
  2. Official PostgreSQL documentation
  3. PostgreSQL 18 announcement (postgresql.org)
  4. PGDATA and volume change in the image (docker-library/postgres, GitHub)
  5. PostgreSQL 18.6 and 19 Beta 3 announcement (postgresql.org)
  6. PostgreSQL versioning policy
  7. docker-entrypoint.sh for the 18 image (docker-library/postgres, GitHub)
  8. PostgreSQL 18 release notes
  9. pg_dump documentation
  10. pg_restore documentation
  11. PostgreSQL 18 memory settings
  12. HEALTHCHECK reference (Docker Docs)

Route: Self-hosted databases and storage with Docker