Secure PostgreSQL with TLS
Self-managed Control Zero deployments that run their own PostgreSQL box (tenant data, audit logs, or a future co-located database) MUST encrypt the connection between the application and PostgreSQL with TLS. Plaintext on the wire — even on a private network — is not acceptable for the data Control Zero stores.
This page shows how to provision industry-standard PostgreSQL TLS with a single
command, czctl postgres tls provision, using a vetted, load-tested
configuration. The same command works for any self-managed box; nothing here is
specific to one database.
TLS for the application-to-PostgreSQL connection is a deployment requirement, not an optional hardening step. Provision it before you point a production application at a self-managed PostgreSQL box.
What the vetted configuration is
czctl postgres tls provision encodes a single, proven configuration so no
deployment has to hand-roll TLS:
- ECDSA P-256 internal CA (10-year) and a 90-day ECDSA P-256 server leaf. ECDSA P-256 keeps the per-connection handshake cheap.
- Dual Subject Alternative Name (SAN) on the leaf: one IP and one
DNS name, so clients can connect by either and still pass
verify-full. postgresql.confTLS block:ssl = on,ssl_min_protocol_version = 'TLSv1.3'(a--allow-tls12-fallbackflag lowers it to TLS 1.2 if you must support an older client), the Mozilla ECDHE-ECDSA cipher list (which only affects TLS 1.2 and below — TLS 1.3 suites are fixed by OpenSSL),ssl_prefer_server_ciphers = on, andssl_ecdh_curve = 'prime256v1'.pg_hba.confusingscram-sha-256, applied as a non-breakingtransition(plaintexthostline kept alongsidehostssl) that you later re-run asharden(hostssl-only).- The server key is written
0600and owned by the container database uid (999for the officialpostgres:17image), which PostgreSQL requires or it refuses to start.
Under a pooled connection (the application uses a persistent pgxpool), the
measured TLS overhead is about 1.3% versus plaintext — the cost is the
per-connection handshake, amortized away by pooling. The negotiated cipher is
TLS 1.3 / TLS_AES_256_GCM_SHA384.
channel_binding (and the pgx caveat)
channel_binding=require adds protection against a TLS-terminating MITM relay,
but it needs SCRAM-SHA-256-PLUS, which requires pgx v5.9.0 or newer. Control
Zero currently ships pgx v5.5.3, so channel_binding=require would fail today.
The configuration therefore ships with channel_binding off and a
documented hook: once the backend is on pgx v5.9.0+, add channel_binding=require
to the client DSN (the czctl postgres tls verify --channel-binding require
flag exercises it). Until then, sslmode=verify-full is the bar.
Prerequisites
czctlon the PostgreSQL box (it ships with the backend container).- The box's connection details: the private IP clients use, a DNS name for it, and the CIDR of the single application box allowed to connect.
- Run
czctl postgres tls provisionas a user that can write the cert directory andchownthe key to the database uid (usuallyroot).
Provision in one command
By default the command is a dry run — it prints exactly what it would do and
writes nothing. Add --apply to make changes.
czctl postgres tls provision \
--ip 10.20.1.1 \
--dns audit-pg.internal \
--allow-cidr 10.20.1.2/32 \
--role cz_audit_writer --role audit_ro \
--cert-dir /etc/cz-tls \
--container cz-audit-pg \
--hba-path /opt/cz-audit/conf/pg_hba.conf \
--export-ca /opt/cz-audit/certs/ca.crt \
--mode transition \
--apply
What each step does:
- Generates the ECDSA P-256 CA + 90-day dual-SAN leaf into
--cert-dir(idempotent — an existing cert set is reused unless you pass--force). - Writes the
postgresql.confTLS block to<conf.d>/cz-tls.conf. Make surepostgresql.confincludes that directory (include_dir). - Writes the managed
pg_hba.confblock (the block is delimited by markers, so re-running replaces it instead of duplicating it). - Reloads PostgreSQL with
pg_ctl reload(a SIGHUP — no restart; existing connections keep their state). - Exports the CA certificate to
--export-cafor distribution to clients. - Self-verifies (see below).
The "prove TLS" gate
A plaintext canary that merely connects is not proof that TLS is in force.
The provisioner's self-verify step (and the standalone czctl postgres tls verify command) performs a real sslmode=verify-full handshake and asserts
that the server's own pg_stat_ssl view reports the session as encrypted:
czctl postgres tls verify \
--host audit-pg.internal \
--user cz_audit_writer \
--ca /opt/cz-audit/certs/ca.crt
A passing run prints both the client-negotiated protocol/cipher and the
server-side pg_stat_ssl row:
[PASS] TLS proven
client negotiated: TLS 1.3 / TLS_AES_256_GCM_SHA384
pg_stat_ssl: ssl=true version=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384
Provide the password via the --password flag or the CZ_PG_VERIFY_PASSWORD
environment variable; never paste it into a shell history file.
The non-breaking 9-step cutover
Run on a live box, TLS must be added without dropping the application's existing
plaintext connection. The transition → harden flow does exactly that:
- Run
czctl postgres tls provision --mode transition --apply. This generates the certs, writes the TLS config, and writes apg_hba.confthat keeps the existing plaintexthostline above the newhostssllines. - Reload happens automatically (step 4 of the command). The application,
still on
sslmode=disable, keeps working. - Confirm the certs and config landed: the command prints
[PASS]for each step. - Distribute the exported CA (
--export-ca) to the application box. - Flip the application's connection string to
sslmode=verify-full&sslrootcert=<path to the CA>and redeploy. - Verify the application reconnects over TLS — run
czctl postgres tls verifyfrom the box, and confirm the application's own health checks pass. - Re-run the provisioner with
--mode harden --apply. This rewritespg_hba.conftohostssl-only, removing the transient plaintext line. - The reload applies the hardened
pg_hba.conf. Any client still on plaintext is now rejected. - Confirm with
czctl postgres tls verifythatverify-fullstill succeeds — and that a plaintext attempt is refused.
Because every step is idempotent, you can re-run the command at any point without rotating the CA or duplicating config.
Rotation
The 90-day leaf is rotated via reload, not restart:
czctl postgres tls provision \
--ip 10.20.1.1 --dns audit-pg.internal \
--allow-cidr 10.20.1.2/32 --cert-dir /etc/cz-tls \
--container cz-audit-pg --mode harden \
--force --apply
--force regenerates the leaf (and CA) and the automatic pg_ctl reload makes
PostgreSQL pick up the new server certificate without dropping connections.
Rotate the leaf well before its 90-day expiry. When rotating the CA itself,
concatenate the old and new CA certificates into ssl_ca_file during the
overlap window so clients trusting either CA keep working.
Verifying after any change
Always finish with the prove-TLS gate, never a plaintext ping:
czctl postgres tls verify \
--host audit-pg.internal \
--user cz_audit_writer \
--ca /opt/cz-audit/certs/ca.crt
See also the czctl CLI Reference and Security Hardening.