Docker Compose PostgreSQL environment variables, explained

2026-11-10来源 dev.to DockerEnglish

Last updated: 8 November 2026 · PostgreSQL 18 (official postgres:18 image, 18.6) · Docker Compose v2/v5

Setting Docker Compose PostgreSQL environment variables looks like three lines of YAML. Then you change the password, restart, and get password authentication failed. Or your .env file seems to be ignored. Or a $ in your password quietly disappears.

This guide gives you a complete, copy-paste Compose setup for Postgres with a .env file, a health check and an app that connects to it. It explains which variables the official image reads, when it reads them (only once, which is the cause of most "my password doesn't work" posts) and how to fix each common error. You'll know exactly where every value comes from, so the database starts the same way on your laptop and in production.

Try it on Hubfly space: import the same Compose file and run Postgres next to your app.

The variables the postgres image reads

The official image reads these at startup. Bold ones are the ones you'll actually set.

Variable Default What it does
POSTGRES_PASSWORD none (required) Password for the superuser. Startup fails without it unless auth is set to trust.
POSTGRES_USER postgres Name of the superuser that's created.
POSTGRES_DB same as POSTGRES_USER Name of the first database.
POSTGRES_INITDB_ARGS empty Extra flags for initdb, like --data-checksums or --locale-provider=icu --icu-locale=en-US.
POSTGRES_INITDB_WALDIR empty Puts the write-ahead log in a separate directory.
POSTGRES_HOST_AUTH_METHOD the server's password_encryption (scram-sha-256 on 14+) Auth method for network connections. Never set it to trust on anything reachable.
PGDATA /var/lib/postgresql/18/docker on 18 Where the data files live. Leave it alone on 18.
*_FILE versions none POSTGRES_PASSWORD_FILE, POSTGRES_USER_FILE, POSTGRES_DB_FILE and POSTGRES_INITDB_ARGS_FILE read the value from a file (for Docker secrets).

Two things trip people up:

  • There is no POSTGRES_PORT or POSTGRES_HOST. Postgres listens on 5432 inside the container. Change the port on the host side of ports: instead.
  • All of these only matter the first time. The image runs its setup only when the data directory is empty. More on that in Why your new password is ignored.

Step 1: Create the .env file

Make a project folder, then generate a strong password that's safe to put in a URL:

mkdir shop && cd shop openssl rand -hex 24 

Hex output has only 0-9 and a-f, so you never have to escape or encode it. Create .env:

# .env (never commit this file) POSTGRES_USER=shop POSTGRES_PASSWORD=paste-the-48-character-hex-string-here POSTGRES_DB=shop 

Keep it out of Git, and commit a template instead so teammates know what to fill in:

echo ".env" >> .gitignore sed 's/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=change-me/' .env > .env.example 

Why: the .env file next to compose.yaml is loaded automatically. You keep secrets out of the YAML, and each machine gets its own values.

Step 2: Write compose.yaml

services: db: image: postgres:18 restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-shop} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} POSTGRES_DB: ${POSTGRES_DB:-shop} volumes: - pgdata:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 3s retries: 10 start_period: 10s api: build: . restart: unless-stopped environment: DATABASE_URL: postgresql://${POSTGRES_USER:-shop}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-shop} depends_on: db: condition: service_healthy volumes: pgdata: 

What each part does:

  • ${POSTGRES_PASSWORD:?...} stops Compose before anything starts if the password is missing or empty. Without it you'd get a container that crashes on its first boot.
  • ${POSTGRES_USER:-shop} uses shop when the variable isn't set.
  • pgdata:/var/lib/postgresql is the right mount for Postgres 18. Older guides mount /var/lib/postgresql/data; on 18 that makes the container refuse to start (see What can go wrong).
  • $${POSTGRES_USER} in the health check is escaped on purpose. $$ tells Compose "don't fill this in", so the shell inside the container reads the variable at run time. The full story is in Docker health checks that catch real problems.
  • -h 127.0.0.1 makes the check use TCP, so it can't pass against the temporary socket-only server the image runs during setup.
  • @db:5432: inside Compose, the host is the service name, not localhost.
  • No ports: on db. Your app reaches it over the private network. If you need to connect from your laptop, add ports: ["127.0.0.1:5432:5432"] so it's only open on your own machine.

Replace build: . with your own app. Most Postgres drivers accept DATABASE_URL, and libpq-based ones also read PGHOST, PGUSER, PGPASSWORD and PGDATABASE.

Step 3: Check the values before you start anything

docker compose config 

This prints the file with every ${...} filled in, so you can see exactly what each container will get. If the password is missing you'll see:

error while interpolating services.db.environment.POSTGRES_PASSWORD: required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env 

In the output, the health check still shows $${POSTGRES_USER}. That's normal: config prints a file you could run again, so it keeps the escape. The container receives a single $.

Then start it and connect:

docker compose up -d docker compose ps docker compose exec db psql -U shop -d shop -c 'select version();' 

exec needs no password: inside the container, local connections use trust auth. Network connections from other containers need the password.

.env vs env_file vs environment

These three look alike and do different jobs. This is the single most confusing part of Compose.

.env (next to compose.yaml) env_file: environment:
Fills in ${VAR} in the YAML Yes No No
Puts variables inside the container No, only what you reference Yes, every line Yes
Wins when the same name is set twice n/a Loses to environment: Wins

So with this file:

services: db: image: postgres:18 env_file: db.env # contains POSTGRES_DB=ignored and ONLY_IN_DBENV=hello  environment: POSTGRES_DB: orders FROM_DBENV: ${ONLY_IN_DBENV:-not-visible} 

the container gets POSTGRES_DB=orders (because environment: wins) and FROM_DBENV=not-visible, because db.env is never used to fill in ${...}. If you want a file's values in the YAML, it has to be .env or passed with --env-file.

Other rules worth knowing:

  • Your shell wins over .env. POSTGRES_PASSWORD=x docker compose up uses x, whatever .env says. An old export in your terminal can silently override the file.
  • Compose looks for .env next to compose.yaml, not in the folder you run the command from. Running from a subfolder still finds it.
  • Use a different file per environment with docker compose --env-file prod.env up -d.

Passwords with $, # or @ in them

Generated hex passwords avoid all of this. If you must use a password with symbols, here's what happens.

A $ gets eaten. Compose fills in variables inside .env and env_file values too. This line:

POSTGRES_PASSWORD=Tr0ub$dor&3 

becomes Tr0ub&3, because $dor is read as an empty variable. Compose prints only a warning (The "dor" variable is not set. Defaulting to a blank string.) and starts anyway. Your database is now created with a different password than the one in your password manager. Fix it with single quotes or $$:

POSTGRES_PASSWORD='Tr0ub$dor&3' # or POSTGRES_PASSWORD=Tr0ub$dor&3 

A # is fine inside a value (pass#word). Only # with a space before it starts a comment.

@, #, /, : and % break DATABASE_URL. With the password p@ss#w0rd/1, a client parses everything after the first @ as the host and fails with:

could not translate host name "ss#w0rd" to address: Name or service not known 

Percent-encode the password in the URL only (the POSTGRES_PASSWORD value stays as it is):

python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" 'p@ss#w0rd/1' # p%40ss%23w0rd%2F1 

Then use postgresql://shop:p%40ss%23w0rd%2F1@db:5432/shop.

Why your new password is ignored

Here's why: setup only runs when the data directory is empty. On the first start it creates the user, password and database from your variables. On every later start it finds existing data, prints this line and skips setup completely:

PostgreSQL Database directory appears to contain a database; Skipping initialization 

So changing POSTGRES_PASSWORD, POSTGRES_USER or POSTGRES_DB in .env does nothing to an existing volume. The old password still works and the new one fails with FATAL: password authentication failed for user "shop".

You have two fixes.

Keep your data and change the password inside Postgres (use this one in production):

docker compose exec db psql -U shop -d shop -c "ALTER USER shop WITH PASSWORD 'the-new-password';" 

Then put the same value in .env and recreate the app so it picks it up: docker compose up -d.

Start over with an empty database (development only, this deletes all data):

docker compose down -v docker compose up -d 

-v removes the named volume, so the next start runs setup again with your current .env.

The same rule applies to scripts in /docker-entrypoint-initdb.d/. .sh, .sql and compressed .sql (.gz, .xz, .zst) files there run once, on the first start, sorted by name. They never run against an existing volume.

Use Docker secrets instead of a plain variable

Anyone who can run docker inspect on the container sees POSTGRES_PASSWORD in plain text. A secret file is a step up, and the image supports it through POSTGRES_PASSWORD_FILE:

services: db: image: postgres:18 restart: unless-stopped environment: POSTGRES_USER: shop POSTGRES_DB: shop POSTGRES_PASSWORD_FILE: /run/secrets/db_password secrets: - db_password volumes: - pgdata:/var/lib/postgresql secrets: db_password: file: ./db_password.txt volumes: pgdata: 
openssl rand -hex 24 > db_password.txt chmod 600 db_password.txt echo "db_password.txt" >> .gitignore 

A trailing newline in the file is fine; the image strips it. Don't set both POSTGRES_PASSWORD and POSTGRES_PASSWORD_FILE, or the container exits with error: both POSTGRES_PASSWORD and POSTGRES_PASSWORD_FILE are set (but are exclusive). Your app needs the password too, so either mount the same secret into it (if your framework reads *_FILE variables) or keep it in .env for the app only.

Running the same file on Hubfly space

Hubfly space's Stack Composer imports Compose YAML: named volumes, health checks and depends_on with service_healthy carry over, and the db → api order becomes numbered deploy stages. build: folders are built when you deploy the stack from your GitHub repo; otherwise point api at a pushed image (image: ghcr.io/your-org/shop-api:1.0). Three differences matter for environment variables:

  • There's no .env file on the server. Set each variable as a row in the container's Environment Variables tab, and keep the Secret toggle on for the password (it's on by default) so it's masked in logs. Variables set at the project level are shared by every container in the project and are stored encrypted (AES-256-GCM).
  • Write a single $ in commands and health checks. Stack Composer keeps $$ as two characters, so the shell turns it into a process ID. Use $POSTGRES_USER there instead of $${POSTGRES_USER}.
  • Bind mounts are skipped, so files like ./init.sql:/docker-entrypoint-initdb.d/init.sql won't be there. Bake init scripts into your own image with a COPY line instead.

Imported services start on the shared tier (free within 360 hours a month) with auto-sleep. For a database, switch db to Dedicated with 24/7 on: at 0.5 vCPU and 512 MB that's about $2.19 a month for compute (0.5 × $0.004 × 730 h + 0.5 GB × $0.002 × 730 h). Storage is billed per GB on top; Compose volumes are created as Balanced storage, about $0.017 per GB-month.

What can go wrong

What you see Why Fix
Error: Database is uninitialized and superuser password is not specified. POSTGRES_PASSWORD is empty on the first start. Set it in .env. Use ${POSTGRES_PASSWORD:?...} so Compose stops earlier with a clear message.
FATAL: password authentication failed for user "shop" after changing .env The volume already has data, so setup was skipped. ALTER USER (keeps data) or docker compose down -v (deletes data).
FATAL: role "postgres" does not exist You set POSTGRES_USER=shop, so there's no postgres user. Connect with -U shop.
FATAL: database "shop" does not exist The volume was created earlier with a different POSTGRES_DB. Create it: docker compose exec db createdb -U shop shop.
Error: in 18+, these Docker images are configured to store database data in a format which is compatible with "pg_ctlcluster" You mounted /var/lib/postgresql/data on Postgres 18, or reused a volume from 17 or older. Mount /var/lib/postgresql. The image won't upgrade data from 17 or older for you: upgrade it first with a dump and restore or pg_upgrade.
The "xyz" variable is not set. Defaulting to a blank string. A $ in a password or value. Single-quote the value or write $$.
could not translate host name "..." A special character in DATABASE_URL, or localhost used as the host. Percent-encode the password; use db as the host.
connection refused from the app on first boot The app started before Postgres was ready. depends_on with condition: service_healthy, as in Step 2.
A list entry like - POSTGRES_DB:shop has no effect List form needs =. Compose reads POSTGRES_DB:shop as one variable name with no value. Write - POSTGRES_DB=shop, or use the map form.

FAQ

How do I pass environment variables to Postgres in Docker Compose?

Put them under environment: on the db service, and keep the values in a .env file next to compose.yaml, referenced as ${POSTGRES_PASSWORD}. Run docker compose config to see the final values.

Why is my .env file not working in Docker Compose?

Usually one of four things: the file isn't in the same folder as compose.yaml, you used env_file: and expected it to fill in ${...} (it doesn't), a variable exported in your shell is overriding it, or the volume already exists so Postgres ignores the new values.

What's the difference between .env and env_file in Docker Compose?

.env fills in ${VAR} placeholders in the YAML. env_file: copies every line of a file into the container's environment. They can be the same file, but they're two separate features.

Do I need POSTGRES_USER and POSTGRES_DB?

No. Only POSTGRES_PASSWORD is required. Without the others you get a postgres user and a postgres database. Setting your own names is cleaner when several apps share a server.

How do I change the Postgres password in Docker?

Run ALTER USER name WITH PASSWORD '...' with docker compose exec db psql, then update .env. Changing only .env does nothing once the volume has data.

Where does Postgres 18 store data in Docker?

In /var/lib/postgresql/18/docker. Mount the volume at /var/lib/postgresql so a future upgrade can sit next to it. Coming from 17 or older? See how to upgrade Postgres to a new major version in Docker.

Can I use Docker secrets with the postgres image?

Yes. Set POSTGRES_PASSWORD_FILE (or POSTGRES_USER_FILE, POSTGRES_DB_FILE) to the path of the secret, such as /run/secrets/db_password.

Should I publish port 5432?

Only if something outside Docker needs it, and then bind it to 127.0.0.1. Containers on the same Compose network reach Postgres without any ports: entry.

Go further on Hubfly space

  • Skip running Postgres yourself. A managed PostgreSQL database gives you a ready connection string, and binding it to your app adds DATABASE_URL to the app as a Secret.
  • Deploy the whole stack from Compose. Follow the Compose guide to import this file and run db and api in one project.
  • Reach a private database from your laptop with a tunnel instead of publishing 5432.

Related guides: Docker health checks that catch real problems, Gate your app on a Postgres migration job, 502 Bad Gateway in Docker? Bind to 0.0.0.0, How to get a managed PostgreSQL database for about $2 a month.

Try it on Hubfly space

By Octave · drafted with AI, reviewed by Octave · Last updated 8 November 2026

原文:https://www.a8q9.cn/en/article/docker-compose-postgresql-environment-variables-explained-901145.html

相关文章