Docker Postgres 'password authentication failed': 7 Causes

devopsdockerpostgresqlself-hosting
Docker Postgres 'password authentication failed': 7 Causes

password authentication failed means the server you reached rejected what your client sent, and a POSTGRES_PASSWORD changed on an existing volume is one common reason. Seven causes give the same client text. Test the password over the Compose network, fix it with \password, and avoid docker compose down -v, which deletes your data.

I reproduced all seven in a WSL2 lab with Docker 29.6 on Docker Desktop, Compose 5.3, Postgres 18.6 (postgres:18), and Npgsql 10.0.3, on November 7, 2026. A second container and a package Postgres inside Docker-in-Docker stood in for the other server in cause 5. Nothing ran on a real VPS, and I did not test md5 passwords from old volumes or a native Postgres on Windows or macOS.

How Do You Test the Password for Real?

Run psql the way your app connects, from another container, over the Compose network, by service name:

docker compose run --rm --no-deps db psql -h db -U postgres -c "SELECT current_user"

This starts a one-off container that only runs psql, publishes no port, and leaves nothing behind. Replace db with your service name and postgres with the role your app uses, then type the password your app sends at the prompt. Add -d yourdb if the role has no database of the same name, or the right password prints database "..." does not exist. A wrong password prints the error below, and the right one prints the role name:

Password for user postgres:
psql: error: connection to server at "db" (192.168.48.2), port 5432 failed: FATAL:  password authentication failed for user "postgres"

If this fails, the role on the volume doesn't have the password you typed, which points at causes 1, 2, 3, or 7 in the table below. If it passes and your app still fails, the app is sending different bytes or reaching a different server, which points at causes 3, 4, 5, or 6.

Why Does docker exec psql Connect Without a Password?

Because the image never asks it for one. Its initdb step sets up trust for the local socket and the loopback addresses, and only the last rule checks a password:

docker compose exec db sh -c 'grep -vE "^(#|$)" "$PGDATA/pg_hba.conf"'
local   all             all                                     trust
host    all             all             127.0.0.1/32            trust
host    all             all             ::1/128                 trust
local   replication     all                                     trust
host    replication     all             127.0.0.1/32            trust
host    replication     all             ::1/128                 trust
host all all all scram-sha-256

So docker compose exec db psql -U postgres -c "SELECT current_user" succeeds whatever the password is. Adding -h 127.0.0.1 or -h localhost doesn't change that. I sent a wrong password to each address with log_connections=all on, and the server logged how it let the loopback attempts in:

LOG:  connection authenticated: user="postgres" method=trust (/var/lib/postgresql/18/docker/pg_hba.conf:119)

The container's own IP and another container by service name both refused that wrong password, because they match the last rule.

A green healthcheck doesn't help either. My container reported health=healthy while it rejected a wrong password.

Which of the Seven Causes Is It?

Every cause gives the same client text, from psql and from Npgsql:

psql: error: connection to server at "db" (192.168.112.2), port 5432 failed: FATAL:  password authentication failed for user "postgres"
npgsql FAIL: Npgsql.PostgresException: 28P01: password authentication failed for user "postgres"
  SqlState=28P01 Severity=FATAL Routine=auth_failed Detail=(null) Hint=(null)

28P01 is the Postgres code for a failed password login, and Detail is null, so your .NET logs never see a reason.

The server log adds little. For a wrong password on an existing role it prints only the pg_hba.conf line it matched, so of these seven it names only cause 7. That leaves the tells:

#CauseTellFixDestroys data?
1POSTGRES_PASSWORD changed after first bootLog says Skipping initialization, printenv shows the new value, the old password still logs in\password through the socketNo
2Leftover volume from a project with the same folder nameNo Volume Created line in up, volume older than the container, tables you never made\password to keep the data, down -v only if you do not want itdown -v: yes. \password: no
3Dollar sign eaten by Compose or .envWarning that a variable is not set, docker compose config shows a short valueSingle-quote it in .env or use $$ in YAML, then \passwordNo
4Percent sequence in a URIPassword contains %XX and the client uses a URI. A raw @ or / gives a different errorPercent-encode it or use the keyword formNo
5Another Postgres on the portYour container log has no password authentication failed linePoint the client or the published port at your containerNo
6CR or LF from a file or shell, on the client sideod -c shows \r in what the client readsStrip it where the client reads itNo
7POSTGRES_USER changed after first bootLog says DETAIL: Role "admin" does not existCREATE ROLE through the socket, or restore the variableNo

How Do You Fix a Changed POSTGRES_PASSWORD Without Losing Data?

The image applies POSTGRES_PASSWORD only when the data directory is empty. The password lives with the role on the named volume, and the volume outlives the container:

A Compose file defines an app, PostgreSQL, and Redis on one Compose network. PostgreSQL keeps its data, including its roles and their passwords, on a named volume that outlives the container.

This was my test file. Port 15810 is my lab's, so pick your own and keep the 127.0.0.1, because a bare 5432:5432 publishes on every interface and Docker's published ports bypass UFW:

services:
  db:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
    ports:
      - '127.0.0.1:15810:5432'
    volumes:
      - pgdata:/var/lib/postgresql
volumes:
  pgdata:

After I changed the variable and recreated the container, the log showed Skipping initialization (trimmed):

docker compose logs db | grep -E "Skipping initialization|FATAL|DETAIL"
db-1  | PostgreSQL Database directory appears to contain a database; Skipping initialization
db-1  | 2026-11-07 10:20:54.002 UTC [44] FATAL:  password authentication failed for user "postgres"
db-1  | 2026-11-07 10:20:54.002 UTC [44] DETAIL:  Connection matched file "/var/lib/postgresql/18/docker/pg_hba.conf" line 128: "host all all all scram-sha-256"

The fix goes through the socket, which needs no password, so it works when you no longer know the old one:

docker compose exec db psql -U postgres
\password postgres

Type the value your app sends at both prompts, then \q. My rows survived, and Npgsql logged in with the same connection string that had failed.

If POSTGRES_USER was set at first boot, use that name in -U. If the volume was initialized with POSTGRES_INITDB_ARGS="--auth-local=scram-sha-256 --auth-host=scram-sha-256", the socket and loopback ask for the password too, and this route is closed. With --auth-local alone, -h 127.0.0.1 stayed trust. To do this on a running stack, see changing the password with no failed logins.

The common advice is this command, and it deletes the volume and every table on it:

docker compose down -v

The new password then works because the database is brand new, and my next query returned ERROR: relation "orders" does not exist. Only run it when you don't want the data, which can be true for cause 2.

Compose derives the volume name from the project name, so a new project in a folder with the same name picked up the earlier project's volume with no warning. The only difference in up was a missing Volume <project>_pgdata Created line. Replace pgauth-r1 with your project name and compare the volume's age with the CREATED column of docker compose ps:

docker volume ls --filter name=pgauth-r1
docker volume inspect pgauth-r1_pgdata --format "{{.CreatedAt}}"

Any recreate also makes the volume older, so check for tables you never made before you delete anything.

Why Does a Dollar Sign Break the Password?

Compose treats $ as the start of a variable. With s3cr$t99, it looks for t99, finds nothing, prints a warning, and starts anyway:

msg="The \"t99\" variable is not set. Defaulting to a blank string."

The value the container received, for each way of writing it:

compose.yml  s3cr$t99                  [s3cr]
compose.yml  s3cr$$t99                 [s3cr$t99]
.env  PW=s3cr$t99                      [s3cr]
.env  PW="s3cr$t99"                    [s3cr]
.env  PW='s3cr$t99'                    [s3cr$t99]

On first boot the role gets s3cr, so the real password fails and s3cr logs in. To see what Compose resolved, run this, and don't paste the output anywhere, because it prints the secret:

docker compose config | grep POSTGRES_PASSWORD

A correct value prints as s3cr$$t99, which looks wrong and isn't. Fix .env with single quotes, then run \password if the role already has the short value:

POSTGRES_PASSWORD='s3cr$t99'

Why Does the Password Work in psql but Not in the App?

The app's bytes differ from what you typed. In a URI, libpq decodes percent sequences before sending, so a role whose password really is ab%41cd fails until the % is encoded:

psql uri> postgresql://postgres:ab%41cd@db:5432/postgres
psql: error: connection to server at "db" (192.168.96.2), port 5432 failed: FATAL:  password authentication failed for user "postgres"
psql uri> postgresql://postgres:ab%2541cd@db:5432/postgres
psql OK: user=postgres

Npgsql takes no URI, and passing one throws Format of the initialization string does not conform to specification starting at index 0. The same trap appears in a hand-written DATABASE_URL parser, because System.Uri.UserInfo stays encoded.

In the keyword form, Password=ab;cd throws Couldn't set cd;database, so quote it:

Host=db;Username=postgres;Password="ab;cd";Database=postgres

Threads often blame a trailing newline, and at first boot it is harmless. POSTGRES_PASSWORD_FILE with LF or CRLF set the clean value on the role, and Compose .env and env_file stripped the CR. The damage came on the reading side: Linux bash sourcing a CRLF file kept the \r, and Npgsql with an untrimmed File.ReadAllText through its connection string builder failed on CRLF.

Check the bytes, and note that pg.clean follows your umask, so remove it or restrict it afterwards:

od -c pg.secret
tr -d '\r\n' < pg.secret > pg.clean && od -c pg.clean
0000000   f   i   l   e   p   a   s   s  \r  \n
0000012
0000000   f   i   l   e   p   a   s   s
0000010

Is Another Postgres Answering on the Port?

Check this when the client connects through a published port, because the test at the top never touches that port. Grep your container's log for the password line, not for FATAL. Mine held an unrelated the database system is starting up line and no password failure, while the client got 28P01 from the other server.

Then ask who holds the port, using your port number:

docker ps --filter publish=15810 --format 'table {{.Names}}\t{{.Ports}}'
docker compose port db 5432
NAMES            PORTS
pgautha-r-db-1   127.0.0.1:15810->5432/tcp
127.0.0.1:15810

docker ps lists only containers, so it can't show a package install. On the host, ss lists it, with a blank process column in my Ubuntu 24.04 test:

ss -ltnp "sport = :5432"

With package Postgres on 127.0.0.1:5432, publishing 5432 failed with address already in use. Publishing 5433 left a client on 5432 getting 28P01, and my container's log stayed clean.

What If POSTGRES_USER Changed?

This is the one cause the server names:

FATAL:  password authentication failed for user "admin"
DETAIL:  Role "admin" does not exist.

The first-boot rule applies here too, so the new name never gets created, and the container stays healthy. Setting POSTGRES_USER=shop from first boot also means postgres never existed. Restore the variable, or create the role through the socket with the first-boot name in -U:

docker compose exec db psql -U shop -d shop
CREATE ROLE admin LOGIN SUPERUSER;
\password admin
\q

SUPERUSER is there because this role replaces the bootstrap user, so leave it off an app login.

Why Does One Npgsql Login Log Two Failures?

Npgsql tries GSS encryption first and retries without it after any error, including this one. Each attempt is a separate connection, so one failed open wrote two lines on a plain server. That matters if you alert on this line or run Fail2ban against it.

This setting brought it to one line:

Host=db;Username=postgres;Password=wrong-pass;Database=postgres;GSS Encryption Mode=Disable

The Cannot load library libgssapi_krb5.so.2 line on stderr is not the cause. It appeared on a good password too, and the count stayed at two on an image that had the library.

Summary

psql through docker exec tells you nothing about a password, because the image trusts the socket and the loopback addresses. Test from a one-off container over the Compose network, and use the result to split the seven causes into a role problem or a client problem. The client text is identical for all of them, and the server log names only a missing role, so use the tells in the table.

No fix here costs you data except docker compose down -v. One \password through the socket repairs causes 1 and 2 and finishes cause 3. These results come from containers in a WSL2 lab, so run the test on your own server before you change anything.

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

Why does changing POSTGRES_PASSWORD not work?

The image applies that variable only when the data directory is empty. On an existing volume it skips initialization, so the role keeps its old password. In testing, docker inspect showed the new value while the new password failed and the old one still logged in.

Why does psql inside the container connect without a password?

The official image sets up trust authentication for the local socket and the loopback addresses, so psql through docker exec is never asked for a password. In testing, a wrong password over 127.0.0.1 logged in and the server log recorded method=trust. Test from another container over the Compose network instead.

How do I reset the Postgres password without deleting the volume?

Open psql through the local socket with docker compose exec, run the \password command for the role, and type the new value at the prompt. The data stays, because this changes the role in place. Running docker compose down -v also gets you a working password, and it deletes every table on the volume.

What does SQLSTATE 28P01 mean in Npgsql?

It is the Postgres code for a failed password login, and Npgsql raises it as a PostgresException with that SqlState and a null Detail. All seven causes I reproduced gave it, so the code alone does not tell you which one you have.

Does a trailing newline in a password file cause this?

Not at first boot. POSTGRES_PASSWORD_FILE with LF or CRLF set the clean value on the role in testing. The damage is on the client side: Linux bash sourcing a CRLF file keeps the carriage return, and so does Npgsql when an untrimmed file goes through its connection string builder.

Browse all Self-Hosting guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.