Skip to main content

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.

info

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.conf TLS block: ssl = on, ssl_min_protocol_version = 'TLSv1.3' (a --allow-tls12-fallback flag 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, and ssl_ecdh_curve = 'prime256v1'.
  • pg_hba.conf using scram-sha-256, applied as a non-breaking transition (plaintext host line kept alongside hostssl) that you later re-run as harden (hostssl-only).
  • The server key is written 0600 and owned by the container database uid (999 for the official postgres:17 image), 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

  • czctl on 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 provision as a user that can write the cert directory and chown the key to the database uid (usually root).

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:

  1. 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).
  2. Writes the postgresql.conf TLS block to <conf.d>/cz-tls.conf. Make sure postgresql.conf includes that directory (include_dir).
  3. Writes the managed pg_hba.conf block (the block is delimited by markers, so re-running replaces it instead of duplicating it).
  4. Reloads PostgreSQL with pg_ctl reload (a SIGHUP — no restart; existing connections keep their state).
  5. Exports the CA certificate to --export-ca for distribution to clients.
  6. 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 transitionharden flow does exactly that:

  1. Run czctl postgres tls provision --mode transition --apply. This generates the certs, writes the TLS config, and writes a pg_hba.conf that keeps the existing plaintext host line above the new hostssl lines.
  2. Reload happens automatically (step 4 of the command). The application, still on sslmode=disable, keeps working.
  3. Confirm the certs and config landed: the command prints [PASS] for each step.
  4. Distribute the exported CA (--export-ca) to the application box.
  5. Flip the application's connection string to sslmode=verify-full&sslrootcert=<path to the CA> and redeploy.
  6. Verify the application reconnects over TLS — run czctl postgres tls verify from the box, and confirm the application's own health checks pass.
  7. Re-run the provisioner with --mode harden --apply. This rewrites pg_hba.conf to hostssl-only, removing the transient plaintext line.
  8. The reload applies the hardened pg_hba.conf. Any client still on plaintext is now rejected.
  9. Confirm with czctl postgres tls verify that verify-full still 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.