Multiple Postgres Databases in One Docker Compose Service
Create multiple Postgres databases in one Docker Compose service using an init script. Updated for Postgres 18 volume paths, per-user permissions and backups.
32 min read

The official postgres Docker image creates exactly one database: the one you name in POSTGRES_DB. There is no hidden multi-database env var. The upstream feature request (docker-library/postgres#151) was closed without one. To get multiple PostgreSQL databases in one Docker Compose service you drop an init script into /docker-entrypoint-initdb.d and let the image entrypoint run it on first boot. That is still the officially documented pattern, and it works the same on PostgreSQL 18.
This guide builds one container hosting db1, db2, and db3 from a single init script, then adds a second variant that gives each database its own user and password. It is updated for the PostgreSQL 18 volume path change, the PostgreSQL 15+ schema permission fix, and docker compose v2 commands throughout. If all you need is the “docker postgres create multiple databases” job done, Steps 1, 2, and 4 are the whole happy path.
Updated for PostgreSQL 18
Using postgres:18 or newer? Mount /var/lib/postgresql, NOT /var/lib/postgresql/data. On 18+ images PGDATA moved to /var/lib/postgresql/18/docker, so the old mount silently stops persisting data and everything disappears when the container is recreated. The correct volume blocks are in “Postgres 18+ volume path change” under Step 2. On postgres:17 and older, keep the old /var/lib/postgresql/data path: mounting the parent directory there does not persist data either.
Why run multiple PostgreSQL databases in one container
Four reasons this pattern keeps showing up:
- Dev and test environments. One container holds every database your app touches. One
docker compose up, one log stream, one thing to reset. - Resource efficiency on a small VPS. A single Postgres process means one buffer pool and one set of background workers instead of N copies. On a 2 vCPU / 4 GB box that difference is real.
- Multi-tenant apps. One database per tenant, still managed as a single service with a single backup schedule.
- Legacy applications. Some apps expect several databases on one server. Replicating that in Docker makes the migration boring instead of interesting.
When not to do this
One container means shared max_connections, shared memory, and shared blast radius. A bad query in db1 can starve db3. A single upgrade point is convenient until it is an outage. For anything with different uptime, lifecycle, or compliance needs, run separate Postgres containers. This pattern is a good default for dev stacks and small side projects, not for hard multi-tenant isolation.
Cost-wise this is hard to beat: the whole stack fits on a single EUR 4-6/month VPS. I would run it on a Hetzner Cloud VPS and keep a nightly dump off-box. A related single-container pattern is how I deploy PGvector and pgAdmin on Docker for local embedding work.
Prerequisites
- Docker Engine with the Compose v2 plugin. The old
docker-composev1 binary is deprecated; every command below usesdocker compose(space, no hyphen). - A Linux VPS or local Docker host you can run
dockeron. - A project directory for the compose file, init script, and data.
- Basic
psqlfamiliarity. - Port 5432 free on the host, or pick another (5433 works fine).
Check what you have:
docker compose versionExpect something like Docker Compose version v2.x.x. If that command fails, install the docker-compose-plugin package or update Docker before continuing.
compose.yaml vs docker-compose.yml
Docker now documents compose.yaml as the preferred filename. The old docker-compose.yml still works and Compose picks it up automatically. The examples below use compose.yaml.
Step 1: create the init script for multiple databases
The postgres image runs every script in /docker-entrypoint-initdb.d on first start, sorted by name, as the bootstrap superuser. The entrypoint sources or executes shell scripts (.sh) and feeds SQL files (.sql) to psql. This is the docker-entrypoint-initdb.d multiple databases pattern: there is no native flag, just these hooks.
Create init-multiple-databases.sh:
#!/bin/bash
set -e
set -u
function create_user_and_database() {
local database=$1
echo " Creating user and database '$database'"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "postgres" <<-EOSQL
CREATE USER $database;
CREATE DATABASE $database OWNER $database;
GRANT ALL PRIVILEGES ON DATABASE $database TO $database;
EOSQL
}
if [ -n "${POSTGRES_MULTIPLE_DATABASES:-}" ]; then
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
for db in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do
create_user_and_database "$db"
done
echo "Multiple databases created"
fiThis creates one user and one database per name in POSTGRES_MULTIPLE_DATABASES, with the user owning the database. CREATE DATABASE ... OWNER ... matters more than it looks: on PostgreSQL 15+ it is the difference between “connects” and “can create tables”. More on that in the per-user section and troubleshooting.
Two flags do the safety work. set -e aborts the script on the first error, and ON_ERROR_STOP=1 makes psql exit non-zero on any SQL error. Combined, a bad run fails loudly instead of leaving you a half-initialized cluster that looks fine.
Init scripts run only once
Scripts in /docker-entrypoint-initdb.d execute only against an empty data directory. Adding db4 to POSTGRES_MULTIPLE_DATABASES next week does nothing: the cluster is already initialized and the scripts are skipped. See “Adding a database later” for the fix.
If the script fails mid-run, the entrypoint exits. On the next start with a half-initialized data directory, the remaining scripts will not run either. Fix the script, then wipe and re-init (dev only) or repair manually with psql.
Step 2: Docker Compose file for multiple Postgres databases
Create compose.yaml next to the init script:
services:
postgres:
image: postgres:18.6-alpine
container_name: postgres_multi_db
restart: unless-stopped
shm_size: 128mb
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_MULTIPLE_DATABASES: db1,db2,db3
volumes:
- ./init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh:ro
- ./postgres_data:/var/lib/postgresql
ports:
- 127.0.0.1:5432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d db1"]
interval: 5s
timeout: 3s
retries: 10What each part buys you:
POSTGRES_MULTIPLE_DATABASES: db1,db2,db3is a plain comma-separated list. The init script splits on commas and creates a user plus database per name. All of them share the same login (POSTGRES_USER) in this variant.- The init script is mounted
:ro. It is only read at first init, so read-only is the correct default. 127.0.0.1:5432:5432binds to loopback only. If another app on the same host needs Postgres, that is all you need. Do not publish5432:5432to the world unless you really mean it.- The
pg_isreadyhealthcheck gives you a real readiness signal instead of “read the log and squint”. It is what makesdocker compose up --waitwork in Step 4. shm_size: 128mbmatches the official image example. Postgres uses shared memory for parallel queries; the Docker default (64 MB) is small.
The version: top-level key is deprecated and ignored by modern Compose, so leave it out. Pin the minor version (postgres:18.6-alpine) if you want reproducible builds, or float on postgres:18-alpine and accept minor bumps. Do not use postgres:14 or postgres:15 from old tutorials: see the version notes at the end.
If you want a UI next to your compose stacks, manage Compose stacks with Dockge instead of SSH-ing in for every docker compose ps.
Postgres 18+ volume path change (mount /var/lib/postgresql)
This is the change that bites people. In PostgreSQL 18 images, PGDATA moved to a version-specific directory: /var/lib/postgresql/18/docker. The image VOLUME moved up one level to /var/lib/postgresql (docker-library/postgres PR #1259, merged 2025-06-06). The postgres 18 docker volume mount path rule is now keyed to the major version:
| Image major | Bind mount | Where data lands |
|---|---|---|
| 18+ | ./postgres_data:/var/lib/postgresql |
./postgres_data/18/docker (persisted) |
| 17 and older | ./postgres_data:/var/lib/postgresql/data |
./postgres_data (persisted) |
PostgreSQL 18+
volumes:
- ./init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh:ro
- ./postgres_data:/var/lib/postgresqlPostgreSQL 17 and older
volumes:
- ./init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh:ro
- ./postgres_data:/var/lib/postgresql/dataData loss on postgres:18 with the old path
If you bump the image to postgres:18 while keeping the ./postgres_data:/var/lib/postgresql/data mount, writes go to the image’s own PGDATA (/var/lib/postgresql/18/docker), which falls on an anonymous volume. The bind mount sits empty. docker compose down followed by up recreates the container, the anonymous volume goes away, and your databases are gone. Symptom: docker volume ls shows anonymous volumes and postgres_data is nearly empty. Fix the mount path before the first up.
The inverse warning from the official README is just as important: on postgres:17 and older images, mounting /var/lib/postgresql (instead of /var/lib/postgresql/data) will NOT persist data to your bind mount. Key the mount to the major version and stop guessing.
One operator win from this change: because PGDATA now lives under the versioned path /var/lib/postgresql/18/docker, a major-version upgrade with pg_upgrade --link can run across container recreations against the same mounted directory. Old tutorials could not do that cleanly.
Step 3: set up environment variables (.env)
Put credentials in .env next to compose.yaml instead of hardcoding them:
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypasswordThe compose file already references them with guards: ${POSTGRES_USER:-postgres} falls back to postgres, and ${POSTGRES_PASSWORD:?set in .env} refuses to start if the password is missing. Then lock the file down:
chmod 600 .envCompose reads .env for interpolation, but it does not export anything to your shell. The psql commands below expand $POSTGRES_USER from the shell environment, so load the file once:
set -a; source .env; set +a
echo "$POSTGRES_USER"That last line must print myuser. If it prints nothing, the export step did not run and every psql -U "$POSTGRES_USER" below will fail with fe_sendauth or role "" does not exist. You can also just replace $POSTGRES_USER with the literal username in those commands.
Passwords in env vars are not secrets
Everything you put in environment: is visible to anyone who can run docker inspect, including POSTGRES_MULTIPLE_DATABASES when you use the db:user:pass format below. The image supports POSTGRES_PASSWORD_FILE, POSTGRES_USER_FILE, POSTGRES_DB_FILE, and POSTGRES_INITDB_ARGS_FILE for the standard variables, but the custom multi-DB variable gets no _FILE handling. For real secrets, see Docker Compose secrets and secure alternatives.
Step 4: run the stack and wait for readiness
docker compose up -d --wait--wait blocks until the pg_isready healthcheck passes and returns non-zero if the service never becomes healthy. That is a real readiness gate: if this command exits 0, you can connect. On a first boot this is where the docker postgres create multiple databases work happens: the entrypoint sources your init script before the server accepts connections. Do not use the legacy docker-compose up -d binary name.
The interesting lines in docker compose logs postgres look like this (timestamps trimmed):
postgres_multi_db | /usr/local/bin/docker-entrypoint.sh: sourcing /docker-entrypoint-initdb.d/init-multiple-databases.sh
postgres_multi_db | Multiple database creation requested: db1,db2,db3
postgres_multi_db | Creating user and database 'db1'
postgres_multi_db | CREATE ROLE
postgres_multi_db | CREATE DATABASE
postgres_multi_db | GRANT
postgres_multi_db | Creating user and database 'db2'
postgres_multi_db | CREATE ROLE
postgres_multi_db | CREATE DATABASE
postgres_multi_db | GRANT
postgres_multi_db | Creating user and database 'db3'
postgres_multi_db | CREATE ROLE
postgres_multi_db | CREATE DATABASE
postgres_multi_db | GRANT
postgres_multi_db | Multiple databases created
postgres_multi_db | PostgreSQL init process complete; ready for start up.
postgres_multi_db | database system is ready to accept connectionsEach database must produce CREATE ROLE, CREATE DATABASE, and GRANT. If you see sourcing ... init-multiple-databases.sh but no Multiple database creation requested, the env var never reached the container. If the container exits right after the sourcing line, the script failed and set -e aborted the init.
sh: locale: not found is expected on Alpine
Alpine-based images print sh: locale: not found and WARNING: no usable system locales were found during initdb. That is musl being musl. Harmless for dev. If you need proper collations, see troubleshooting below.
Confirm readiness:
docker compose psSTATUS must read healthy. If you are doing this on a Dokploy host (the original version of this article ran under /opt/stacks), the same compose file works as a Dokploy service: deploy a Docker Compose app in Dokploy.
Step 5: verify the databases
List the databases:
docker compose exec postgres psql -U "$POSTGRES_USER" -c '\l'You must see db1, db2, and db3 alongside postgres, template0, and template1. Abbreviated, the rows that matter look like this:
Name | Owner
----------+---------
db1 | db1
db2 | db2
db3 | db3
postgres| myuser
...Now prove permissions work. \l alone would never catch the PostgreSQL 15 schema bug:
docker compose exec postgres psql -U db1 -d db1 -c 'CREATE TABLE _probe(i int); DROP TABLE _probe;'Expect CREATE TABLE then DROP TABLE. Any permission denied here means the owner fix is missing; jump to troubleshooting.
docker compose psshows the servicehealthy.\llists db1, db2, db3.- The
CREATE TABLE _probe/DROP TABLEprobe succeeds as the per-database user. - Nice to have for the PG18 mount:
docker compose down && docker compose up -d --wait, then\lstill lists the databases. If they vanish, your mount path is wrong.
Different users and passwords per database
If each database should have its own login, switch to a db:user:pass format and fix the ownership at creation time. This is the variant that the old version of this article got wrong on PostgreSQL 15+.
PostgreSQL 15+: GRANT ALL is not enough
GRANT ALL PRIVILEGES ON DATABASE does not grant CREATE on the public schema. Since PostgreSQL 15 the public schema grants no CREATE to PUBLIC and is owned by pg_database_owner. The user connects fine and then every CREATE TABLE fails with ERROR: permission denied for schema public. The fix is one clause: CREATE DATABASE $database OWNER $user;
Replace init-multiple-databases.sh with:
#!/bin/bash
set -e
set -u
function create_user_and_database() {
local database=$1 user=$2 password=$3
echo " Creating user '$user' and database '$database'"
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL
CREATE USER $user WITH PASSWORD '$password';
CREATE DATABASE $database OWNER $user;
GRANT ALL PRIVILEGES ON DATABASE $database TO $user;
EOSQL
}
if [ -n "${POSTGRES_MULTIPLE_DATABASES:-}" ]; then
echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
for cfg in $(echo "$POSTGRES_MULTIPLE_DATABASES" | tr ',' ' '); do
IFS=':' read -r db user pass <<< "$cfg"
create_user_and_database "$db" "$user" "$pass"
done
echo "Multiple databases created"
fiAnd pass the tuples in compose.yaml:
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env}
POSTGRES_MULTIPLE_DATABASES: db1:user1:pass1,db2:user2:pass2,db3:user3:pass3Format is database:user:password, comma-separated. Two limitations to know before you copy this into production:
- Passwords cannot contain
:or'. The:breaks the field split and'breaks the SQL literal. Generate passwords with[A-Za-z0-9]only for this scheme, or move to a per-database env var setup. - These values are plain env vars.
docker inspectshows all of them. Fine on a dev laptop, not fine on a shared host.
Verify the fix the same way as Step 5, but as the per-database user:
docker compose exec postgres psql -U user1 -d db1 -c 'CREATE TABLE _probe(i int); DROP TABLE _probe;'If that returns CREATE TABLE / DROP TABLE, ownership is correct and user1 can build schema in db1.
Why does owning the database fix the public schema?
Before PostgreSQL 15, anyone with CONNECT could create objects in the public schema. PG 15 removed that PUBLIC CREATE permission and made pg_database_owner the owner of public. So the path to “this user may create tables here” is now: the user owns the database, pg_database_owner resolves to that owner for the database, and the owner naturally has CREATE on public. That is why CREATE DATABASE db1 OWNER user1 replaces the old CREATE DATABASE db1 + GRANT ALL combo. GRANT ALL PRIVILEGES ON DATABASE only touches database-level privileges (CONNECT, CREATE, TEMP), not schema-level ones.
Adding a database later (init scripts run only once)
The most common surprise with docker-entrypoint-initdb.d multiple databases setups: the scripts do not re-run. They fire only when the data directory is empty. Editing POSTGRES_MULTIPLE_DATABASES and restarting changes nothing. Three ways forward:
1. Create it manually (default, works everywhere). You need the role and the database:
docker compose exec postgres psql -U "$POSTGRES_USER" -c "CREATE USER user4 WITH PASSWORD 'pass4';"
docker compose exec postgres psql -U "$POSTGRES_USER" -c "CREATE DATABASE db4 OWNER user4;"
docker compose exec postgres psql -U "$POSTGRES_USER" -c "GRANT ALL PRIVILEGES ON DATABASE db4 TO user4;"Same one-liners work for the simple variant: CREATE DATABASE db4 OWNER db4;. Check it landed:
docker compose exec postgres psql -U "$POSTGRES_USER" -c "SELECT datname FROM pg_database ORDER BY 1;"2. Wipe and re-init (dev only).
docker compose down -v
docker compose up -d --waitdown -v deletes your data
docker compose down -v removes the data directory contents along with anonymous volumes. Every database in the cluster goes with it. Never run this against a stack that holds anything you care about. If you need a rollback path before a risky change, take a dump first (see the backup section).
3. Keep the script idempotent. If you really want a re-runnable script (for example mounted into a custom entrypoint), make the CREATE statements conditional:
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname postgres <<-EOSQL
SELECT 'CREATE USER user4'
WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'user4')\gexec
SELECT 'CREATE DATABASE db4 OWNER user4'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'db4')\gexec
EOSQLThat is useful for a “bring up whatever is missing” hook. The stock image still only runs init scripts once, so this pattern needs your own wrapper if you want it on every start.
Realistic trigger for all three: a new app lands on the server and wants its own database. You self-host Umami analytics on Docker or a wiki and it expects umami to already exist. Create the database manually (option 1) and move on.
Troubleshooting common errors
“permission denied for schema public”
ERROR: permission denied for schema public
LINE 1: CREATE TABLE foo (id int);Cause: PostgreSQL 15+ removed CREATE for PUBLIC on the public schema, and your per-database user does not own the database. Fix at creation time with CREATE DATABASE $database OWNER $user; or afterwards:
docker compose exec postgres psql -U "$POSTGRES_USER" -c "ALTER DATABASE db1 OWNER TO user1;"Retest with the CREATE TABLE _probe probe from Step 5.
Port 5432 already in use
Error response from daemon: driver failed programming external connectivity ... bind: address already in useFind the squatter:
ss -ltnp | grep 5432
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 5432Then either stop the other listener, remap to 127.0.0.1:5433:5432, or drop the ports: block entirely. If only containers need this Postgres, they can talk over the compose network without publishing any host port.
sh: locale: not found and “no usable system locales were found”
Expected on Alpine images. initdb probes for system locales, musl does not ship them, and Postgres logs a warning and falls back to en_US.utf8 / C collations. Harmless for dev and most apps. If you need real collation support (sorting, unique constraints on accented text), switch to the non-Alpine image or use ICU locales:
environment:
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=en-US"ICU locale support is available on Alpine-based images from postgres:15-alpine up (PostgreSQL 16+ images ship it).
Data disappeared after recreating the container
Either docker compose down -v ran at some point, or you hit the PostgreSQL 18 mount path mistake. Check:
docker volume ls
ls -la ./postgres_dataWith the wrong PG18 mount, postgres_data stays nearly empty while an anonymous volume holds your data until the container is recreated. Fix the volumes: entry to match your image major (see the table in Step 2), then restore from backup. There is no in-place recovery once the anonymous volume is gone.
Init script failed and now nothing runs
Symptom: container exits during first boot, log ends right after sourcing /docker-entrypoint-initdb.d/.... set -e and ON_ERROR_STOP=1 did their job. Fix the script (quoting, bad SQL, password with a '), then:
docker compose down -v
docker compose up -d --waitOnly safe before you have real data. If the cluster is half-initialized and holds data you need, connect as the bootstrap superuser and finish the CREATE statements by hand instead of wiping.
Check your image tag first
Most of these failures are version-skew problems. A tutorial written for postgres:14 will mislead you on postgres:18 (mount path) and on postgres:15+ (schema permissions). Confirm with docker compose exec postgres psql -c 'SELECT version();' before debugging anything else.
Backup and restore multiple databases
One container with N databases means N dumps plus the roles. The roles part is what everyone forgets: the per-database users created by the init script live in the global catalog, not inside any single database dump.
Dump every database in custom format:
for db in db1 db2 db3; do
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" -Fc "$db" > "${db}.dump"
doneDump roles, tablespaces, and grants:
docker compose exec -T postgres pg_dumpall --globals-only > globals.sqlRestore in the right order, globals first (roles must exist before databases can reference them):
docker compose exec -T postgres psql -U "$POSTGRES_USER" -f - < globals.sql
docker compose exec -T postgres pg_restore -U "$POSTGRES_USER" -d db1 --clean --if-exists < db1.dump--clean --if-exists makes the restore re-runnable over an existing database. Without those flags, pg_restore errors out on objects that already exist.
- One
pg_dump -Fcper database (loop overPOSTGRES_MULTIPLE_DATABASES). pg_dumpall --globals-onlyfor roles and grants.- Offsite copy of both: S3-compatible storage is fine, the dumps are small.
- Test the restore path before you need it. An untested backup is a hope, not a plan.
Schedule this and push the artifacts to object storage: schedule Postgres backups to Cloudflare R2 with Dokploy. The restore order above doubles as your rollback path for a bad migration: restore the last dump of the affected database and replay whatever comes after it.
Schedule Postgres backups to Cloudflare R2Take a fresh dump before any major-version bump. pg_upgrade failures are rare, “no backup before the upgrade” failures are not.
PostgreSQL + Docker version notes (October 2026)
| Item | Status (2026-10-11) |
|---|---|
| PostgreSQL 18 | Current major, GA 2025-09-25. Latest minor 18.6 (2026-08-13) |
| PostgreSQL 17 | 17.11 (2026-08-13), supported until 2029-11-08 |
| PostgreSQL 13 | EOL 2025-11-13 (already past) |
| PostgreSQL 14 | EOL 2026-11-12 (one month out) |
| Image tags | 18.6-alpine3.24, 18-alpine, 17.11-alpine; latest/alpine resolve to 18.6 |
| Multi-DB env var | None. docker-library/postgres#151 closed. Init scripts remain the answer |
PostgreSQL 14 reaches EOL on 2026-11-12
If an old tutorial left you on postgres:14 or postgres:15, plan the move now. EOL means no more minor releases, including security fixes. PostgreSQL 13 is already out of support. Do not jump to PostgreSQL 19 yet: it is still in beta.
Source of truth for these numbers: https://www.postgresql.org/versions.json and the official image README. Version numbers age fast; check both before you pin anything long-term.
Security and production hardening
Short list, high value:
- Bind to loopback:
127.0.0.1:5432:5432, or publish no host port at all if only containers need the database. - Remember that Docker bypasses UFW. Published ports are reachable regardless of your firewall rules. See how Docker bypasses UFW and how to fix it.
- Keep the default host auth method. PostgreSQL 14+ images use
scram-sha-256. Never setPOSTGRES_HOST_AUTH_METHOD=truston a networked host. chmod 600 .envand stop putting real passwords incompose.yaml.- Stay on the image’s non-root
postgresuser (the default). Do not run the container as root to “fix” a permission error. - Cap resources so one noisy database cannot OOM the host:
shm_size: 128mbplusmem_limit(Compose) or deploy limits. - Pin the image tag and bump minors deliberately. Floating
latestin production is how you discover PG18’s mount change at 2 a.m.
For the broader hardening story on a Docker host (SSH, unattended upgrades, audit reports), I walked through how I secured my Docker server in a BSI report walkthrough.
FAQ
Can I run multiple Postgres versions in one container?
No. One image ships one PostgreSQL major. postgres:18 cannot open a 17 data directory. If you need two majors side by side, run two containers (different ports, different data dirs) or use a version manager on the host. Mixing majors in one container is not supported and not recoverable.
Is one container with many databases production-ready?
Fine for dev, staging, and small side projects where the databases share a lifecycle and an availability target. Split into separate containers when tenants need isolation, when one database’s backup/restore cadence differs from the others, or when a single shared max_connections pool becomes a bottleneck. Boring default: one container per application in production.
Do I need a user per database?
No. The simple variant (one POSTGRES_USER login that can reach every database) is easier to operate and enough when the applications already share a trust boundary. Add per-database users when different apps or teams connect with different credentials and you want least privilege at the database level.
Why not just use POSTGRES_DB?
POSTGRES_DB creates exactly one database. There is no plural variant in the official image: the request for one (docker-library/postgres#151) was closed without implementation. Init scripts in /docker-entrypoint-initdb.d remain the supported way to create multiple PostgreSQL databases in one Docker Compose service.
Next steps
You now have one Postgres service with N databases, verified permissions, and a dump/restore path. From here: schedule the dumps off-box, keep the image tag pinned, and split the cluster when the databases stop sharing the same uptime requirements. The init script pattern has not changed in years; the mount path and the schema permissions did. Those two are what break people.


