Skip to main content
RunBook Academy

← All labs in PostgreSQL

Lab · intermediate · ~60 min

Lab 5: Configure TLS and prove what each sslmode actually checks

C · SimulationB · Nested virtualisation

Objectives

  • Establish whether a cluster has TLS enabled and what certificate it is presenting
  • Issue a CA-signed server certificate with a subjectAltName and install it
  • Demonstrate what each of the six sslmode values checks, and what it does not
  • Show that verify-ca accepts a wrong host name and verify-full does not
  • Enforce TLS from the server with hostssl, where the client cannot decline it
  • Authenticate a client by certificate with no password anywhere

Prerequisites

  • A PostgreSQL 18 cluster you can reload and restart
  • openssl available on the same host
  • Completion of Lab 4, or equivalent familiarity with pg_hba.conf rule ordering

Objective

“Is the database connection encrypted?” is a question that gets a yes far more often than it deserves. By the end of this lab you will be able to answer a sharper set of questions, with evidence:

  • Is TLS on, and what certificate is the server presenting?
  • Is the client checking that certificate, or merely encrypting?
  • Could a client turn encryption off if it wanted to?
  • Which sessions on this server right now are unencrypted?

The lab starts with a finding you may not expect. The Debian package turns TLS on by default and points it at a certificate no client can ever verify. That configuration passes a naive audit and provides no protection against an attacker positioned to intercept the connection. You will see exactly how far it gets you and exactly where it stops.

Architecture

One cluster, one small CA created for the lab, a server certificate carrying both a DNS name and an IP address in its subjectAltName, and a client certificate. Connections arrive over TCP by three different names so that host name verification can be exercised.

flowchart TD
    CA["Lab CA\nca.crt / ca.key"] --> SC["server.crt\nCN=pg.lab.internal\nSAN: DNS pg.lab.internal, IP 172.26.0.4"]
    CA --> CC["client.crt\nCN=tlsuser"]
    SC --> S["PostgreSQL 18\nssl=on"]
    C1["client via pg.lab.internal\nname matches the SAN"] --> S
    C2["client via db.wrong.name\nname NOT in the SAN"] --> S
    CC --> C1
    S --> V["pg_stat_ssl\nssl, version, cipher, bits, client_dn"]

Requirements

  • A PostgreSQL 18 cluster you can reload, whose pg_hba.conf you can edit. The lab reuses rbpg-lab01 from Labs 1, 3 and 4.
  • openssl on the same host. The lab installs it into the container.
  • Two host names resolving to the server, one that appears in the certificate and one that does not. The lab adds both to /etc/hosts.
  • The addresses 172.26.0.4 and the names pg.lab.internal and db.wrong.name are placeholders. Substitute your own consistently.

Scenario

You have inherited a PostgreSQL server. The handover notes say “connections are encrypted”. You need to find out what that sentence is worth before you sign off on it.

Tasks

Task 1 — Find out what the cluster is already doing

LAB="$HOME/rbpg-lab-05"
mkdir -p "$LAB"

docker exec -u postgres rbpg-lab01 psql -X -c "
  SELECT name, setting FROM pg_settings WHERE name LIKE 'ssl%' ORDER BY name;" \
  | tee "$LAB/tls-baseline.txt"
Read-only / SafeTLS is already on, and it is already pointed at something
$ psql -X -c "SELECT name, setting FROM pg_settings WHERE name LIKE 'ssl%' ORDER BY name;"
                  name                  |                setting                 
----------------------------------------+----------------------------------------
ssl                                    | on
ssl_ca_file                            | 
ssl_cert_file                          | /etc/ssl/certs/ssl-cert-snakeoil.pem
ssl_ciphers                            | HIGH:MEDIUM:+3DES:!aNULL
ssl_crl_dir                            | 
ssl_crl_file                           | 
ssl_dh_params_file                     | 
ssl_groups                             | X25519:prime256v1
ssl_key_file                           | /etc/ssl/private/ssl-cert-snakeoil.key
ssl_library                            | OpenSSL
ssl_max_protocol_version               | 
ssl_min_protocol_version               | TLSv1.2
ssl_passphrase_command                 | 
ssl_passphrase_command_supports_reload | off
ssl_prefer_server_ciphers              | on
ssl_tls13_ciphers                      | 
(16 rows)

ssl = on, out of the box, with nobody having configured anything. The certificate is the Debian snakeoil certificate, generated by the ssl-cert package at install time. Look at what it is:

docker exec rbpg-lab01 openssl x509 -in /etc/ssl/certs/ssl-cert-snakeoil.pem \
  -noout -subject -issuer -dates | tee -a "$LAB/tls-baseline.txt"
Read-only / Safeself-signed, named after the host, valid for ten years
$ openssl x509 -in /etc/ssl/certs/ssl-cert-snakeoil.pem -noout -subject -issuer -dates
subject=CN=5dbb8625e16a
issuer=CN=5dbb8625e16a
notBefore=Aug 28 00:09:00 2026 GMT
notAfter=Aug 25 00:09:00 2036 GMT

Subject equals issuer, so it signed itself. The common name is the machine’s host name at install time, which on this container is a generated hex string and on a real host is whatever hostname returned during the package install — not necessarily the name clients use.

Task 2 — Watch the gap for yourself

Open the server to the network, create a test role, and connect:

docker exec -i -u postgres rbpg-lab01 psql -X <<'SQL'
CREATE ROLE tlsuser LOGIN PASSWORD 'lab05-not-a-real-secret';
ALTER SYSTEM SET listen_addresses = '*';
SQL

docker exec rbpg-lab01 bash -c \
  "echo 'host    all    all    172.26.0.0/16    scram-sha-256  # lab05' \
   >> /etc/postgresql/18/main/pg_hba.conf"

docker exec rbpg-lab01 pg_ctlcluster 18 main restart

docker exec -u postgres -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
  psql -X "host=172.26.0.4 user=tlsuser dbname=postgres sslmode=require" \
  -c "SELECT ssl, version, cipher, bits FROM pg_stat_ssl WHERE pid = pg_backend_pid();"

docker exec -u postgres -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
  psql -X "host=172.26.0.4 user=tlsuser dbname=postgres sslmode=verify-full" \
  -c "SELECT 1;"
Read-only / Saferequire succeeds, verify-full does not even try
$ one connection with sslmode=require, one with sslmode=verify-full
 ssl | version |         cipher         | bits 
-----+---------+------------------------+------
t   | TLSv1.3 | TLS_AES_256_GCM_SHA384 |  256
(1 row)

psql: error: connection to server at "172.26.0.4", port 5432 failed: root certificate file "/var/lib/postgresql/.postgresql/root.crt" does not exist
Either provide the file, use the system's trusted roots with sslrootcert=system, or change sslmode to disable server certificate verification.

TLS 1.3 with a 256-bit AES-GCM cipher, and a client that cannot verify who it is talking to. That is the whole gap in two commands.

The error message is unusually good: it names the exact file libpq looked for (~/.postgresql/root.crt, relative to the connecting user’s home directory) and lists the three ways forward.

Task 3 — Issue a certificate a client can actually verify

docker exec rbpg-lab01 bash -c '
  DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssl
  mkdir -p /etc/postgresql/18/main/tls && cd /etc/postgresql/18/main/tls

  # A CA for the lab.
  openssl req -new -x509 -days 3650 -nodes -newkey rsa:2048 \
    -keyout ca.key -out ca.crt -subj "/CN=RunBook Academy Lab CA"

  # A server key and request.
  openssl req -new -nodes -newkey rsa:2048 -keyout server.key -out server.csr \
    -subj "/CN=pg.lab.internal"

  # The extensions that matter: the names clients will use, and the purpose.
  printf "subjectAltName=DNS:pg.lab.internal,IP:172.26.0.4\nextendedKeyUsage=serverAuth\n" \
    > server.ext

  openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out server.crt -days 825 -extfile server.ext

  chown -R postgres:postgres /etc/postgresql/18/main/tls
'

docker exec rbpg-lab01 openssl x509 -in /etc/postgresql/18/main/tls/server.crt \
  -noout -subject -issuer -ext subjectAltName
Configuration changea certificate carrying both names a client might use
$ openssl x509 -in server.crt -noout -subject -issuer -ext subjectAltName
subject=CN=pg.lab.internal
issuer=CN=RunBook Academy Lab CA
X509v3 Subject Alternative Name: 
  DNS:pg.lab.internal, IP Address:172.26.0.4

The subjectAltName is the part that does the work. Host name verification checks the SAN; the common name is legacy and modern clients may ignore it entirely. A certificate without a SAN covering the name clients actually use will fail verify-full no matter how correct everything else is.

Task 4 — Install it, with the key permissions wrong on purpose

docker exec rbpg-lab01 chmod 0644 /etc/postgresql/18/main/tls/server.key

docker exec -i -u postgres rbpg-lab01 psql -X <<'SQL'
ALTER SYSTEM SET ssl_cert_file = '/etc/postgresql/18/main/tls/server.crt';
ALTER SYSTEM SET ssl_key_file  = '/etc/postgresql/18/main/tls/server.key';
ALTER SYSTEM SET ssl_ca_file   = '/etc/postgresql/18/main/tls/ca.crt';
SELECT pg_reload_conf();
SQL

docker exec rbpg-lab01 tail -3 /var/log/postgresql/postgresql-18-main.log
Configuration changethe server declines to load a private key anybody can read
$ reload after pointing ssl_key_file at a 0644 key, then tail the log
2026-08-28 00:29:04.250 UTC [8708] LOG:  private key file "/etc/postgresql/18/main/tls/server.key" has group or world access
2026-08-28 00:29:04.250 UTC [8708] DETAIL:  File must have permissions u=rw (0600) or less if owned by the database user, or permissions u=rw,g=r (0640) or less if owned by root.
2026-08-28 00:29:04.250 UTC [8708] LOG:  SSL configuration was not reloaded

Note the last line: “SSL configuration was not reloaded”. This is the same all-or-nothing behaviour you saw with pg_hba.conf in Lab 4. The server kept its previous TLS configuration — the snakeoil certificate — and carried on serving connections with it.

That is the dangerous shape again. A deployment that rotates a certificate and gets the permissions wrong does not fail loudly; it quietly keeps serving the old certificate until somebody restarts the server, at which point it does not come up.

The DETAIL line states both acceptable cases precisely: 0600 or tighter if the key is owned by the database user, or 0640 or tighter if it is owned by root. The second case exists so a key managed by a certificate-renewal process running as root can be read by a postgres-group process without being copied.

docker exec rbpg-lab01 chmod 0600 /etc/postgresql/18/main/tls/server.key
docker exec -u postgres rbpg-lab01 psql -X -c "SELECT pg_reload_conf();"

docker exec -u postgres rbpg-lab01 psql -X -c "
  SELECT name, setting FROM pg_settings
  WHERE name IN ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file') ORDER BY name;"
Configuration changethe new certificate in force
$ psql -X -c "SELECT name, setting FROM pg_settings WHERE name IN ('ssl','ssl_cert_file','ssl_key_file','ssl_ca_file') ORDER BY name;"
     name      |                 setting                
---------------+----------------------------------------
ssl           | on
ssl_ca_file   | /etc/postgresql/18/main/tls/ca.crt
ssl_cert_file | /etc/postgresql/18/main/tls/server.crt
ssl_key_file  | /etc/postgresql/18/main/tls/server.key
(4 rows)

Task 5 — The sslmode ladder, one server, six answers

Make the certificate’s name resolve, then walk every value:

docker exec rbpg-lab01 bash -c 'cp /etc/postgresql/18/main/tls/ca.crt /root/ca.crt
                                echo "172.26.0.4 pg.lab.internal" >> /etc/hosts'

for M in disable allow prefer require verify-ca verify-full; do
  echo "sslmode=$M"
  docker exec -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
    psql -X "host=pg.lab.internal user=tlsuser dbname=postgres sslmode=$M sslrootcert=/root/ca.crt" \
    -c "SELECT ssl, version, cipher FROM pg_stat_ssl WHERE pid = pg_backend_pid();"
done | tee "$LAB/sslmode-ladder.txt"
Read-only / Safethe same server, six client settings, two outcomes
$ six connections differing only in sslmode
sslmode=disable
ssl | version | cipher 
-----+---------+--------
f   |         | 

sslmode=allow
ssl | version | cipher 
-----+---------+--------
f   |         | 

sslmode=prefer
ssl | version |         cipher         
-----+---------+------------------------
t   | TLSv1.3 | TLS_AES_256_GCM_SHA384

sslmode=require
t   | TLSv1.3 | TLS_AES_256_GCM_SHA384

sslmode=verify-ca
t   | TLSv1.3 | TLS_AES_256_GCM_SHA384

sslmode=verify-full
t   | TLSv1.3 | TLS_AES_256_GCM_SHA384

Two of the six connected in clear text. disable never offers TLS; allow prefers not to use it and only tries if a plain connection is refused. Both are easy to read as “SSL is configured” and neither encrypts anything on a server that accepts plain connections.

The remaining four all produced identical TLS. What separates them is not the encryption — it is what the client checked before trusting it, and Task 6 makes that visible.

Task 6 — Where verify-ca stops and verify-full continues

Add a second name for the same address, one that is not in the certificate:

docker exec rbpg-lab01 bash -c 'echo "172.26.0.4 db.wrong.name" >> /etc/hosts'

for M in require verify-ca verify-full; do
  echo "sslmode=$M via db.wrong.name"
  docker exec -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
    psql -X "host=db.wrong.name user=tlsuser dbname=postgres sslmode=$M sslrootcert=/root/ca.crt" \
    -c "SELECT 'connected' AS status;"
done | tee "$LAB/verification-gap.txt"
Read-only / Safeonly one of the three notices it is talking to the wrong name
$ three connections to a host name absent from the certificate
sslmode=require via db.wrong.name
status   
-----------
connected
(1 row)

sslmode=verify-ca via db.wrong.name
status   
-----------
connected
(1 row)

sslmode=verify-full via db.wrong.name
psql: error: connection to server at "db.wrong.name" (172.26.0.4), port 5432 failed: server certificate for "pg.lab.internal" (and 1 other name) does not match host name "db.wrong.name"

This is the whole point of the lab.

sslmodeEncryptsChecks the chainChecks the host name
disablenonono
allowonly if forcednono
preferif offerednono
requireyesnono
verify-cayesyesno
verify-fullyesyesyes

require is the value most connection strings settle on, and it checks nothing about who answered. verify-ca proves the certificate was issued by a CA you trust, which stops a self-signed impostor but not a different host holding a legitimate certificate from the same CA — a meaningful gap in any environment where one internal CA signs for many hosts.

Only verify-full closes it, and only if the certificate’s subjectAltName actually covers the name the client connects by.

Task 7 — Take the choice away from the client

Everything so far has been the client’s decision. sslmode lives in the client’s connection string, so a server that accepts plain connections will get them from any client configured to send them — including one misconfigured by accident.

Change one word in pg_hba.conf:

docker exec rbpg-lab01 sed -i \
  's|^host    all    all    172.26.0.0/16    scram-sha-256  # lab05|hostssl all    all    172.26.0.0/16    scram-sha-256  # lab05|' \
  /etc/postgresql/18/main/pg_hba.conf

docker exec -u postgres rbpg-lab01 psql -X -c "SELECT pg_reload_conf();"

docker exec -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
  psql -X "host=pg.lab.internal user=tlsuser dbname=postgres sslmode=disable" \
  -c "SELECT 1;" | tee "$LAB/enforcement.txt"
Configuration changean unencrypted client no longer has a rule to match
$ a connection with sslmode=disable after switching the rule to hostssl
psql: error: connection to server at "pg.lab.internal" (172.26.0.4), port 5432 failed: FATAL:  no pg_hba.conf entry for host "172.26.0.4", user "tlsuser", database "postgres", no encryption

The message is the Lab 4 “no entry” case, and the reason is worth following: a hostssl rule simply does not match an unencrypted connection, so the search ran off the end of the file. The encryption requirement is expressed as a matching condition, not as a separate check.

The counterpart is hostnossl, which matches only unencrypted connections — occasionally useful for giving plain local traffic a different authentication method, and more often useful as a rule to grep for and delete.

Task 8 — The audit query

docker exec -u postgres rbpg-lab01 psql -X -c "
  SELECT a.pid, a.usename, a.client_addr, s.ssl, s.version
  FROM pg_stat_activity a
  JOIN pg_stat_ssl s USING (pid)
  WHERE a.backend_type = 'client backend';" | tee -a "$LAB/enforcement.txt"
Read-only / Safeevery client backend and whether it is encrypted
$ psql -X -c "SELECT a.pid, a.usename, a.client_addr, s.ssl, s.version FROM pg_stat_activity a JOIN pg_stat_ssl s USING (pid) WHERE a.backend_type = 'client backend';"
 pid  | usename  | client_addr | ssl | version 
------+----------+-------------+-----+---------
8962 | postgres |             | f   | 
(1 row)

This is the query to run on a server you have inherited. Any row with ssl = f and a non-null client_addr is a network connection carrying credentials and data in clear text. A null client_addr means a Unix socket connection, where TLS is neither used nor needed — the traffic never leaves the machine.

Run it repeatedly over a working day rather than once. Connection pools recycle, batch jobs connect on a schedule, and the badly configured client is rarely the one connected when you happen to look.

Task 9 — Authenticate by certificate, with no password

A client certificate can replace the password entirely.

docker exec rbpg-lab01 bash -c '
  cd /etc/postgresql/18/main/tls
  openssl req -new -nodes -newkey rsa:2048 -keyout client.key -out client.csr \
    -subj "/CN=tlsuser"
  printf "extendedKeyUsage=clientAuth\n" > client.ext
  openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out client.crt -days 825 -extfile client.ext
  mkdir -p /root/tlsclient && cp client.crt client.key /root/tlsclient/
  chmod 0600 /root/tlsclient/client.key
'

docker exec rbpg-lab01 sed -i \
  's|^hostssl all    all    172.26.0.0/16    scram-sha-256  # lab05|hostssl all    all    172.26.0.0/16    cert clientcert=verify-full  # lab05|' \
  /etc/postgresql/18/main/pg_hba.conf

docker exec -u postgres rbpg-lab01 psql -X -c "SELECT pg_reload_conf();"

The certificate’s common name is tlsuser, matching the role name. That is what cert authentication compares, unless a map= sends it through pg_ident.conf as in Lab 4.

# Password alone, with a valid TLS session:
docker exec -e PGPASSWORD=lab05-not-a-real-secret rbpg-lab01 \
  psql -X "host=pg.lab.internal user=tlsuser dbname=postgres sslmode=verify-full sslrootcert=/root/ca.crt" \
  -c "SELECT 1;"

# Client certificate, and no password supplied at all:
docker exec rbpg-lab01 \
  psql -X "host=pg.lab.internal user=tlsuser dbname=postgres sslmode=verify-full sslrootcert=/root/ca.crt sslcert=/root/tlsclient/client.crt sslkey=/root/tlsclient/client.key" \
  -c "SELECT current_user, ssl, version, client_dn
      FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) WHERE pid = pg_backend_pid();"
Read-only / Safethe password stops working and the certificate starts
$ one connection with a password only, one with a client certificate only
psql: error: connection to server at "pg.lab.internal" (172.26.0.4), port 5432 failed: FATAL:  connection requires a valid client certificate

current_user | ssl | version |  client_dn  
--------------+-----+---------+-------------
tlsuser      | t   | TLSv1.3 | /CN=tlsuser
(1 row)

client_dn is now populated. That column is empty for every password-authenticated session, which makes it a clean way to audit which connections used a certificate.

Validation

test -s "$LAB/tls-baseline.txt"     && echo "OK tls-baseline"
test -s "$LAB/sslmode-ladder.txt"   && echo "OK sslmode-ladder"
test -s "$LAB/verification-gap.txt" && echo "OK verification-gap"
test -s "$LAB/enforcement.txt"      && echo "OK enforcement"

grep -q "snakeoil"  "$LAB/tls-baseline.txt"     && echo "OK default certificate identified"
grep -c "TLSv1.3"   "$LAB/sslmode-ladder.txt"    # expect 4 of the 6 modes
grep -q "does not match host name" "$LAB/verification-gap.txt" && echo "OK verify-full gap shown"
grep -q "no pg_hba.conf entry"     "$LAB/enforcement.txt"      && echo "OK hostssl enforcement shown"

Questions to answer without looking anything up:

  1. A cluster reports SHOW ssl as on. What have you learned, and what have you not?
  2. What is the difference between require and verify-ca? Between verify-ca and verify-full?
  3. A client connects by IP address with verify-full and fails, but succeeds by DNS name. What is wrong with the certificate?
  4. Your renewal job replaced the certificate and reloaded, and the log says “SSL configuration was not reloaded”. Which certificate are clients being served, and when will you find out?
  5. Which single change makes it impossible for a client to connect unencrypted, and which file is it in?

Expected Outcome

You have established that TLS was already on and worth very little, replaced the certificate with one a client can verify, seen all six sslmode values against one server, and moved the encryption decision from the client to the server.

The two checks to carry away:

-- Which sessions are unencrypted over the network right now?
SELECT a.pid, a.usename, a.client_addr, s.ssl, s.version, s.client_dn
FROM pg_stat_activity a JOIN pg_stat_ssl s USING (pid)
WHERE a.backend_type = 'client backend' AND a.client_addr IS NOT NULL AND NOT s.ssl;

-- Can a client connect unencrypted at all?
SELECT rule_number, type, database, user_name, address, auth_method
FROM pg_hba_file_rules WHERE type IN ('host','hostnossl') ORDER BY rule_number;

The second is the one that matters. Any host rule reachable from the network is a rule that will accept a plain connection.

Troubleshooting

The server refuses to start after installing the certificate. Almost always the key permissions. PostgreSQL requires the private key to be 0600 (or 0640 with group ownership) and owned by the user the server runs as; it refuses to start otherwise and says so: private key file "..." has group or world access. Task 4 sets the permissions wrong deliberately so you meet this message here rather than during a certificate renewal.

SSL configuration was not reloaded. A reload found the new TLS material unusable and kept the context it already had. The server is still serving with the old certificate. This is a warning that reads like an error, and the important part is that nothing changed — check the path, the permissions and the file’s validity, then reload again.

sslmode=verify-full fails with server certificate for "X" does not match host name "Y". That is the check working. verify-full compares the name you connected to against the certificate’s subject alternative names. Connect by the name in the certificate, or reissue the certificate for the name you use.

sslmode=verify-ca fails with root certificate file ... does not exist. The client needs the CA certificate, and by default looks in ~/.postgresql/root.crt. Pass sslrootcert= explicitly rather than relying on that path.

A client still connects unencrypted after Task 7. A host rule earlier in the file matched it. host accepts encrypted and unencrypted clients; only hostssl requires TLS. Check evaluation order with pg_hba_file_rules.

pg_stat_ssl shows ssl = f for a session you expected to be encrypted. Unix-socket connections are local and are never TLS. Filter on client_addr IS NOT NULL, as the audit query in Task 8 does.

Cleanup

docker exec rbpg-lab01 sed -i '/# lab05/d' /etc/postgresql/18/main/pg_hba.conf
docker exec rbpg-lab01 rm -rf /etc/postgresql/18/main/tls /root/tlsclient /root/ca.crt

# /etc/hosts inside a container is a bind mount, so `sed -i` fails with
# "Device or resource busy" — it renames a temporary file over the target.
# Rewrite the contents in place instead.
docker exec rbpg-lab01 bash -c \
  'grep -v -e pg.lab.internal -e db.wrong.name /etc/hosts > /tmp/hosts.new \
   && cat /tmp/hosts.new > /etc/hosts && rm -f /tmp/hosts.new'

docker exec -i -u postgres rbpg-lab01 psql -X <<'SQL'
ALTER SYSTEM RESET ssl_cert_file;
ALTER SYSTEM RESET ssl_key_file;
ALTER SYSTEM RESET ssl_ca_file;
ALTER SYSTEM RESET listen_addresses;
DROP ROLE IF EXISTS tlsuser;
SQL

docker exec rbpg-lab01 pg_ctlcluster 18 main restart

docker exec -u postgres rbpg-lab01 psql -X -c \
  "SELECT name, setting FROM pg_settings WHERE name IN ('ssl','ssl_cert_file','listen_addresses');"

The cluster should be back to ssl = on with the snakeoil certificate and listen_addresses = localhost — which, having done this lab, you now know is a server with encryption available and identity unproven.

Production notes

  • sslmode=require is the setting that reads as safe and is not. It encrypts and authenticates nothing — any certificate, from anyone, including one presented by whatever is between you and the server. verify-full is the only value that checks both the chain and the name.
  • sslmode=prefer — the libpq default — falls back to cleartext with no error, no warning and no log entry when the server declines TLS. A client using the default has not asked for encryption; it has expressed a preference.
  • Move the decision to the server. A hostssl rule with no reachable host rule is enforcement; a well-configured client is a convention that survives until somebody writes a new connection string.
  • Run the pg_stat_ssl audit query on a schedule, not once. It is the only thing that answers “is anything talking to this cluster in cleartext right now” and the answer changes whenever an application is deployed.
  • Certificate expiry is an availability event for a cluster with hostssl rules. Monitor the expiry date, and rehearse the renewal — the failed-reload behaviour above is what a botched renewal looks like.

What You Learned

  • TLS being on says nothing about TLS being useful. The cluster started with a self-signed certificate no client could verify.
  • The private key permissions are a startup precondition, not a recommendation. Wrong mode, no server.
  • A failed TLS reload keeps the old context and warns rather than failing — so the server carries on with the certificate you thought you had replaced.
  • The six sslmode values are four different security postures. disable and allow and prefer do not require encryption; require encrypts without authenticating; verify-ca checks the chain; only verify-full also checks the name.
  • verify-full is what turns encryption into authentication, and the hostname mismatch it produces is the check working.
  • Only the server can enforce. hostssl refuses a plaintext client with no encryption in the message; a host rule accepts both.

Deliverables

  • · tls-baseline.txt - the ssl settings and the certificate the cluster shipped with
  • · sslmode-ladder.txt - pg_stat_ssl for all six sslmode values against one server
  • · verification-gap.txt - the three outcomes when the host name does not match the certificate
  • · enforcement.txt - hostssl refusing an unencrypted client, and the audit query

Verification status

Last reviewed
2026-08-28
Executed end to end
2026-08-28