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 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.
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:
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:
docker compose exec db psql -U postgres -d shop
Replace shop with your database and postgres with your first-boot user.
\password app_v2
Update the app's secret in .env, then redeploy only the API:
docker compose up -d --no-deps api
Confirm it with the one-off container test.
--no-deps matters when one variable feeds both services, as in my Dokploy deploy guide's Compose file.
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, 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:
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:
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:
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:
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:
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:
docker compose run --rm --no-deps db psql -h db -U app_v1 -d shop -c "SELECT 1"
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, 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:
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:
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:
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 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.

