Configuring ClickHouse Network Security Groups
When a ClickHouse cluster moves from a single-node proof of concept to a replicated production deployment, the network layer becomes the first thing that breaks: INSERT batches time out mid-stream, Distributed table queries return partial results, and ReplicatedMergeTree fetch queues stall because a cloud Network Security Group (NSG) silently drops interserver traffic between availability zones. The failure is invisible in the firewall logs and looks like an engine bug until you map every listener the server opens to an explicit NSG rule. This page walks through provisioning the security group for a production cluster on AWS, Azure, or GCP, aligning it with ClickHouse’s native listener bindings, and verifying that every path — client, interserver, and coordination — is reachable. It builds on the perimeter model described in Security & Access Control Boundaries and the distributed execution mechanics covered in ClickHouse Core Architecture & Analytics Fundamentals.
Prerequisites
Port Topology and Listener Map
An NSG is a stateful packet filter: return traffic on an established connection is admitted automatically, so you allowlist by originating direction. ClickHouse opens several distinct listeners, and each maps to a service boundary that a specific caller depends on. The rules below are the minimum inbound set for a replicated cluster inside a VPC or VNet.
- 8123/tcp — HTTP/HTTPS interface for the REST API, JDBC/ODBC bridges, BI tools, and the embedded web UI.
- 9000/tcp — native TCP binary protocol for
clickhouse-client, Python ingestion drivers, and high-throughput batch loads. - 9009/tcp — interserver HTTP port for
Distributedquery execution, shard-to-shard exchange, and part fetches. - 9010/tcp — interserver HTTPS / replication coordination for
ReplicatedMergeTreebackground merges and part transfer when TLS is enabled. - 9181/tcp — ClickHouse Keeper client port for distributed DDL, leader election, and partition metadata sync (2181/tcp if you run ZooKeeper).
Scope every inbound rule to a source CIDR: client ports (8123, 9000) open only to application and ETL subnets; interserver and coordination ports (9009, 9010, 9181) open only to the deployment’s own security group referenced as the source. That self-referencing source is what keeps replication traffic private while still allowing every node to reach every peer.
Step-by-Step: Provisioning the Security Group
1. Create the group and capture its ID
aws ec2 create-security-group \
--group-name clickhouse-analytics-prod \
--description "ClickHouse analytics cluster boundary" \
--vpc-id vpc-0a1b2c3d4e5f
Expected output — record the returned identifier for the self-referencing interserver rules:
{ "GroupId": "sg-0abc123def4567890" }
2. Admit client traffic from application and ETL subnets only
aws ec2 authorize-security-group-ingress --group-id sg-0abc123def4567890 \
--ip-permissions \
'IpProtocol=tcp,FromPort=8123,ToPort=8123,IpRanges=[{CidrIp=10.20.0.0/20,Description="app subnet HTTP"}]' \
'IpProtocol=tcp,FromPort=9000,ToPort=9000,IpRanges=[{CidrIp=10.20.16.0/20,Description="etl runners native"}]'
A successful call returns "Return": true. Restricting the source CIDR here is the single most effective control on this page — it removes the internet-facing attack surface without touching a single ClickHouse setting.
3. Admit interserver and coordination traffic from the group to itself
aws ec2 authorize-security-group-ingress --group-id sg-0abc123def4567890 \
--ip-permissions \
'IpProtocol=tcp,FromPort=9009,ToPort=9010,UserIdGroupPairs=[{GroupId=sg-0abc123def4567890,Description="interserver replication"}]' \
'IpProtocol=tcp,FromPort=9181,ToPort=9181,UserIdGroupPairs=[{GroupId=sg-0abc123def4567890,Description="keeper coordination"}]'
Referencing sg-0abc123def4567890 as its own source means only members of this security group — the ClickHouse nodes — can reach ports 9009, 9010, and 9181. This is what isolates part fetching and Keeper coordination from every other workload in the VPC.
4. Align the ClickHouse listeners with the NSG
An NSG rule is only half the path. ClickHouse binds its own listeners at the application layer, and a mismatch produces Connection refused errors that never appear in firewall logs. Declare the bindings explicitly in /etc/clickhouse-server/config.xml:
<clickhouse>
<listen_host>0.0.0.0</listen_host>
<tcp_port>9000</tcp_port>
<http_port>8123</http_port>
<interserver_http_port>9009</interserver_http_port>
<interserver_http_host>clickhouse-node-01.internal</interserver_http_host>
<remote_servers>
<analytics_cluster>
<shard>
<replica>
<host>clickhouse-node-01.internal</host>
<port>9000</port>
</replica>
<replica>
<host>clickhouse-node-02.internal</host>
<port>9000</port>
</replica>
</shard>
</analytics_cluster>
</remote_servers>
</clickhouse>
The interserver_http_host value must resolve to a routable internal name that peers can reach over 9009/9010. If it defaults to localhost, replication queues stall with DNS resolution failed even when every NSG rule is correct. Apply the change without a restart:
clickhouse-client --query "SYSTEM RELOAD CONFIG"
An empty result (exit code 0) confirms the reload succeeded.
5. Probe every path from the caller that will use it
Run these from the machine that actually originates the traffic — the client checks from an ETL runner, the interserver checks from a peer node:
# native + HTTP reachability from an ETL runner
nc -zv clickhouse-node-01.internal 9000
nc -zv clickhouse-node-01.internal 8123
# interserver reachability from a peer node
curl -s -o /dev/null -w "%{http_code}\n" http://clickhouse-node-02.internal:9009/ping
Expected output:
Connection to clickhouse-node-01.internal 9000 port [tcp/*] succeeded!
Connection to clickhouse-node-01.internal 8123 port [tcp/*] succeeded!
200
For a Python ingestion path, confirm the driver completes a full handshake rather than only a TCP open — a permissive NSG can still hide a TLS or auth mismatch:
import clickhouse_connect
client = clickhouse_connect.get_client(
host="clickhouse-node-01.internal",
port=8123,
username="etl_ingest",
password="•••",
connect_timeout=5,
)
print(client.query("SELECT 1").result_rows) # -> [(1,)]
Verification
Once the paths probe clean, confirm ClickHouse’s own view of connectivity from inside the server. These queries turn “the firewall looks right” into “replication and coordination are actually healthy.”
-- Every configured host should show errors_count = 0 and no recovery backoff.
SELECT cluster, host_name, host_address, port, errors_count, estimated_recovery_time
FROM system.clusters
WHERE cluster = 'analytics_cluster'
ORDER BY host_name;
-- A blocked interserver port surfaces here as is_readonly = 1 and a growing queue.
SELECT database, table, replica_name, is_readonly, queue_size, absolute_delay
FROM system.replicas
WHERE queue_size > 0 OR is_readonly = 1;
-- Network error codes accumulate here when a rule is too tight.
SELECT name, code, value
FROM system.errors
WHERE name IN ('NETWORK_ERROR', 'SOCKET_TIMEOUT', 'NO_REMOTE_SHARD_AVAILABLE')
AND value > 0;
A healthy cluster returns rows only from the first query, all with errors_count = 0; the second and third return empty sets. Persistent is_readonly = 1 with a non-zero queue_size is the signature of a blocked 9009/9010 rule between zones, not a disk or merge problem.
Gotchas & Edge Cases
-
Stateful NSGs still need egress for health probes. Return traffic on an established connection is free, but cloud load-balancer health checks and cross-account
PrivateLinkcalls originate outbound from the node. If egress is locked down,system.clustersshows a healthy config while the load balancer marks the target unhealthy. Add explicit outbound rules mirroring the interserver ports. -
SYSTEM RELOAD CONFIGdoes not rebind listener ports. Changingtcp_port,http_port, orinterserver_http_portinconfig.xmlrequires a fullclickhouse-serverrestart — the reload only refreshes hot-reloadable sections likeremote_serversand users. Editing a port and reloading leaves the server listening on the old port while your NSG allows the new one, producingConnection refused. -
Keeper coordination failure masquerades as a DDL hang. If 9181 is blocked but the data ports are open, ingestion keeps working while
ON CLUSTERDDL statements hang indefinitely waiting on the distributed DDL queue. Checksystem.distributed_ddl_queuefor stuck entries before assuming the NSG is fine because queries still run. -
Asymmetric routing bypasses the symmetric-looking rule. In multi-AZ transit-gateway topologies, the reply can take a different route than the request. The NSG allows the SYN but the return path is filtered elsewhere, so
ncsucceeds intermittently. Confirm route tables, not just security-group rules, when reachability flaps. This is the same class of failure that undermines DNS-based fallback routing for analytics when TTLs and route convergence drift apart.
Related
- Security & Access Control Boundaries — the privilege, quota, and perimeter model this network layer sits inside.
- Implementing DNS-Based Fallback Routing for Analytics — how routing and failover interact with the ports you just opened.
- Fallback Routing & High Availability — designing the deployment so a partitioned node degrades gracefully.
- ClickHouse Core Architecture & Analytics Fundamentals — the distributed execution model these boundaries protect.
Up one level: Security & Access Control Boundaries.