# Change the Postgres Password in Docker Compose: Failed Logins per Procedure

> Editing POSTGRES_PASSWORD changes nothing on an existing volume. A second login role gave 0 failed logins, and each redeploy still lost about 3 seconds.

Published: 2026-11-07. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/change-postgres-password-docker-compose

Editing `POSTGRES_PASSWORD` in Compose does not change the database password, because the image reads it only on first boot.
Change the role with `\password` in `psql`, then update the app.
A second login role gave 0 failed logins in 6 of 6 runs, while each single-replica redeploy still lost about 3 seconds of requests.

I measured each procedure on November 7, 2026, in a WSL2 lab: an ASP.NET minimal API on Npgsql 10.0.3, Postgres 18.6, and [**k6**](https://k6.io/) inside Compose at 100 requests per second for 100 seconds, with a burst to 300 from second 45 to 55 in the burst runs.
Each request reads and holds a connection for 20 ms.
Nothing ran on a real VPS, I did not test write load, PgBouncer, or more than one replica, and I ran EF Core migrations only outside the load test.

For the error itself, see [**password authentication failed**](https://milanjovanovic.tech/blog/docker-postgres-password-authentication-failed).

## What Happens If You Only Edit POSTGRES_PASSWORD?

The variable changes and the role doesn't, so the app sends a password the role doesn't have:

```text
inspect: POSTGRES_PASSWORD=pw-new health=healthy
  t= 60- 65 auth=500
  t= 65- 70 auth=500
```

That is every request failing, while `docker inspect` shows the new value and the healthcheck stays green.
The totals below only reflect how long I waited.
`ALTER ROLE` ended it with no redeploy: failures stopped within a second in all six runs.

## How Do You Change the Password in Place?

Set the password of the role your app logs in as, here `app_v2`:

```bash
docker compose exec db psql -U postgres -d shop
```

Replace `shop` with your database and `postgres` with your first-boot user.

```text
\password app_v2
```

Update the app's secret in `.env`, then redeploy only the API:

```bash
docker compose up -d --no-deps api
```

Confirm it with the [**one-off container test**](https://milanjovanovic.tech/blog/docker-postgres-password-authentication-failed).

`--no-deps` matters when one variable feeds both services, as in my [**Dokploy deploy guide's Compose file**](https://milanjovanovic.tech/blog/deploy-dotnet-app-to-vps-with-dokploy).
Plain `up -d` then recreates the database too, which lost about 5 times the requests and added `57P01` errors.
`--no-deps` only postpones this to the next plain `up -d`.

With a separate `APP_PASSWORD`, plain `up -d` recreated only the API.
I did not test Dokploy's own redeploy.

## How Many Requests Fail Under Each Procedure?

Failed logins are HTTP 500 with `28P01`.
Redeploy losses are requests k6 could not deliver while the API container was replaced.
Other is HTTP 500 for `57P01`, `57P03`, and connect failures.

Each number is one run:

| Procedure | Failed logins | Lost to redeploy | Other |
| --- | --- | --- | --- |
| Control: no password change, recreate the API, burst | 0, 0, 0 | 218, 184, 213 | 0 |
| Edit POSTGRES_PASSWORD, up -d, ALTER ROLE 40 s later | 4621, 4479, 1568, 4640, 4761, 1009 | 1020, 1159, 3505, 830, 1006, 2654 | 153, 194, 154, 222, 84, 1253 |
| ALTER ROLE, redeploy API 40 s later, steady load | 0, 0, 2 | 221, 246, 213 | 0 |
| Same, burst | 389, 0, 1682, 804, 0, 1 | 226, 183, 197, 207, 208, 229 | 0 |
| Same burst, plain up -d, one variable feeds both services | 1678, 1676, 1675 | 1014, 1022, 1023 | 51, 63, 126 |
| Same steady load, Connection Lifetime=20 | 2798, 2930, 2839 | 219, 224, 237 | 0 |
| Same burst, Connection Idle Lifetime=10, Connection Pruning Interval=2 | 2772, 3198, 3200 | 194, 269, 527 | 0 |
| Second login under a group owner, burst | 0, 0, 0 | 177, 238, 227 | 0 |
| Same, app role owned the tables, ownership moved under load first | 0, 0, 0 | 227, 195, 216 | 0 |

Every row lost requests to the redeploy, including the control that changed no password.
Compose stops the old container and starts a new one, and with one replica and no proxy nothing retries.
Outside the two rows where `up -d` restarts the database too, that was 177 to 269 requests in all but one run, over about 3 to 3.7 seconds, so nothing on this page reaches zero lost requests.

## Why Do ALTER ROLE Failures Depend on Pool Headroom?

Postgres checks a password only at login, so `ALTER ROLE` doesn't touch open connections.
That is why steady load shows 0, 0, and 2.

The failures come from pool growth.
In Npgsql 10.0.3's `PoolingDataSource.cs`, a request that finds no idle connection tries to open a new one, and it only waits once the pool is at `Maximum Pool Size`.
After `ALTER ROLE`, that new open sends the old password.

A failed open doesn't grow the pool, so the shortage lasts as long as the extra load does.

I warmed pools to a set size, changed the password, and sent 3,000 requests over 10 seconds.
Failed logins per run:

| Warm pool | Even arrivals | Random arrivals | Capped at the warm pool, even arrivals |
| --- | --- | --- | --- |
| 3 | 1709, 1713, 1711 | 1853, 1852, 1832 | 0 logins failed, about 1,242 pool timeouts |
| 5 | 907, 900, 890 | 1168, 1179, 1168 | 0 logins failed, 67 to 77 pool timeouts |
| 7 | 227, 232, 233 | 646, 611, 658 | 0 failures |
| 9 | 0, 0, 0 | 315, 323, 325 | 0 failures |
| 21 | 0, 0, 0 | 0, 0, 0 | Not run |

No pool size is safe by itself, because the number that gives zero moves with how bursty the arrivals are.
Without a password change, the same load grew the pool to 9 with even arrivals and 16 or 17 with random ones, and zero failed logins appeared only at or above those sizes.
I did not run sizes between 9 and 21 with random arrivals.

Capping the pool at its warm size with `Maximum Pool Size` removes the failed logins, because a full pool opens nothing.
The cost is pool timeouts when the cap is below what the load needs, and changing it needs its own redeploy, so it must be in place before the rotation.

Short lifetimes make this worse, because every retired connection needs a replacement that can't log in.
`Connection Lifetime=20` took steady load from about 0 failed logins to about 2,800, starting 10 seconds after `ALTER ROLE`.

The [**Npgsql defaults**](https://www.npgsql.org/doc/connection-string-parameters.html), read on November 7, 2026, are 300 seconds idle and 3,600 seconds total.
I measured only a 40-second window at those defaults, so redeploy soon after `\password`.

## How Does the Two-Role Procedure Work?

The old role keeps its password, so the running pool's new connections still log in, at any pool size.
The app moves to a second login when it redeploys.

When the app role owns the tables, grants copied by hand aren't enough for the new role, which got `permission denied for sequence orders_id_seq` on insert and `must be owner of table orders` on a migration.
So move ownership to a group role first, in the same `psql` session as above:

```sql
BEGIN;
SET LOCAL lock_timeout = '3s';
CREATE ROLE shop_owner NOLOGIN;
GRANT shop_owner TO app_v1;
REASSIGN OWNED BY app_v1 TO shop_owner;
COMMIT;
```

Without the `lock_timeout` line, under load this took 1.1 to 1.4 seconds, including `docker compose exec` overhead, and cost 0 failed requests on a 1,000-row table.
`REASSIGN` queues behind any open transaction, and new queries queue behind it: with one 12-second transaction open, a read took 9.5 seconds instead of 0.7.
With `lock_timeout` the block rolled back after 3 seconds and reads recovered, so run it again if that happens.
I did not test large tables.

If your app logs in as `postgres`, this step refuses with `cannot reassign ownership of objects owned by role postgres`.
Create the group, then move each object with `ALTER TABLE orders OWNER TO shop_owner`, and `ALTER VIEW`, `ALTER TYPE`, or `ALTER SEQUENCE` for the rest, because anything left behind gave `permission denied` or `must be owner`.
After the redeploy run `\password postgres`, because the old secret still logs in as superuser, and skip the `DROP` steps.

Create the second login:

```sql
CREATE ROLE app_v2 LOGIN IN ROLE shop_owner;
ALTER ROLE app_v2 SET ROLE shop_owner;
GRANT CREATE ON SCHEMA public TO shop_owner;
\password app_v2
```

The `SET ROLE` line makes new tables belong to the group, and it survives Npgsql's pool reset.
Without it, a table a migration creates belongs to `app_v2`, the other login gets `permission denied for table created_by_v2`, and the next `DROP ROLE` is refused.

The `GRANT` is there because `REASSIGN OWNED` leaves schema grants behind.
Without it, `CREATE TABLE` and `CREATE INDEX` failed with `permission denied for schema public`, and an EF Core 10.0.14 app running `MigrateAsync` at startup crashed in 3 of 3 runs with nothing pending, on its `CREATE TABLE IF NOT EXISTS` for the history table.

My connection string reads `Username=${APP_USER};Password=${APP_PASSWORD}`, so edit `.env` to `APP_USER=app_v2` and the new `APP_PASSWORD`, then redeploy:

```bash
docker compose up -d
```

Switching back to `app_v1` still works until the `DROP`.

Check who is connected before you remove the old login, because `DROP ROLE` doesn't end sessions:

```bash
docker compose exec db psql -U postgres -d shop -c "SELECT usename, count(*) FROM pg_stat_activity WHERE datname = 'shop' AND pid <> pg_backend_pid() GROUP BY usename"
```

If `app_v1` still appears, wait or end it with the by-name command below.

Then drop it, with the `REVOKE` only if the role held a schema grant:

```bash
docker compose exec db psql -U postgres -d shop -c "REVOKE ALL ON SCHEMA public FROM app_v1"
docker compose exec db psql -U postgres -d shop -c "DROP ROLE app_v1"
```

If `DROP ROLE` still refuses, revoke what its `DETAIL` lists.

Type the old password at this prompt to prove it is rejected:

```bash
docker compose run --rm --no-deps db psql -h db -U app_v1 -d shop -c "SELECT 1"
```

```text
psql: error: connection to server at "db" (172.29.0.2), port 5432 failed: FATAL:  password authentication failed for user "app_v1"
```

## How Do You Keep the Password Out of Logs and Off the Volume?

Use `\password`, which computes the verifier on the client, so the server only sees that.
Where the value landed for each method, with `log_statement=all`:

| Method | Server log | docker events | History file |
| --- | --- | --- | --- |
| docker exec psql -c "ALTER ROLE ... PASSWORD" | Plaintext | Plaintext | Host shell history (same mechanism, this exact form not run) |
| \password | Verifier only | Nothing | The command, no value |
| Typed in psql as root, the exec default | Plaintext | Nothing | /root/.psql_history, in the container layer, gone after a recreate |
| Typed in psql with exec -u postgres | Plaintext | Nothing | /var/lib/postgresql/.psql_history, on the volume |

The last row is new with the Postgres 18 mount.
The postgres user's home is `/var/lib/postgresql`, and that path is now the volume, so the file survived a recreate and a tar of the volume held the value.

`pg_dumpall --roles-only` carried only the verifier, so the risk is a volume-level copy like the [**restic volume backup**](https://milanjovanovic.tech/blog/off-site-backups-vps), not a dump.

If you already typed a password as the postgres user, remove the file with `docker compose exec db rm /var/lib/postgresql/.psql_history`.
That doesn't reach a volume backup that was already taken.

## Does Changing the Password Cut Off a Leaked Credential?

No.
A plain role's open session kept reading through `ALTER ROLE` and `NOLOGIN`, and after `DROP ROLE` it stayed connected without its table access.

The setup above is worse, because the `SET ROLE` default keeps the group's rights.
New logins were refused and the open session kept everything, in 3 of 3 runs:

```text
open session identity after DROP: OK [shop_owner]
open session read after DROP: OK [3]
open session INSERT after DROP: OK [4]
open session DDL after DROP: OK [altered]
```

The same login without `SET ROLE` got `permission denied for table orders` after the drop.

For a leak, change the password or set `NOLOGIN`, end the sessions by name, confirm with the "who is connected" query, and only then drop.
If the app still logs in as that role, redeploy it on a new login first, because ending its connections forces new opens with the old password:

```bash
docker compose exec db psql -U postgres -d shop -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'app_v2'"
```

After `DROP ROLE` the session's `usename` is NULL, so that command matches 0 rows and the session stays open.
This form finds every client backend whose role is gone:

```bash
docker compose exec db psql -U postgres -d shop -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename IS NULL AND backend_type = 'client backend'"
```

I ran these against single sessions, not under load, and I didn't measure how a pool recovers.

## Summary

Editing the variable fails every request until the role changes.
`ALTER ROLE` followed by a redeploy gave 0 to about 1,850 failed logins out of 3,000 burst requests, depending on warm connections, with no safe pool size to quote.
A second login under a group owner gave 0 in all 6 runs and needs no tuning.

Each single-replica redeploy separately lost about 3 seconds of requests, and I have no measured fix for that here.
Use `\password` to keep the value out of logs and off the volume, and end old sessions before you drop a role, because a session under `SET ROLE` keeps write access.
These results come from containers in a WSL2 lab with read traffic, so rehearse this on your own stack first.

My [**self-hosting course**](https://milanjovanovic.tech/pragmatic-self-hosting) configures the app's secrets when it deploys a .NET API with PostgreSQL on a private Docker network.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### How do I change the Postgres password in Docker Compose?

Open psql with docker compose exec, run the \password command for the role, update the secret the app reads, and redeploy only the app. Editing POSTGRES_PASSWORD alone changes nothing on an existing volume, because the image applies it only on first boot.

### Does ALTER ROLE drop existing connections?

No. Postgres checks a password only at login, so open connections keep working. Failures start when the pool needs a new connection. Under steady load I saw 0, 0, and 2 failed logins before the redeploy, and one burst run failed 1,682.

### Can I change the Postgres password with no downtime?

Failed logins can reach zero, but lost requests did not on a single replica. A second login role gave 0 failed logins in 6 of 6 runs, and each of those redeploys still lost 177 to 238 requests at 100 per second. I did not test two replicas or a proxy with retries.

### Does changing the password cut off a leaked credential?

No. An open session survived ALTER ROLE, NOLOGIN, and DROP ROLE. With a default SET ROLE to a group, it kept reading, writing, and altering tables after the drop. End sessions with pg_terminate_backend by role name before you drop the role, then confirm none are left.

### How do I change the password without it landing in logs?

Use the \password command at the psql prompt. The server log then holds only a SCRAM verifier, docker events hold nothing, and the history file holds the command with no value. An inline ALTER ROLE can put the value in all of those, and on Postgres 18 typed as the postgres user it lands on the volume.
