# Docker Compose PostgreSQL environment variables, explained

*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**](https://hubfly.space/?ref=docker-compose-postgresql-environment-variables): 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:

```bash
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`:

```bash
# .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:

```bash
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

```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](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables).
- **`-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

```bash
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:

```txt
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:

```bash
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:

```yaml
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:

```bash
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:

```txt
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):

```bash
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:

```txt
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):

```bash
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):

```bash
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`:

```yaml
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:
```

```bash
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](https://blog.hubfly.space/upgrade-postgres-major-version-docker?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables) 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](https://blog.hubfly.space/upgrade-postgres-major-version-docker?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables).

### 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](https://www.hubfly.space/docs/databases/get-started/?ref=docker-compose-postgresql-environment-variables) gives you a ready connection string, and [binding it to your app](https://www.hubfly.space/docs/databases/apps-and-bindings/?ref=docker-compose-postgresql-environment-variables) adds `DATABASE_URL` to the app as a Secret.
- **Deploy the whole stack from Compose.** Follow the [Compose guide](https://www.hubfly.space/docs/compose/?ref=docker-compose-postgresql-environment-variables) to import this file and run `db` and `api` in one project.
- **Reach a private database from your laptop** with a [tunnel](https://www.hubfly.space/docs/cli/tunnels/?ref=docker-compose-postgresql-environment-variables) instead of publishing 5432.

Related guides: [Docker health checks that catch real problems](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables), [Gate your app on a Postgres migration job](https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables), [502 Bad Gateway in Docker? Bind to 0.0.0.0](https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables), [How to get a managed PostgreSQL database for about $2 a month](https://blog.hubfly.space/managed-postgresql-database-2-dollars-a-month?utm_source=hashnode&utm_campaign=docker-compose-postgresql-environment-variables).

[**Try it on Hubfly space**](https://hubfly.space/?ref=docker-compose-postgresql-environment-variables)

---

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

