CloudNativePG Part 8: Two TLS Worlds and the Name Between Them
One PostgreSQL connection, two certificate authorities: a public CA proves the server while CloudNativePG keeps its internal CA for client certificates.
Introduction
Every hostname on the Bletchley cluster that goes through Traefik gets a Let's Encrypt certificate, automatically, without me thinking about it. cert-manager watches the Ingress, solves a DNS-01 challenge against the TransIP zone, and the browser is happy. It has worked that way for ten hostnames since March, and I had stopped noticing it was a mechanism at all rather than just a property of the cluster.
PostgreSQL never passes through Traefik. It listens on a bare MetalLB LoadBalancer on port 5432, so no Ingress annotation ever created a certificate for it. CloudNativePG generates its own instead, from its own internal CA, carrying sixteen subject alternative names that are all in-cluster service names. None of them is a name a machine outside the cluster could ask for, and postgres.vluwte.nl, the name that should point at the database, had been resolving to a Traefik entrypoint that stopped existing on August 30.
Fixing that turned out to be less about certificates than about noticing what a PostgreSQL connection has always been able to do. There are two trust anchors on one connection, not one. The server proves its identity using one certificate authority, and it decides which authority it will accept client certificates from entirely separately. CloudNativePG will let you move the first of those to a public CA while it goes on managing the second on its own internal CA, and the reason that works is that CloudNativePG's own verification deliberately never looks at hostnames. That single design decision is what makes the split possible, and it constrains everything downstream of it.
So the work has three parts: choosing which half of the connection gets a public certificate, getting CloudNativePG to accept it without breaking the clients inside the cluster, and proving from outside that the half I moved is genuinely verifiable. The rest of the post follows those three, and ends on an openssl handshake where both anchors are visible at once.
This post is part of the CloudNativePG sub-series.
- Part 5: lldap's Database Goes First
- Part 6: Migrating Umami
- Part 7: Turning Off the Old One
- Part 8: Two TLS Worlds and the Name Between Them
🏠 This is part of the Homelab Journey series - building a production Kubernetes cluster from scratch.
- CloudNativePG Part 7
- CloudNativePG Part 8: Two TLS Worlds and the Name Between Them (you are here)

This post assumes you have a CloudNativePG cluster running and exposed outside Kubernetes, and cert-manager issuing certificates from a real ACME issuer. If you're starting from scratch, CloudNativePG Part 3: Installation and Exposing It with MetalLB and Certificate Management: cert-manager on the Bletchley Cluster are the prerequisites.
What the Database Was Actually Serving
Before touching anything, I wrote down what was there. The whole post depends on the before and after being comparable, so the same commands that verify the change were run first to establish the baseline.
kubectl -n databases get secret bletchley-pg-server \
-o jsonpath='{.data.tls\.crt}' | base64 -d \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
subject=CN=bletchley-pg-rw
issuer=OU=databases, CN=bletchley-pg
notBefore=Aug 2 15:32:50 2026 GMT
notAfter=Oct 31 15:32:50 2026 GMT
Sixteen SANs, and it is worth reading all of them because the shape is the argument:
bletchley-pg-rw, bletchley-pg-rw.databases, bletchley-pg-rw.databases.svc,
bletchley-pg-rw.databases.svc.cluster.local,
bletchley-pg-r, bletchley-pg-r.databases, bletchley-pg-r.databases.svc,
bletchley-pg-r.databases.svc.cluster.local,
bletchley-pg-ro, bletchley-pg-ro.databases, bletchley-pg-ro.databases.svc,
bletchley-pg-ro.databases.svc.cluster.local,
bletchley-pg-external, bletchley-pg-external.databases,
bletchley-pg-external.databases.svc,
bletchley-pg-external.databases.svc.cluster.local
Four services, four DNS forms each. Not one of them is a name that exists outside the cluster. An off-cluster client connecting to pg.vluwte.nl is presented a certificate that does not mention pg.vluwte.nl, signed by an authority it has never heard of, which rotates every ninety days. There is no client-side configuration that makes that verifiable in any durable way. You could distribute the CA, but you would be redistributing it four times a year.
That is why Umami ran without sslmode: not as a shortcut, but because that was the only configuration that connected.
Part One: One Certificate Cannot Do Both Jobs
CloudNativePG lets you supply your own server certificate through spec.certificates.serverTLSSecret. My first instinct was that this would be additive, my hostname alongside the ones the operator already had. It is not. Supplying serverTLSSecret replaces the operator's certificate wholesale. Whatever is in that Secret is what every client sees, in-cluster and out.
Which surfaces the constraint that decides this whole post: Let's Encrypt will not sign bletchley-pg-rw, and it will not sign anything under .cluster.local. The first is not a public FQDN. The second is a reserved special-use domain that no public CA is permitted to issue for. So the certificate that lets sulu verify the database cannot carry those names while remaining a publicly trusted certificate issued by a public CA such as Let's Encrypt.

I looked at four ways through this:
| Option | What it does | Why it did or didn't win |
|---|---|---|
| A: Let's Encrypt certificate, external name only | Certificate for postgres.vluwte.nl, supplied as serverTLSSecret. No in-cluster names on it at all. |
Chosen. Publicly trusted, auto-renewing, nothing to install on any client. Costs in-cluster hostname matching. |
| B: internal CA certificate carrying every name | One certificate from the Part 5 internal CA with the external name and all sixteen service names. | Preserves everything, but is not publicly trusted, which was the goal. The root still has to reach every off-cluster client. Kept as the fallback. |
C: separate external endpoint via a Pooler |
Terminate external TLS on a PgBouncer with its own certificate, leave the operator's alone. | The genuinely clean separation, and a whole extra component with its own failure modes for two tenants. Disproportionate. |
D: serverAltDNSNames |
Add postgres.vluwte.nl to the operator's generated certificate. One field, no new objects. |
Solves the hostname and nothing else. The signing CA still rotates quarterly and is still untrusted, so sulu still cannot verify. The hostname was always the easy half. |
Option D deserves a second look because it is the obvious thing and it is a real supported feature, not a hack. It just answers a different question. verify-full needs a name match and a trusted chain; D delivers the name match and leaves the chain exactly as broken as it was.
Option A is what the intro described, made concrete: Let's Encrypt gets the server certificate and nothing else, while the internal CA keeps the client and replication certificates it has been signing since August. One connection, two authorities, each doing only the job it is allowed to do. The rest of the post is what it takes to make that split hold.
Part Two: The Hinge, and Why the Split Is Even Available
The obvious objection to Option A is that ripping the service names off the server certificate should break every client inside the cluster. It does not, and the reason is written down explicitly in the CloudNativePG certificates documentation:
the operator and instances verify server certificates against the CA only, disregarding the DNS name
And then, remarkably, the reason:
This approach is due to the typical absence of DNS names in user-provided certificates for the <cluster>-rw service used for communication within the cluster.CloudNativePG anticipated exactly this situation and designed for it. That sentence is the hinge the entire post turns on. It is what makes Option A supported rather than reckless, it is why the operator and the replicas do not care that bletchley-pg-rw has vanished from the certificate, and, as Part Three shows, it is also what dictates how narrow the CA Secret has to be.
The other half of the split is stated just as plainly, a few paragraphs further down the same page:
The operator still creates and manages the two secrets related to client certificates.
So serverTLSSecret is not "replace the cluster's TLS". It is "replace one direction of it". clientCASecret and the streaming_replica certificate stay on the internal CA, untouched, which is what will make the handshake at the end of this post look the way it does.
That leaves the one client the operator does not control. LLDAP is Authelia's authentication backend, so a Postgres problem here is a login problem, and its connection string settles it:
kubectl -n lldap get externalsecret -o yaml | grep connection-url
# → postgresql://{{ .username }}:{{ .password }}@bletchley-pg-rw.databases.svc:5432/lldap
No sslmode parameter, and nothing in the pod environment overrides it, so sqlx falls back to its documented default of Prefer: negotiate TLS, validate neither chain nor hostname. The one in-cluster client that could have been hurt by losing bletchley-pg-rw from the SAN list was never checking it.
Gate cleared. Option A.
Part Three: Getting CloudNativePG to Accept It
The Certificate itself is unremarkable. It is the same DNS-01 path that already works for ten other hostnames, and postgres.vluwte.nl has no public A record, which does not matter because DNS-01 only needs a TXT record at _acme-challenge.postgres.vluwte.nl.
apps/databases/certificate-postgres-vluwte.yaml:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: postgres-vluwte-tls
namespace: databases
spec:
secretName: postgres-vluwte-tls
secretTemplate:
labels:
cnpg.io/reload: "true"
usages:
- server auth
dnsNames:
- postgres.vluwte.nl
issuerRef:
name: letsencrypt-production
kind: ClusterIssuer
usages: server auth is the split showing up in the manifest: this certificate is for one direction of the connection only. And cnpg.io/reload: "true" is the single most consequential line in the file, because without it a renewal is issued that the running instances never pick up.
One SAN. I considered adding pg.vluwte.nl as a transitional second name and decided against it: that name has never appeared on any certificate, including the current one, so connections to it have always been unverified. Adding it would grant a verify-full that nothing asks for.
It issued first time, and then the interesting part:
kubectl -n databases get secret postgres-vluwte-tls -o jsonpath='{.data}' | jq 'keys'
[
"tls.crt",
"tls.key"
]
No ca.crt. And CloudNativePG requires one, since the docs are unambiguous that serverTLSSecret and serverCASecret are both mandatory or neither is. Meanwhile cert-manager's documentation explains why the key is missing:
if the Certificate Authority is known, the corresponding CA certificate will be stored in the secret with keyca.crt. For example, with the ACME issuer, the CA is not known andca.crtwill not exist in the Secret.
So serverCASecret has to be a second Secret, built by hand, containing the root. This is the step that will catch most people out, because nothing tells you about it until the instance manager fails to start.
Which raises the obvious question: won't the admission webhook stop me from applying a broken configuration? I checked, with both Secrets deliberately absent:
kubectl apply -f apps/databases/cluster.yaml --dry-run=server
# → cluster.postgresql.cnpg.io/bletchley-pg configured (server dry run)
configured rather than unchanged, so the block was seen as a real change and accepted, with the Secrets it names not existing. The webhook validates the shape of the spec and nothing else. It is not a safety net. A typo in a Secret name, or a missing ca.crt, will pass admission cleanly and fail later, inside the instance manager, on a live database.
Never Name the Root From Memory
The draft of this step I wrote a week earlier said to build the CA Secret from isrgrootx1.pem. That is what every guide says, because every guide was written before late 2025. It would have been wrong.
Read the chain out of the Secret instead of assuming it:
kubectl -n databases get secret postgres-vluwte-tls \
-o jsonpath='{.data.tls\.crt}' | base64 -d \
| openssl crl2pkcs7 -nocrl -certfile /dev/stdin \
| openssl pkcs7 -print_certs -noout
subject=CN=postgres.vluwte.nl
issuer=C=US, O=Let's Encrypt, CN=YR2
subject=C=US, O=Let's Encrypt, CN=YR2
issuer=C=US, O=ISRG, CN=Root YR
subject=C=US, O=ISRG, CN=Root YR
issuer=C=US, O=Internet Security Research Group, CN=ISRG Root X1
YR2 is a Generation Y intermediate. Let's Encrypt introduced a new hierarchy in late 2025: YR1/YR2/YR3 chain to ISRG Root YR, replacing ISRG Root X1. A Secret containing X1 would have contained a root that does not sign this certificate, and per the dry run above, nothing would have caught it at admission.
Note also that the chain has three certificates, not two. The third is Root YR cross-signed by ISRG Root X1, and it is the entire transition mechanism. Let's Encrypt publishes Root YR both self-signed and cross-signed, and cert-manager stored the cross-signed copy, so the served chain terminates in a root that older trust stores already have.
For the Secret I used the self-signed root, not the cross-signed one. Both verify today, but the cross-sign exists specifically to be retired once trust stores carry Root YR directly. Take the .pem, not the .txt, because the .txt is a human-readable description of a certificate rather than a certificate:
curl -sSL https://letsencrypt.org/certs/gen-y/root-yr.pem -o /tmp/root-yr.pem
kubectl -n databases get secret postgres-vluwte-tls \
-o jsonpath='{.data.tls\.crt}' | base64 -d > /tmp/chain.pem
openssl verify -CAfile /tmp/root-yr.pem -untrusted /tmp/chain.pem /tmp/chain.pem
# → /tmp/chain.pem: OK
Prove the chain closes against the root before it goes anywhere near the cluster. Then build the Secret:
kubectl -n databases create secret generic postgres-vluwte-ca \
--from-file=ca.crt=/tmp/root-yr.pem --dry-run=client -o yaml \
> apps/databases/secret-postgres-vluwte-ca.yaml
grep -o 'ca.crt: .*' apps/databases/secret-postgres-vluwte-ca.yaml \
| cut -d' ' -f2 | base64 -d | head -1
# → -----BEGIN CERTIFICATE-----
kubectl create secret --from-file base64-encodes whatever you hand it without looking at it. That decode check is the only thing standing between the wrong file and a Secret that looks entirely plausible.
Why the Pin Has to Be Narrow
Part 8 rejects Option B partly because it requires pinning a CA on the client, and then it goes and pins a CA in serverCASecret. That deserves stating rather than glossing over.
The pin is immune to expiry, since Root YR is valid to 2045, but it is exposed to distrust. If Root YR were ever dropped from trust stores, every operating system would follow and this Secret would not.
The obvious-looking fix is trust-manager with useDefaultCAs: true, which tracks Mozilla's store and would handle distrust automatically. I had that written into an earlier draft and it is actively worse here, for a reason that comes straight back to the hinge in Part Two. useDefaultCAs puts roughly 150 public CAs into serverCASecret, and serverCASecret is what the replicas use to verify their own primary, using CA-only verification that disregards the DNS name. Combine those two facts and any certificate from any public CA, for any hostname, would satisfy a replica verifying its primary. The posture goes from only Let's Encrypt can present as our database to anyone who can obtain a public certificate can.
The narrow pin is what compensates for CloudNativePG not checking the name. A narrow pin plus no hostname check is defensible. A 150-CA bundle plus no hostname check is not. The same design decision that made the split possible is the one that decides how wide this Secret is allowed to be.
A Reload, Not a Restart
Three lines at the top level of spec in apps/databases/cluster.yaml:
certificates:
serverTLSSecret: postgres-vluwte-tls
serverCASecret: postgres-vluwte-ca
I expected this to roll the instances. It did not, and the reason is structural rather than lucky. The certificate is not mounted by the kubelet. bletchley-pg-1's volumes are only pgdata, scratch-data, shm, plugins and the API access token. The instance manager fetches the material through the API and writes it to disk itself, so the Secret name is runtime configuration, not pod spec. And PostgreSQL has re-read SSL parameters on SIGHUP since version 10.
The instance logs show the whole mechanism, all three instances within thirty milliseconds of each other:
18:51:00.669 "Refreshed configuration file" filename=/controller/certificates/server.crt secret=postgres-vluwte-tls
18:51:00.672 "Refreshed configuration file" filename=/controller/certificates/server.key secret=postgres-vluwte-tls
18:51:00.695 "Refreshed configuration file" filename=/controller/certificates/server-ca.crt secret=postgres-vluwte-ca
18:51:00.745 "reloading the instance" logger=instance-manager instance=bletchley-pg-3
18:51:00.745 "Requesting configuration reload" pgCtlOptions=["-D","/var/lib/postgresql/data/pgdata","reload"]
18:51:00.747 logger=pg_ctl "server signaled"
18:51:00.747 logger=postgres "received SIGHUP, reloading configuration files" backend_type=postmaster
session_start_time="2026-08-09 10:41:01 UTC"
session_start_time still reads August 9. That is the same postmaster process that was running before the change, which is proof that no restart happened, independent of pod ages. Pod ages stayed at 29 days, restart counts at zero, and the operator log recorded nothing but three "Defaulting for Cluster" lines. It never considered a rollout.
Notice which files got written. server.crt and server.key came from the Let's Encrypt Secret, server-ca.crt from the pinned root, and nothing touched client-ca.crt, which is the file the internal CA still owns. The two anchors are two different files on disk, which is the clearest possible sign that they were never one thing.
The check that actually mattered was replication:
kubectl cnpg status bletchley-pg -n databases
Three instances ready, both replicas streaming at zero lag on the same LSN, primary promotion time unchanged. This is the one place a wrong root would have surfaced, because the replicas verify the primary through serverCASecret and nothing else in the system would have complained. Pod readiness would have looked perfectly fine.
I also checked something the rollback plan had been assuming without evidence:
kubectl -n databases get secret bletchley-pg-server bletchley-pg-ca
# → both present, 36d
The operator does not garbage-collect the Secrets it stopped referencing, so removing spec.certificates really would put the cluster back on material that still exists rather than triggering a fresh CA generation. Good to know before needing it rather than after.
Then the DNS repoint, a hand edit in internal DNS, and the one step in this whole exercise that no kubectl apply will roll back:
$ dig +short postgres.vluwte.nl
10.0.140.101
$ dig +short pg.vluwte.nl
10.0.140.101
Both names now answer. pg.vluwte.nl stays for the grace period, so there is always a route back.
Part Four: Proving It From Outside
This is the part the post exists for. From sulu, with nothing installed and no -CAfile passed:
openssl s_client -connect postgres.vluwte.nl:5432 -starttls postgres \
-servername postgres.vluwte.nl -verify_return_error </dev/null
depth=3 C=US, O=Internet Security Research Group, CN=ISRG Root X1
verify return:1
depth=2 C=US, O=ISRG, CN=Root YR
verify return:1
depth=1 C=US, O=Let's Encrypt, CN=YR2
verify return:1
depth=0 CN=postgres.vluwte.nl
verify return:1
---
Certificate chain
0 s:CN=postgres.vluwte.nl
i:C=US, O=Let's Encrypt, CN=YR2
v:NotBefore: Sep 7 16:52:45 2026 GMT; NotAfter: Dec 6 16:52:44 2026 GMT
1 s:C=US, O=Let's Encrypt, CN=YR2
i:C=US, O=ISRG, CN=Root YR
v:NotBefore: Sep 3 00:00:00 2025 GMT; NotAfter: Sep 2 23:59:59 2028 GMT
2 s:C=US, O=ISRG, CN=Root YR
i:C=US, O=Internet Security Research Group, CN=ISRG Root X1
v:NotBefore: May 13 00:00:00 2026 GMT; NotAfter: Sep 2 23:59:59 2032 GMT
---
Acceptable client certificate CA names
OU=databases, CN=bletchley-pg
---
Protocol : TLSv1.3
Verification: OK
Verify return code: 0 (ok)
sulu is a RHEL box whose CA bundle contains ISRG Root X1 and X2 and no Root YR at all. Verification succeeds anyway, because the cross-signed copy in the served chain closes the path at a root it already has. Depth 3 is the entire Generation Y transition doing its job, and it means keeping that trust store current stays the operating system's problem rather than becoming a task on my list. The cross-signed Root YR at position 2 was issued on May 13, 2026, which is when Let's Encrypt began the transition in earnest, and it is not a certificate anybody normally prints.
Then psql, which failed on the first attempt in a way worth reproducing:
psql: error: connection to server at "postgres.vluwte.nl" (10.0.140.101), port 5432 failed:
root certificate file "/Users/igor/.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.
sslmode=verify-full does not mean "use the operating system's trust store." libpq looks for ~/.postgresql/root.crt and gives up if it is not there. The OS bundle is opt-in, via sslrootcert=system. If you hit this, it reads like the certificate didn't work, and the certificate is fine.
With that added:
$ psql "host=postgres.vluwte.nl port=5432 dbname=umami user=umami \
sslmode=verify-full sslrootcert=system" \
-c "SELECT ssl, version, cipher FROM pg_stat_ssl
JOIN pg_stat_activity USING (pid) WHERE pid = pg_backend_pid();"
ssl | version | cipher
-----+---------+------------------------
t | TLSv1.3 | TLS_AES_256_GCM_SHA384
(1 row)
An off-cluster client, on a hostname that exists only in internal DNS, verifying both the chain and the hostname against nothing but the OS trust store. No -CAfile, no pinned CA, nothing distributed, nothing to go stale in ninety days.
And the contrast shot, which is what gives the success meaning:
$ psql "host=pg.vluwte.nl port=5432 dbname=umami user=umami \
sslmode=verify-full sslrootcert=system" -c "SELECT 1;"
psql: error: connection to server at "pg.vluwte.nl" (10.0.140.101), port 5432 failed:
server certificate for "postgres.vluwte.nl" does not match host name "pg.vluwte.nl"
Same IP, same server, same certificate, same trust store. Only the name differs. The hostname check is genuinely being enforced, which proves the passing case was a real verification and not simply an absence of objection.
Two Authorities, One Connection
Buried in the middle of that handshake, between the chain and the protocol summary, is the block this whole post was walking toward:
Acceptable client certificate CA names
OU=databases, CN=bletchley-pg
Read it next to the chain a few lines above it. In one TCP connection, the server presents a Let's Encrypt certificate to prove who it is, and in the same handshake announces that the client certificates it will accept must be signed by the internal CloudNativePG CA. Two certificate authorities, two directions, one connection, and neither one knows or cares about the other.

That is not a workaround, and it is not something Part 8 invented. It is how TLS has always worked: server identity and client authentication are separate decisions with separate anchors. What Part 8 did was notice that CloudNativePG exposes those two decisions as two separate fields, serverTLSSecret and clientCASecret, and that it only ever asked me to fill in the first one.
The reason that is safe rather than clever is the hinge from Part Two. Because the operator verifies against the CA and disregards the DNS name, the server certificate is free to carry a name that means nothing inside the cluster. And because the operator kept managing the client side on its own CA, the replication and client authentication that depend on names the public CA would never sign carried on working without being touched.
The practical version, for anyone doing this on their own cluster: you are not deciding whether to trust Let's Encrypt or your internal CA. You are deciding which of them owns which half of the connection, and the answer can reasonably be "one each".
The Warning That Corrected Part 6
Last step: move Umami's DATABASE_URL to the new name and give it an sslmode. It started clean, no P1011, and then logged this on its first database connection:
Warning: SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca'
are treated as aliases for 'verify-full'.
In the next major version (pg-connection-string v3.0.0 and pg v9.0.0), these
modes will adopt standard libpq semantics, which have weaker security guarantees.
That corrects what I wrote in Part 6. I recorded the P1011 failure there as "Prisma implements sslmode=require as verify the chain, unlike libpq." Half right, and the wrong half matters. The behaviour belongs to pg-connection-string, and it is not chain verification. require is an outright alias for verify-full, chain and hostname. So in Part 6 the connection was failing on two counts simultaneously: an untrusted internal CA, and a hostname that appeared on no certificate. Fixing only the CA would never have been enough. Part 8 happened to fix both, which is why it works now.
Two consequences, and the second is why I did not just leave require in place.
Umami is at full verification today whether or not the connection string says so. And that is silently temporary: the warning says pg v9 will make require mean standard libpq require, encrypted with nothing verified. A routine dependency bump would then downgrade this connection from full verification to none, with no error and nothing in the logs. So I set it explicitly:
postgresql://umami:<password>@postgres.vluwte.nl:5432/umami?schema=public&sslmode=verify-full
The old string also carried sslaccept=accept_invalid_certs, a Prisma parameter left over from the Part 6 workaround, being parsed by pg-connection-string, which ignores it outright. The connection string was asking the client to accept invalid certificates while the connection was in fact fully verified. Harmless while ignored, and a switch that would silently disable everything this post built if some future version started honouring it. Removed.
Three different meanings for one keyword, then: libpq's require (encrypt only), node-postgres' require (verify everything), and libpq's verify-full, which still needs sslrootcert=system before it will look at the OS store at all. The mode name tells you how strictly to verify. It never tells you where the trust comes from, or what your particular library thinks the word means.
What's Working Now
- ✅ The server half of the connection is anchored on a public CA and the client half is still anchored on the internal CloudNativePG CA, independently, on the same TCP connection
- ✅
postgres.vluwte.nlresolves to10.0.140.101and answers on5432, so the good name points at the database for the first time - ✅
psql "host=postgres.vluwte.nl sslmode=verify-full sslrootcert=system"succeeds from off-cluster against the system trust store, with nothing pinned and nothing distributed - ✅ Umami connects with an explicit
sslmode=verify-full, chain and hostname both checked - ✅ The certificate auto-renews through cert-manager, and
cnpg.io/reloadon both Secrets means the instances pick up the new material without a restart - ✅ LLDAP, streaming replication and the operator were unaffected throughout: no restart, no failover, no re-promotion
- ✅
pg.vluwte.nlstill works during the grace period, so there is a route back
Lessons Learned
- A PostgreSQL connection has two trust anchors, not one. Server identity and client authentication are separate decisions with separate certificate authorities, and CloudNativePG exposes them as separate fields. Once you see that, "which CA should the database use?" stops being the right question.
- CA-only verification is what makes the split legal. CloudNativePG verifies against the CA and disregards the DNS name, on purpose, precisely because user-supplied certificates rarely carry service names. The blocker was answered in the documentation; the fix was reading rather than engineering.
serverTLSSecretreplaces, it does not extend. And because Let's Encrypt is forbidden from signing.cluster.local, no single certificate can serve both the in-cluster and the external name. That constraint is what the whole design fell out of.- The pin has to be narrow because there is no hostname check behind it.
useDefaultCAslooks tidier and would have let any publicly-issued certificate satisfy a replica verifying its primary. Narrow trust is what compensates for name-blind verification. - Read the root out of the chain that was actually issued. Every guide names ISRG Root X1 because every guide predates Generation Y. Building the CA Secret from memory would have produced a root that does not sign this certificate, and nothing in the admission path would have said a word.
- The admission webhook validates shape, not substance.
spec.certificateswas accepted on a dry run with both Secrets entirely absent. Ordering the work so the Secrets exist first is the actual safety mechanism, not tidiness. - An SSL mode name tells you how strictly to verify, never where trust comes from.
verify-fullneedingsslrootcert=system, and node-postgres treatingrequireasverify-full, are the same trap in two different libraries.
← Previous: CloudNativePG Part 7
Questions or suggestions? Leave a comment below or reach out at igor@vluwte.nl.