The database is yours, which means the operational decisions are too. Here is what to decide.
What Infralytiqs needs from your server
Less than you might expect. A reachable HTTP endpoint, and credentials that can create a database and a table. That is the whole requirement — everything else on this page is about running it well rather than getting it working.
On first save, Infralytiqs runs CREATE DATABASE IF NOT EXISTS followed by CREATE TABLE IF NOT EXISTS <db>.website_events. If you ticked the cluster option and named a cluster, the table is created ON CLUSTER with a ReplicatedMergeTree engine instead of a plain MergeTree. Nothing else is ever created, and nothing outside that database is touched.
Pick a deployment
All three are supported identically. The difference is who carries the operational burden.
💻
A container, for evaluation
One command, about a minute, costs nothing. Ideal for wiring up an integration and deciding whether you like the product. Not suitable for production — no replication, no backups, and its durability is whatever your Docker volume gives you.
☁
ClickHouse Cloud
Managed, with a free tier to start. Backups, upgrades and replication are handled for you, and you get an HTTPS endpoint with credentials to paste into the connection form. The right default unless you already run databases.
🖧
Self-hosted
Your own nodes, your own rules. Cheapest at volume and the only option when data residency is contractual, but you own replication, backups, upgrades and monitoring. Use a replicated cluster; a single node will eventually lose data.
Standing one up
The container is for evaluation. The Compose file and the SQL after it are what you would actually run.
# Evaluation only. Not exposed, not replicated, not backed up.
docker run -d \
--name infralytiqs-clickhouse \
-p 8123:8123 -p 9000:9000 \
-e CLICKHOUSE_USER=infralytiqs \
-e CLICKHOUSE_PASSWORD='change-me-locally' \
-e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 \
--ulimit nofile=262144:262144 \
-v infralytiqs-ch-data:/var/lib/clickhouse \
clickhouse/clickhouse-server:latest
curl -sS http://localhost:8123/ping # -> Ok.
# The collector runs elsewhere, so its localhost is not yours. Expose this
# through a tunnel for evaluation rather than opening 8123 to the internet:
# cloudflared tunnel --url http://localhost:8123
# ngrok http 8123
# Then use the tunnel URL in the connection form.
# docker-compose.yml — a defensible single-node setup behind a TLS proxy.
# For production proper, use a replicated cluster or ClickHouse Cloud.
services:
clickhouse:
image: clickhouse/clickhouse-server:24.8
restart: unless-stopped
# No published ports. Only the proxy reaches it, over the internal
# network — this is the single most valuable line in the file.
expose:
- "8123"
environment:
CLICKHOUSE_DB: analytics
CLICKHOUSE_USER: admin
CLICKHOUSE_PASSWORD_FILE: /run/secrets/ch_admin_password
secrets:
- ch_admin_password
volumes:
- ch-data:/var/lib/clickhouse
- ch-logs:/var/log/clickhouse-server
- ./config/users.d:/etc/clickhouse-server/users.d:ro
- ./config/config.d:/etc/clickhouse-server/config.d:ro
ulimits:
nofile: { soft: 262144, hard: 262144 }
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"]
interval: 30s
timeout: 5s
retries: 3
deploy:
resources:
limits: { memory: 8G }
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
depends_on:
clickhouse: { condition: service_healthy }
volumes:
ch-data:
ch-logs:
caddy-data:
secrets:
ch_admin_password:
file: ./secrets/ch_admin_password.txt
# Caddyfile — automatic certificates, and only the endpoints the platform
# actually needs. Everything else is refused.
#
# clickhouse.acme.com {
# @allowed path /ping /query /
# handle @allowed {
# reverse_proxy clickhouse:8123
# }
# respond 404
# }
-- Do not give Infralytiqs your admin account. Create a dedicated user
-- scoped to one database, so the worst case of a leaked credential is
-- bounded to the analytics data it already writes.
CREATE DATABASE IF NOT EXISTS analytics;
CREATE USER infralytiqs
IDENTIFIED WITH sha256_password BY 'use-a-generated-40-char-secret'
-- Restrict by source if your collector has a stable egress address.
-- HOST IP '203.0.113.0/24'
DEFAULT DATABASE analytics;
-- Exactly what the platform needs, and nothing else. It must be able to
-- create its own table on first save.
GRANT SHOW TABLES, SELECT, INSERT, CREATE TABLE, ALTER
ON analytics.* TO infralytiqs;
-- Needed for the storage and health queries the console runs.
GRANT SELECT ON system.parts TO infralytiqs;
GRANT SELECT ON system.columns TO infralytiqs;
GRANT SELECT ON system.tables TO infralytiqs;
-- Deliberately NOT granted: DROP DATABASE, CREATE USER, access to any
-- other database, or the file/url/remote table functions.
-- Cap what one runaway query can consume, so a bad report cannot take the
-- server down.
CREATE SETTINGS PROFILE infralytiqs_profile SETTINGS
max_memory_usage = 8000000000,
max_execution_time = 60,
max_rows_to_read = 5000000000,
readonly = 0,
max_concurrent_queries_for_user = 20;
ALTER USER infralytiqs SETTINGS PROFILE infralytiqs_profile;
-- Verify what you actually granted rather than what you meant to.
SHOW GRANTS FOR infralytiqs;
Settings -> add module -> ClickHouse Server
Name of the Server Production analytics
URL https://clickhouse.acme.com
(the TLS proxy, never :8123 directly)
Tenant Id acme
Site ID acme-www
(leave blank for a tenant-wide configuration; that
becomes the reserved __all_sites__ segment)
DB Name analytics
must match ^[a-zA-Z][a-zA-Z0-9_]{0,62}$
created for you if it does not exist
This URL points to a ClickHouse cluster [x] if replicated
Cluster Name your cluster name
-> switches the engine to ReplicatedMergeTree
and creates the table ON CLUSTER
Capture Precise Visitor Location [ ] leave off unless you need it
and have a lawful basis
Default Time Range Last 7 days
Authentication Method Basic Authentication
Username infralytiqs
Password the generated secret
Add Self-signed certificate paste your CA if it is private
TLS client Authentication if you use mutual TLS
Skip TLS Certificate validation [ ] do not. Add the CA instead
Request Timeout (seconds) 30
Allowed Cookie Names only if you enrich from cookies
Then click Test connection. You cannot save until it passes, and
changing the URL, auth or TLS fields invalidates a previous pass.
Sizing
Reference points rather than promises — measure your own compressed row size and scale from there.
Events per month
Suggested shape
Rough monthly storage
Notes
Under 10 million
A single node, 2 vCPU, 8 GB RAM
Low single-digit GB
A container on a small VM genuinely suffices
10–100 million
A single node, 4 vCPU, 16 GB RAM, SSD
Tens of GB
Add backups. Still comfortably one machine
100 million – 1 billion
2 replicas, 8 vCPU, 32 GB RAM each
Hundreds of GB
Replicate now — restoring from backup at this size is painful
1 billion+
Sharded and replicated, or ClickHouse Cloud
TB scale
Consider materialised views for your hottest aggregates
Operating it
The queries and commands you will reach for repeatedly.
-- The table arrives with a 24-month TTL. Because ttl_only_drop_parts
-- is set, expiry drops whole parts rather than rewriting data, so it is
-- close to free.
SELECT name, engine, partition_key, sorting_key, ttl_expression
FROM system.tables
WHERE name = 'website_events';
-- Keep less:
ALTER TABLE website_events MODIFY TTL event_date + INTERVAL 6 MONTH DELETE;
-- Keep more:
ALTER TABLE website_events MODIFY TTL event_date + INTERVAL 60 MONTH DELETE;
-- Keep everything:
ALTER TABLE website_events REMOVE TTL;
-- Better than a blunt TTL: move old partitions to cheap storage and keep
-- them queryable, if you have a tiered storage policy configured.
ALTER TABLE website_events MODIFY TTL
event_date + INTERVAL 3 MONTH TO VOLUME 'cold',
event_date + INTERVAL 24 MONTH DELETE;
-- Drop one month by hand. Instant, because it unlinks a whole partition
-- instead of deleting rows.
ALTER TABLE website_events DROP PARTITION '202401';
-- Is data still arriving? The single most useful alert you can build:
-- it catches a broken integration, an expired credential and a firewall
-- change all at once.
SELECT
max(event_time) AS newest_event,
dateDiff('minute', max(event_time), now()) AS minutes_stale,
count() AS events_last_hour
FROM website_events
WHERE event_date >= today() - 1
AND event_time >= now() - INTERVAL 1 HOUR;
-- Are merges keeping up? A part count climbing into the thousands for one
-- partition means inserts are too small and too frequent.
SELECT
partition,
count() AS parts,
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS size
FROM system.parts
WHERE table = 'website_events' AND active
GROUP BY partition
ORDER BY partition DESC
LIMIT 12;
-- Anything stuck?
SELECT database, table, elapsed, progress, num_parts, result_part_name
FROM system.merges;
-- For replicated setups: is any replica falling behind?
SELECT
table, replica_name, is_readonly, is_session_expired,
absolute_delay, queue_size, inserts_in_queue, merges_in_queue
FROM system.replicas
WHERE table = 'website_events';
-- What is running right now, and what is it costing?
SELECT
query_id, elapsed, formatReadableSize(memory_usage) AS memory,
read_rows, substring(query, 1, 120) AS query
FROM system.processes
WHERE query NOT LIKE '%system.processes%'
ORDER BY elapsed DESC;
# ClickHouse has native BACKUP/RESTORE. A container volume is not a backup.
# One-off backup to a local disk configured in storage.xml:
clickhouse-client --query "
BACKUP TABLE analytics.website_events
TO Disk('backups', 'website_events_$(date +%Y%m%d).zip')
"
# Incremental, based on the previous backup — far cheaper day to day:
clickhouse-client --query "
BACKUP TABLE analytics.website_events
TO Disk('backups', 'website_events_$(date +%Y%m%d).zip')
SETTINGS base_backup = Disk('backups', 'website_events_full.zip')
"
# Straight to S3, which is usually what you want:
clickhouse-client --query "
BACKUP TABLE analytics.website_events
TO S3('https://acme-backups.s3.eu-west-1.amazonaws.com/clickhouse/website_events',
'\${AWS_ACCESS_KEY_ID}', '\${AWS_SECRET_ACCESS_KEY}')
"
# Restore into a different table name first. Verify, then swap — never
# restore over live data on the basis that the backup is probably fine.
clickhouse-client --query "
RESTORE TABLE analytics.website_events AS analytics.website_events_restored
FROM Disk('backups', 'website_events_20260701.zip')
"
# Confirm the restore before trusting it:
# SELECT count(), min(event_time), max(event_time)
# FROM analytics.website_events_restored;
# Cheaper alternative for point-in-time copies: FREEZE creates hardlinks
# in the shadow directory, so it is near-instant and costs no extra space
# until parts change.
clickhouse-client --query "
ALTER TABLE analytics.website_events FREEZE WITH NAME 'nightly'
"
# Then rsync /var/lib/clickhouse/shadow/nightly/ off the host.
-- The sort key is (user_id, event_time, event_type, event_id), which makes
-- per-user timelines very fast. Reports that filter on event_type or
-- page_path without a user_id have to scan more, so help them along.
-- 1. A skip index on event_type. Cheap to maintain, and lets ClickHouse
-- skip whole granules for a filtered report.
ALTER TABLE website_events
ADD INDEX idx_event_type event_type TYPE set(100) GRANULARITY 4;
ALTER TABLE website_events MATERIALIZE INDEX idx_event_type;
-- 2. A bloom filter for page_path lookups.
ALTER TABLE website_events
ADD INDEX idx_page_path page_path TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE website_events MATERIALIZE INDEX idx_page_path;
-- 3. For a dashboard panel that is opened constantly, pre-aggregate it.
-- This is the single biggest win at high volume.
CREATE TABLE website_events_daily
(
event_date Date,
site_id String,
event_type LowCardinality(String),
country_code LowCardinality(String),
device_type LowCardinality(String),
events AggregateFunction(count),
visitors AggregateFunction(uniq, String),
sessions AggregateFunction(uniq, String)
)
ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, site_id, event_type, country_code, device_type);
CREATE MATERIALIZED VIEW website_events_daily_mv TO website_events_daily AS
SELECT
event_date,
site_id,
event_type,
country_code,
device_type,
countState() AS events,
uniqState(anonymous_id) AS visitors,
uniqState(session_id) AS sessions
FROM website_events
GROUP BY event_date, site_id, event_type, country_code, device_type;
-- Read it with the -Merge combinators:
SELECT
event_date,
countMerge(events) AS events,
uniqMerge(visitors) AS visitors
FROM website_events_daily
WHERE event_date >= today() - 90
GROUP BY event_date
ORDER BY event_date;
-- Note the materialised view only sees rows inserted after you create it.
-- Backfill it once from the raw table if you need the history.
Operational questions
No. It creates and writes one database containing one table, website_events, and reads a few system tables for the storage and health panels. The grant list above is exactly sufficient, which is why giving it your admin account is unnecessary.
You can — it is your table — but prefer custom_dimensions and custom_metrics for anything the collector will populate, since the report builder discovers map keys automatically and an unknown column is not offered anywhere in the UI. Added columns are safe: the platform uses CREATE TABLE IF NOT EXISTS and never drops or recreates the table.
Anything reasonably current. The schema uses Map columns, LowCardinality, ZSTD and Delta codecs, and MATERIALIZED and ALIAS columns — all long-established features. Version 23.8 or later is a safe floor, and staying on a recent LTS is good practice regardless.
No. Put it behind TLS with a reverse proxy, restrict to the paths the platform uses, and if your collector has a stable egress address, add a HOST clause to the user as well. ClickHouse's HTTP interface is powerful, and it is not intended to be an internet-facing surface.
Register the new server as another ClickHouse module — you can copy report suites across when you create it — then re-point your sites. Move historical rows with INSERT INTO new SELECT * FROM remoteSecure(...), one month at a time so you can resume if it is interrupted.
Check system.query_log for what it read. Usually the fix is a skip index on the filtered column, or a materialised view for a panel that is opened constantly. Both are shown above, and both are changes you make yourself without involving us.
ALTER TABLE website_events DELETE WHERE user_id = '...'. It is a mutation and therefore not instant, but it is a genuine deletion rather than a flag. Because the database is yours, you can prove it completed by querying afterwards, which is a materially better position than trusting a vendor's deletion endpoint.
You own the whole stack now
Events flowing in from every surface, into a database you control, queried by a console you did not have to build.