Don’t Put Secrets in Your PostgreSQL Environment Variables

Using the official Postgres Docker image to start a new Postgres server is straightforward. The Docker entrypoint script reads POSTGRES_PASSWORD, sets the superuser password via initdb, and then keeps running. The container starts. Your application connects. Everything looks fine. This is very convenient, but is it a good practice from a security perspective?

As you will see in this blog, there are some risks in using environment variables. Even if they are small, they are avoidable, and there are mitigations you can apply. After reading this blog you will understand the consequences of setting passwords using environment variables for containers, the risks, and what safe alternatives there are.


The page of the official Postgres Docker image shows how to start the container and set a password for the administrative user POSTGRES_PASSWORD:

docker run --name some-postgres -d postgres \
    -e POSTGRES_PASSWORD=mysecretpassword

In a similar manner, the page demonstrates how you could use Docker Compose to deploy your image, and the example from that page (slightly cleaned up) is:

services:
  db:
    image: postgres
    restart: always
    shm_size: 128mb
    environment:
      POSTGRES_PASSWORD: example

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

In Kubernetes, the equivalent would be to use a mounted secret instead (not shown at the page):

env:
  - name: POSTGRES_PASSWORD
    valueFrom:
      secretKeyRef:
        name: pg-secret
        key: example

What happens is that the Docker entrypoint script reads POSTGRES_PASSWORD, sets the superuser password when bootstrapping the server using initdb, and then later starts the postgres process.

What is less obvious is that the password is still sitting in the server process’s environment for the entire lifetime of the container. The entrypoint never unsets the variable after using it. Every backend process that PostgreSQL forks inherits the full environment of its parent, including POSTGRES_PASSWORD. In the container, this trickles into the postmaster process and further down to all the backends that execute your SQL.

NOTE This is specific to the admin bootstrap credentials that are passed using environment variables. Application users created with CREATE ROLE or ALTER USER inside SQL never touch environment variables. Their passwords go directly through PostgreSQL’s own authentication machinery and are not exposed this way.

To demonstrate this, I created the showenv extension. It builds on top of the official postgres:18 image and adds a single SQL function that reads the server process’s environment. The image initialization script installs the extension, creates a non-superuser account normaluser, and grants it access to environment_variables(). Clone the repository and build the image:

git clone https://github.com/mkindahl/pg_showenv.git
cd pg_showenv
docker build -f Dockerfile.demo -t postgres-showenv:18 .

Start a container with the superuser password to use for the container, as given on the Docker Hub page:

docker run -d --name postgres-showenv \
  -e POSTGRES_PASSWORD=mysecretpassword \
  postgres-showenv:18

Wait a few seconds for the container to initialize, then connect as the non-superuser and read the superuser password:

docker exec -e PGPASSWORD=normalpassword postgres-showenv \
  psql -U normaluser postgres -c \
  "SELECT name, value FROM environment_variables() WHERE name = 'POSTGRES_PASSWORD';"
       name        |  value
-------------------+------------------
 POSTGRES_PASSWORD | mysecretpassword

A non-superuser read the superuser password with a single query. Clean up when done:

docker stop postgres-showenv && docker rm postgres-showenv

The rest of this post explains why that happens, what it means for security, and how to avoid it.

PostgreSQL does not expose environment variables. C extensions can.

Before explaining what is happening, it is worth being precise about where the exposure comes from.

PostgreSQL itself has no built-in SQL mechanism to read the server’s environment. There is no system catalog, no SHOW command, and no pg_* view that exposes environ. A stock PostgreSQL installation, with no third-party C extensions, does not leak environment variables via SQL.

The exposure comes from what C extensions are allowed to do: run arbitrary C code inside the server process with full access to the process’s memory. The POSIX standard defines extern char **environ, a pointer to the array of environment strings, and any C code can dereference it. A C extension that does so is not exploiting a bug in PostgreSQL; it is using a standard OS interface that the extension system makes available by design.

The showenv extension that produced the output above is about a hundred lines of straightforward C. The core of it is:

extern char **environ;

const char *eq = strchr(environ[i], '=');
entries[i].name = pnstrdup(environ[i], eq - environ[i]);
entries[i].value = pstrdup(eq + 1);

The function is restricted to superusers by revoking execute from PUBLIC, but any superuser can call it or grant it to anyone else.

Three ways this goes wrong

C language extensions, as well as extensions such as PL/Perl and PL/Python, require superuser privileges to install. Since anything can be done using C, it is inherently insecure and you should be extremely careful when installing any such extensions. However, this can still go wrong in a few scenarios if you as a superuser make mistakes.

Direct superuser compromise. If a superuser account is compromised through a weak password, credential reuse, or a leaked credentials file, the attacker can read every secret in the process environment with a single query. POSTGRES_PASSWORD, API_KEY, DATABASE_URL, DB_ENCRYPTION_KEY: all of it, in one result set.

Accidental or coerced GRANT. A superuser can grant execute on a sensitive function to a non-superuser, deliberately or by mistake. Once granted, any user with that permission can read the full environment.

Supply-chain attack via a trusted extension. This is the scenario that should make you uncomfortable. Consider a widely-used PostgreSQL extension that your team trusts, installs via a package manager, and does not audit on every update. It gets compromised. The attacker adds environment variable exfiltration to the C code. Users rebuild their images, the malicious code runs as the PostgreSQL OS user on every container start, and secrets are sent to an attacker-controlled endpoint.

The supply-chain attack sounds hypothetical, but it need not be. The xz-utils backdoor in 2024 and the event-stream npm compromise in 2018 followed exactly this pattern: establish trust, then slip malicious code into a routine update. A PostgreSQL C extension is a particularly attractive target because it runs with elevated OS privileges and its activity is invisible to SQL-level auditing.

POSTGRES_PASSWORD_FILE does not help

A commonly recommended mitigation is to use POSTGRES_PASSWORD_FILE instead of POSTGRES_PASSWORD. The idea is that the secret lives in a file rather than the environment, so it cannot be read via environ. In Docker Compose this looks like:

services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password

secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt

This does protect the password at rest and prevents it from appearing in docker inspect output. But it does not prevent the password from appearing in the server process’s environment.

The reason is an implementation detail and has to do with how the POSTGRES_PASSWORD_FILE is used. The entrypoint handles both variants through a helper function called file_env:

file_env() {
    ...
    if [ "${!fileVar:-}" ]; then
        val="$(< "${!fileVar}")"   # read password from the file
    fi
    export "$var"="$val"           # export as POSTGRES_PASSWORD
    unset "$fileVar"               # unset POSTGRES_PASSWORD_FILE
}

When the file is read, its contents are exported as POSTGRES_PASSWORD, and POSTGRES_PASSWORD_FILE is unset. The server process inherits POSTGRES_PASSWORD=mysecretpassword regardless of which method was used to supply the password.

Running the demo with POSTGRES_PASSWORD_FILE instead of POSTGRES_PASSWORD produces an identical result:

echo -n "mysecretpassword" > /tmp/postgres_password

docker run -d --name postgres-showenv \
  -e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
  -v /tmp/postgres_password:/run/secrets/postgres_password:ro \
  postgres-showenv:18

docker exec -e PGPASSWORD=normalpassword postgres-showenv \
  psql -U normaluser postgres -c \
  "SELECT name, value FROM environment_variables() WHERE name = 'POSTGRES_PASSWORD';"
       name        |  value
-------------------+------------------
 POSTGRES_PASSWORD | mysecretpassword

POSTGRES_PASSWORD_FILE closes a different risk — the secret is not exposed in Compose files, docker inspect, or kubectl describe pod — but it does nothing for the environ exposure this post demonstrates.

The fix is in the entrypoint

The entrypoint uses POSTGRES_PASSWORD for two purposes: running initdb to initialise the database cluster, and authenticating psql against a temporary server while running initialisation scripts from /docker-entrypoint-initdb.d/. Both are complete before the server process starts. After that, the password serves no further purpose.

The fix is to unset the credential variables immediately before handing off to the server:

unset POSTGRES_PASSWORD POSTGRES_USER POSTGRES_DB POSTGRES_INITDB_ARGS
exec "$@"

This single line, placed just before the final exec "$@" in the entrypoint, eliminates the exposure on both the first-start path (after initdb completes) and on subsequent starts (where the data directory already exists and initialisation is skipped entirely). The server process starts with a clean environment and environment_variables() returns no rows for any of these variables.

This fix has not yet been applied to the official image. A patch has been proposed to the docker-library/postgres maintainers.

What to do

Until the entrypoint is patched, the password will be in the server process’s environment on every start. The practical mitigations reduce the likelihood that it can be read and limit the damage if it is.

  1. Treat C extensions as code you own. Before installing a C extension, read the source. Pin the version and require a deliberate review before any update that touches C code. Check pg_extension for unexpected installations. The attack demonstrated in this post requires a C extension to be loaded; extension hygiene is the most direct control available today.

  2. Minimise superuser accounts and audit grants. Keep the number of superuser accounts to the minimum required. Periodically check who has execute permission on functions that access OS resources. The normaluser in the demo could read the password only because a superuser had granted it access.

  3. Eliminate the password for the admin user where possible. The official Docker image uses Unix socket trust authentication for local connections by default. Administrative work that runs inside the container — backups, maintenance scripts — can connect over the socket without a password, which means POSTGRES_PASSWORD need not be set at all for those use cases.

  4. Use a secrets manager that bypasses the entrypoint entirely. Tools like HashiCorp Vault with dynamic credentials issue short-lived database credentials on demand. The server never receives a bootstrap password through its environment, and any exfiltrated credential expires quickly.

The underlying principle

The problem is not in PostgreSQL, and it is not a design flaw in Docker, nor a design mistake in PostgreSQL. Environment variables are process-visible by design. Any code running in a process can read environ, and PostgreSQL’s extension system makes that property concrete because C extensions run inside the server process with direct access to its memory.

The exposure exists because the official Docker image’s entrypoint exports the password into the environment before starting the server and never cleans it up. The password has done its job but is left in environ for the lifetime of the container. A single unset line placed just before the final exec is all it takes to close it.

Mats

dbmsdrops.kindahl.net

Long time developer with a keen interest in databases, distributed systems, and programming languages. Currently working as Database Architect at Timescale.

Comments

Leave a Reply