TLS to ClickHouse, and why a self-signed node fails
An https URL sets TLS and moves the default port, but only the platform trust store applies, so a custom CA has nowhere to go on this transport.
A managed node connects on the first try. The self-hosted one next to it, same version, same credentials, refuses. A ClickHouse https connection is verified against exactly one trust store, the platform’s own, and the certificate on that node is not in it. No field in the connection form changes that.
What an https URL decides for you
The ClickHouse provider carries no driver. Every statement is the body of a
POST / and the answer comes back through the runtime’s own fetch. The default
port is 8123, and 8443 when TLS is on. The native protocol port 9000 is never
used; this provider does not speak it.
Connection strings are supported: clickhouse://, plain http://, and https://.
The scheme is not decoration. An https:// URL sets ssl.mode = "require" and
switches the default port from 8123 to 8443, so two fields you did not type are
decided by one character you did.
| Input | host | port | database |
|---|---|---|---|
clickhouse://localhost:8123/demo |
localhost |
8123 |
demo |
http://reader:s3cret@ch.internal:9123/analytics |
ch.internal |
9123 |
analytics |
https://abc.clickhouse.cloud/default |
abc.clickhouse.cloud |
8443 |
default |
That inference has to happen, and it is worth saying why. A pasted URL is
decomposed into discrete fields - host, port, user, password, database - before the
provider sees it, and no connectionString is stored on the connection. Those
fields alone cannot express TLS. Without the scheme carrying its intent forward, the
connection would go out as plaintext HTTP to the TLS port and fail with a bare
fetch failed.
The inference runs in exactly one direction. SQLBaseProvider offers a
shouldEnableSSL() helper that guesses TLS from substrings in the hostname -
cloud, aws, and so on. The ClickHouse provider inherits it and never calls it,
deliberately: a self-hosted node whose hostname merely contains one of those words
would be silently switched to TLS it does not serve. TLS here comes from the
connection’s own ssl config or from an https:// scheme, never from a guess.
A ClickHouse https connection certificate is checked against one trust store
Any ssl.mode but disable switches the transport from http to https and the
default port from 8123 to 8443. That is the entire branch. There is no separate
CA or client-certificate plumbing on this path, because the transport uses global
fetch, whose TLS trust follows the platform’s default certificate store.
Four non-disable modes exist in the connection form, and on this transport they
all produce the same handshake:
| Mode | What it means here |
|---|---|
verify-system |
The mode that actually describes this transport |
require |
Not “encrypt without checking”; nothing here can skip a check |
verify-ca |
Cannot pin against a pasted CA, because there is nowhere to put one |
verify-full |
Same |
verify-system is the honest name for what happens: fetch always verifies the
chain against the platform’s certificate store and cannot be told otherwise.
Nothing in the code branches on which of the four is selected. On the driver-based
providers require does mean encrypt without checking; here nothing in the
transport can skip the check, so the distinction has no wire to travel down.
Custom CA, client certificate, relaxed verification
Custom CA certificates, client certificates and relaxed verification are not
honoured on this transport. ssl.caCert, ssl.clientCert and
ssl.rejectUnauthorized are fields other providers read and this one ignores. A node
behind a self-signed certificate fails verification, while a publicly trusted
certificate works.
The mechanism is not a policy decision dressed up as one. Global fetch cannot
carry a custom CA or relax verification without an undici Agent as its
dispatcher, and undici is not a dependency of this project. The three fields exist
in the shared connection model because other providers reach their servers through
node:https, where a CA and a client key have somewhere to go. This provider does
not use that path. Honouring them means giving ClickHouse the node:https route the
driver-based providers already have, which is a follow-up rather than a limitation
of the scheme.
So the failure is a boundary, not a misconfiguration. Nothing you type into the connection form will make a self-signed node verify, and the app does not offer a checkbox that pretends otherwise - the same rule that governs every control on the engine pages, where what an engine deliberately cannot do is published beside what it can.
What that leaves for a self-signed deployment
Three outcomes, and they are easy to tell apart before you try:
- ClickHouse Cloud, or any node whose certificate chains to a publicly trusted
root. Works. Paste the
https://URL, get8443, done. - A node behind a certificate signed by your own internal CA. Fails verification, even though that CA is trusted everywhere else in your organisation, because the check reads the platform store and nothing you supply.
- A node behind a self-signed certificate. Fails verification, for the same reason and with less ambiguity.
The second case is the one that surprises people, because an internal CA feels like a solved problem elsewhere in the stack. It is solved by installing that CA in the platform’s own trust store - the container’s, not the app’s - which is a deployment step rather than an application setting, and one this project does not perform on your behalf.
Deployment shapes that work today
A publicly trusted certificate on the node. Terminate TLS with a certificate
whose chain the platform store already contains, serve the HTTP interface on 8443,
and connect with an https:// URL. ClickHouse Cloud is already this shape, which is
why a Cloud connection needs nothing beyond the URL.
A proxy that terminates TLS in front of the HTTP interface. Put a reverse proxy
holding a trusted certificate in front of the node and let it forward to 8123 on
the private network behind it. The trust question moves to the proxy, where a
certificate can be managed with the same tooling as every other public endpoint you
run, and the hop the app makes is the one it is equipped to verify.
There is a third shape that is not a workaround but is worth naming, because it is the deployment this product is built around: the app runs beside the database, on the same private network, and the link between them never leaves it. TLS on that hop is a decision about your own network rather than an unavoidable requirement, and the security page sets out what is protected where when it is made either way.
For a local instance the question does not arise at all. The pinned compose service publishes only the plaintext port:
docker compose -f database-compose.yml up clickhouse
# then connect to localhost:8123, user libredb, database demo
Port 9000 is deliberately not exposed there either, for the same reason it is not
in the connection form: there is no native-protocol transport in this codebase to
connect with it.