Connecting to ClickHouse over the HTTP interface
Port 8123 is the whole transport, the native port is never used, and a permission denial arrives as a 500, so failures are read by exception code.
A restricted user connects, the schema tree fills in normally, and then the
monitoring panel comes back empty with an HTTP 500 in the network tab. Nothing
crashed. The server was answering a missing SELECT grant, and on this engine
that answer is a 500. If you connect to ClickHouse over the HTTP interface on
8123 and read failures by status code, you will diagnose a permission problem as
an outage every time.
That is not an isolated quirk. It falls out of one decision made at the bottom of the ClickHouse provider, and the same decision determines what you publish from a container and what you never open on a security group.
One HTTP endpoint on 8123, no driver, no pool
The ClickHouse provider carries no driver of any kind. Every statement is the
body of a POST / on the documented HTTP interface, answered through the
runtime’s own fetch. The default port is 8123, or 8443 when TLS is on.
There is no connection pool either. Each statement is one stateless HTTP request.
connect() proves the server, the credentials and the pinned database together
with a single SELECT 1 - the cheapest statement that exercises all three - and
disconnect() has nothing to release, because there is no pool and no session to
close.
The evidence that this is enough is the catalog list. Schema introspection is
three parallel reads: system.tables for name, total_rows, total_bytes,
sorting_key and primary_key; system.columns for name, type,
is_in_primary_key and the default kind and expression, ordered by declaration
position; and system.data_skipping_indices for the nearest thing ClickHouse has
to a secondary index object. Monitoring reads version(), uptime(),
system.metrics, system.server_settings, system.parts and system.disks for
overview and storage, system.events and system.asynchronous_metrics for the
mark-cache ratio and memory, system.query_log filtered to
type = 'QueryFinish' for slow queries, and system.processes for active
sessions. Every one of those is a system.* table reachable by ordinary SQL over
the same endpoint, so a second HTTP surface next to query() would buy nothing.
Foreign keys are the one thing the catalogs cannot answer, and the list is always empty. ClickHouse has no foreign-key concept anywhere - no engine, no table setting, no DDL declares one - so the engine pages state it directly: ER diagrams here show structure without discovered relations.
What to publish from a container, and what not to
The native protocol port 9000 is never used. This provider does not speak it,
and it is out of scope entirely - the native wire format and the server-side
settings only exposed through it are not reachable from here.
That is a boundary with an operational payoff. The local compose service in the
studio repo publishes only 8123, and port 9000 is deliberately not exposed,
because there is no native-protocol transport in the codebase to connect with it:
docker compose -f database-compose.yml up clickhouse
Point a connection at localhost:8123 with user libredb, password
password123, database demo. The service is pinned to
clickhouse/clickhouse-server:26.7.1.1315, the exact build the provider was
live-verified against, and it sets CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 so a
restricted second user can be granted and revoked while you watch what degrades.
The same reasoning applies outward. If the only thing Studio needs is 8123, then
8123 is the only rule a security group needs, and 9000 stays closed with no
capability lost on this side. Connection strings follow the transport rather than
inventing a scheme: clickhouse://, http:// and https:// all parse, and an
https:// URL sets TLS and moves the default port to 8443.
TLS has a limit worth reading before you plan a deployment. The transport uses
global fetch, whose trust follows the platform’s default certificate store, so
ssl.caCert, ssl.clientCert and ssl.rejectUnauthorized are not honoured. A
node behind a publicly-trusted certificate works. A node behind a self-signed
certificate fails verification, and there is no flag here that will talk it out
of that.
Why a denial arrives as a server error
The HTTP interface is mostly honest about failure. A syntax error, an unknown
table and bad credentials all come back as real status codes with ClickHouse’s
numeric exception code in a response header. Two cases break that. One is a
failure part-way through a streamed response: the status line is already
committed as 200, so the real exception arrives fenced in a trailer at the end
of a truncated body instead of in a header. The other is a permission denial,
measured with a purpose-made user granted SELECT on one table only:
| Surface, as a restricted user | Status | Code |
|---|---|---|
system.query_log, system.processes, system.metrics, system.data_skipping_indices |
500 | 497 ACCESS_DENIED |
OPTIMIZE TABLE, KILL QUERY |
500 | 497 ACCESS_DENIED |
system.tables, system.columns |
200 | filtered to what the user may see |
uptime(), version() |
200 | needs no grant |
A permission denial answers 500, not 403 and not 401. Worse for anyone tempted to read the text instead, the 497 message reads “Not enough privileges. To execute this query, it’s necessary to have grant SELECT” - it contains neither “access denied” nor “permission denied”, so message sniffing misses it entirely.
Classifying by exception code rather than status
Detection and classification are therefore split. Detecting that something failed is status-based and lives in the transport. Deciding what failed is code-based, read from the exception-code header, and the transport throws one normalized error carrying that number so provider logic switches on an integer instead of parsing prose.
The payoff is which parts of the interface survive a restricted user. Every
monitoring method degrades to empty or zero on exactly two codes - 497
ACCESS_DENIED and 60 UNKNOWN_TABLE - and any other failure propagates,
because those two are the live-verified codes for “this surface does not exist
for this user or this deployment” while anything else is a mistake that must keep
surfacing. system.data_skipping_indices needs its own grant and answers 500 on
a user who lacks it, so a denied index catalog still yields a full table-and-column
tree with an empty index list. Query statistics come from system.query_log,
which records nothing while log_queries is off, and the panel says exactly that
rather than showing a zero.
The same numbers appear in ordinary editor work. A bare UPDATE ... SET ... WHERE
answers code 48 NOT_IMPLEMENTED, which is why inline row editing is not offered
here at all; the documented way to change a row is ALTER TABLE ... UPDATE typed
in the editor. Multi-statement SQL is rejected by the server itself with code 62,
so no client-side splitting is attempted.
The stateless cost: settings and temporary tables do not persist
One request per statement has a price, and it is stated rather than hidden. No
session_id is pinned, so SET and temporary tables do not survive to the next
statement. A SET max_block_size = 4096 sent on its own changes nothing you will
observe afterwards. The native protocol port is out of scope entirely and no
session is held between statements, so both of those are boundaries of this
transport, not bugs waiting on a fix.
The reason is concurrency. ClickHouse rejects concurrent use of one session_id,
which would serialize every request and break the parallel schema read that fires
three catalog queries at once. Per-request settings are still sent as URL
parameters, which covers what the provider actually needs - a statement deadline
via max_execution_time, for instance.
The same statelessness explains two more absences. There are no transactions:
ClickHouse has no general multi-statement transaction to wrap and the connection
is stateless HTTP, so there is no begin, commit or rollback. And there is no
cancelQuery; a running statement is stopped through the maintenance kill
operation with its query id, which needs its own grant like any other
system.processes operation.
One boundary that is not about the transport, but belongs next to these: agent
mode reads PostgreSQL, SQLite and DuckDB only, because the read-only profile is
database-native and exists only where a provider implements it. On any other
engine a run ends engine-unsupported. Plan mode opens on every connection - it
is toolless, runs nothing, and drafts a statement for you to run yourself.
To check any of this against a real server, the compose service above is the shortest path; the getting started guide covers pointing a Studio container at it.