Set up a private CA, issue server and client certificates, enforce TLS 1.2+ with strong ciphers, and configure pg_hba.conf for mutual TLS client certificate authentication in PostgreSQL 17.
Prerequisites
- PostgreSQL 17 installed
- Root or sudo access
- OpenSSL installed
- Basic familiarity with pg_hba.conf syntax
What this solves
By default, PostgreSQL connections are unencrypted unless explicitly configured otherwise, and password authentication over the network exposes credentials to interception. This tutorial covers generating a private certificate authority, issuing server and client certificates, enforcing TLS-only connections, and configuring mutual TLS so clients authenticate with certificates instead of passwords.
This setup is appropriate for production databases handling sensitive data, multi-tenant environments, and compliance-driven infrastructure where password-based authentication is insufficient.
Step-by-step configuration
Install PostgreSQL 17 and OpenSSL
Install PostgreSQL 17 and the OpenSSL tools needed to build a certificate authority.
sudo apt update
sudo apt install -y postgresql-17 postgresql-client-17 opensslsudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm
sudo dnf install -y postgresql17-server postgresql17 openssl
sudo /usr/pgsql-17/bin/postgresql-17-setup initdb
sudo systemctl enable --now postgresql-17Create a private certificate authority
The CA signs both the server certificate and every client certificate. Keep the CA private key offline or heavily restricted after issuing certificates.
sudo mkdir -p /etc/postgresql/ssl/ca
cd /etc/postgresql/ssl/ca
sudo openssl genrsa -aes256 -out ca.key 4096
sudo openssl req -new -x509 -days 3650 -key ca.key -sha256 \
-out ca.crt \
-subj "/C=NL/O=Example Corp/CN=Example Corp PostgreSQL CA"You will be prompted for a passphrase to encrypt the CA private key. Use a strong passphrase and store it in a secrets manager, not on the filesystem.
Generate the server certificate
Create a key and certificate signing request (CSR) for the PostgreSQL server. The common name must match the hostname clients use to connect.
sudo mkdir -p /etc/postgresql/ssl/server
cd /etc/postgresql/ssl/server
sudo openssl genrsa -out server.key 4096
sudo openssl req -new -key server.key -out server.csr \
-subj "/C=NL/O=Example Corp/CN=db01.example.com"Sign the CSR with the CA to produce the server certificate, valid for one year.
sudo openssl x509 -req -in server.csr -CA /etc/postgresql/ssl/ca/ca.crt \
-CAkey /etc/postgresql/ssl/ca/ca.key -CAcreateserial \
-out server.crt -days 365 -sha256Set correct ownership and permissions on certificates
PostgreSQL refuses to start if the server private key is readable by other users. The postgres process owns and reads these files directly, so ownership must belong to the postgres user with restrictive permissions.
sudo chown postgres:postgres /etc/postgresql/ssl/server/server.key /etc/postgresql/ssl/server/server.crt
sudo chmod 600 /etc/postgresql/ssl/server/server.key
sudo chmod 644 /etc/postgresql/ssl/server/server.crt
sudo chown postgres:postgres /etc/postgresql/ssl/ca/ca.crt
sudo chmod 644 /etc/postgresql/ssl/ca/ca.crtEnable SSL in postgresql.conf
Point PostgreSQL at the certificate files and turn on SSL support.
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server/server.crt'
ssl_key_file = '/etc/postgresql/ssl/server/server.key'
ssl_ca_file = '/etc/postgresql/ssl/ca/ca.crt'
ssl_prefer_server_ciphers = on/var/lib/pgsql/17/data/postgresql.conf unless you changed the data directory during initdb.Enforce strong TLS protocol versions and cipher suites
Restrict connections to TLS 1.2 and above, and set an explicit cipher list to block weak algorithms.
ssl_min_protocol_version = 'TLSv1.2'
ssl_max_protocol_version = 'TLSv1.3'
ssl_ciphers = 'HIGH:!aNULL:!MD5:!3DES:!RC4'
ssl_ecdh_curve = 'prime256v1'Restart PostgreSQL to apply the SSL settings.
sudo systemctl restart postgresqlIssue a client certificate for mutual TLS
Each client certificate's common name must exactly match the PostgreSQL role it authenticates as. Generate one per application or admin user.
sudo mkdir -p /etc/postgresql/ssl/clients
cd /etc/postgresql/ssl/clients
sudo openssl genrsa -out app_user.key 4096
sudo openssl req -new -key app_user.key -out app_user.csr \
-subj "/C=NL/O=Example Corp/CN=app_user"
sudo openssl x509 -req -in app_user.csr -CA /etc/postgresql/ssl/ca/ca.crt \
-CAkey /etc/postgresql/ssl/ca/ca.key -CAcreateserial \
-out app_user.crt -days 365 -sha256Set restrictive ownership before distributing the client key to the application host.
sudo chmod 600 app_user.key
sudo chmod 644 app_user.crtCreate the matching PostgreSQL role
The role name must exactly match the certificate common name for cert authentication to succeed.
sudo -u postgres psql -c "CREATE ROLE app_user LOGIN;"
sudo -u postgres psql -c "GRANT CONNECT ON DATABASE appdb TO app_user;"Configure pg_hba.conf for certificate authentication
Set the authentication method to cert and require clientcert verification so PostgreSQL rejects any connection lacking a CA-signed client certificate.
# TYPE DATABASE USER ADDRESS METHOD
hostssl appdb app_user 203.0.113.0/24 cert clientcert=verify-full
hostssl all all 0.0.0.0/0 rejectThe clientcert=verify-full option checks both that the certificate is signed by the trusted CA and that its common name matches the connecting role. Reload PostgreSQL to apply the rules.
sudo systemctl reload postgresqlVerify your setup
Copy the CA certificate and client certificate and key to the client machine, then connect with sslmode=verify-full to validate both the server identity and the client certificate.
psql "host=db01.example.com dbname=appdb user=app_user \
sslmode=verify-full \
sslrootcert=/etc/postgresql/ssl/ca/ca.crt \
sslcert=/etc/postgresql/ssl/clients/app_user.crt \
sslkey=/etc/postgresql/ssl/clients/app_user.key"Confirm the connection is using SSL and check the negotiated protocol and cipher.
psql -c "SELECT ssl, version, cipher FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) WHERE pid = pg_backend_pid();"Confirm password authentication is actually rejected for the same user without a certificate.
psql "host=db01.example.com dbname=appdb user=app_user sslmode=require"This should fail with an authentication error since no client certificate was supplied.
Rotating certificates without downtime
Certificates expire, and rotating the server certificate should not require a restart. PostgreSQL reloads SSL context on pg_reload_conf() as long as the file paths in postgresql.conf stay the same.
sudo openssl req -new -key /etc/postgresql/ssl/server/server.key \
-out /etc/postgresql/ssl/server/server_renew.csr \
-subj "/C=NL/O=Example Corp/CN=db01.example.com"
sudo openssl x509 -req -in /etc/postgresql/ssl/server/server_renew.csr \
-CA /etc/postgresql/ssl/ca/ca.crt -CAkey /etc/postgresql/ssl/ca/ca.key \
-CAcreateserial -out /etc/postgresql/ssl/server/server.crt.new -days 365 -sha256
sudo mv /etc/postgresql/ssl/server/server.crt.new /etc/postgresql/ssl/server/server.crt
sudo chown postgres:postgres /etc/postgresql/ssl/server/server.crt
sudo -u postgres psql -c "SELECT pg_reload_conf();"For client certificates, issue the replacement before the old one expires, distribute it to the application, and update the application config to point at the new file with a rolling deploy. Since both certificates are trusted by the same CA during the transition window, there is no need to disable authentication.
Set a calendar reminder or monitoring check for certificate expiry dates well before renewal is due. If you already run PostgreSQL streaming replication with PgBouncer, apply the same server certificate rotation on standby nodes to keep replication connections consistent.
Combining with connection pooling
If you terminate connections through PgBouncer, configure PgBouncer to also require TLS between the application and the pooler, and between the pooler and PostgreSQL. See Configure PostgreSQL 17 connection pooling with PgBouncer for high availability for the pooler-side setup, then apply the sslmode=verify-full settings from this tutorial to the pooler's backend connection string.
For broader hardening beyond SSL, including password policies, connection limits, and role separation, see Configure PostgreSQL 17 SSL encryption and advanced security hardening.
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| FATAL: no pg_hba.conf entry for host, SSL off | Client connected without sslmode=require or higher | Add sslmode=verify-full to the connection string, confirm hostssl is used in pg_hba.conf |
| could not load private key file, permission denied | server.key is not readable by the postgres user | Run sudo chown postgres:postgres server.key && sudo chmod 600 server.key |
| certificate verify failed: unable to get local issuer certificate | Client does not have the CA certificate, or sslrootcert path is wrong | Copy ca.crt to the client and reference it with sslrootcert |
| FATAL: certificate authentication failed for user | Certificate common name does not match the PostgreSQL role name | Regenerate the client certificate with CN matching the exact role name |
| SSL error: sslv3 alert handshake failure | Client and server share no compatible cipher or protocol version | Check ssl_min_protocol_version and ssl_ciphers, update client OpenSSL if outdated |
| connection works with sslmode=require but fails with verify-full | Server certificate CN does not match the hostname used to connect | Reissue server certificate with CN matching the DNS name clients use |
| server won't start after enabling ssl | Missing or misnamed certificate files in postgresql.conf paths | Verify file paths with sudo -u postgres psql -c "SHOW ssl_cert_file;" and check the PostgreSQL log |
Next steps
- Configure PostgreSQL 17 connection pooling with PgBouncer for high availability
- Configure PostgreSQL 17 streaming replication for high availability with automatic failover
- Configure PostgreSQL 17 SSL encryption and advanced security hardening
- Set up Vault as a PKI certificate authority with SSL automation and intermediate CA
- Monitor PostgreSQL performance with Prometheus and Grafana dashboards