I recently needed an analytics database for a side project. ClickHouse was the obvious pick, it’s stupidly fast for the append heavy, aggregate later workload I had. What I didn’t want was a managed cloud bill, and I already had an EC2 instance running a few other services. So the plan became: run ClickHouse on that same box, keep it completely off the public internet, let a separate web app read from it, and never lose the data.
This is the full walkthrough install, tuning it to co exist with other services on a small box, locking down users, exposing it to a remote app through a Cloudflare Tunnel (zero open ports), and a nightly S3 backup. I’ve folded in every wall I hit along the way as callouts, because those were the parts that actually cost me time.
💡 The shape of it. ClickHouse listens on
localhostonly. Acloudflareddaemon on the same box makes an outbound connection to Cloudflare; a remote app reaches the DB through that tunnel, gated by a Cloudflare Access service token. Backups go to S3 via an EC2 IAM role. No inbound ports for the database, ever.
My box for reference: 2 vCPU / 8 GB RAM, Ubuntu, shared with a few other services.
1. Install ClickHouse
Straight from the official deb repo:
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL 'https://packages.clickhouse.com/rpm/lts/repodata/repomd.xml.key' | sudo gpg --dearmor -o /usr/share/keyrings/clickhouse-keyring.gpg
ARCH=$(dpkg --print-architecture)
echo "deb [signed-by=/usr/share/keyrings/clickhouse-keyring.gpg arch=${ARCH}] https://packages.clickhouse.com/deb stable main" | sudo tee /etc/apt/sources.list.d/clickhouse.list
sudo apt-get update
sudo apt-get install -y clickhouse-server clickhouse-client
The installer prompts you to set a password for the default (admin) user. Set one and save it — it lands in /etc/clickhouse-server/users.d/default-password.xml.
⚠️ Gotcha one command per line. Don’t paste that GPG line and the
ARCH=line together as one line. If they get concatenated,gpgtries to openARCH=amd64as a file and fails with a cryptic “No such file or directory”. Same with thecurl ... | sudopipe if a paste wraps it across lines,sudoruns with no command. Run each as its own line.
By default ClickHouse binds only to 127.0.0.1/::1 leave it that way. Everything else in this guide relies on it being loopback only.
2. Add swap (cheap OOM insurance)
Small instances usually have no swap, and ClickHouse merges can spike memory. A swapfile keeps the OOM killer from murdering the process (or worse, a neighbour service):
sudo fallocate -l 4G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
3. Tune memory for a shared box
ClickHouse’s defaults assume it owns a big server: a 5 GiB mark cache, generous background pools. On a shared 8 GB box that will starve everything else. I capped it with an absolute memory limit (not a RAM ratio, a ratio would grab 80% of total RAM and choke my other services), plus trimmed caches and noisy system logs.
Best practice is to leave config.xml untouched and drop overrides into config.d/:
sudo tee /etc/clickhouse-server/config.d/z-low-memory.xml >/dev/null <<'XML'
<clickhouse>
<max_server_memory_usage>2684354560</max_server_memory_usage> <!-- 2.5 GiB hard cap -->
<mark_cache_size>268435456</mark_cache_size> <!-- 256 MiB, was 5 GiB -->
<max_concurrent_queries>10</max_concurrent_queries>
<mlock_executable>false</mlock_executable>
<metric_log remove="1"/>
<asynchronous_metric_log remove="1"/>
<trace_log remove="1"/>
<text_log remove="1"/>
</clickhouse>
XML
sudo systemctl restart clickhouse-server
With the hard cap, ClickHouse throws a clean “memory limit exceeded” at 2.5 GiB instead of getting OOM killed and taking neighbours down with it.
⚠️ Gotcha don’t lower
background_pool_size. My first instinct was to also set<background_pool_size>2</background_pool_size>to save resources. The server then refused to boot:DB::Exception: The value of 'number_of_free_entries_in_pool_to_execute_mutation' setting (20) is greater than 'background_pool_size' * 'background_merges_mutations_concurrency_ratio' (4). ... mutations cannot work with these settings. (BAD_ARGUMENTS)ClickHouse has a startup sanity check:
background_pool_size × concurrency_ratio (2)must exceed the mutation free entry threshold (default 20). With a pool of 2, that’s2 × 2 = 4 < 20, so it hard fails. Those pool threads are mostly idle anyway and cost almost no memory the real lever ismax_server_memory_usage. I just removed the override and let it default. If you genuinely must shrink it, you also have to lower three<merge_tree>free entry settings, which isn’t worth it. Whenever a config change won’t boot, check/var/log/clickhouse-server/clickhouse-server.err.logthe exception names the exact setting.
Verify the cap is live:
clickhouse-client --password -q "SELECT value FROM system.server_settings WHERE name='max_server_memory_usage'"
# -> 2684354560
4. Create users the file based way (one read write, one read only)
I wanted two dedicated users instead of throwing default around: app_rw for the service that writes, and app_ro for the web app that only reads. ClickHouse supports declarative XML users in users.d/ passwords as SHA 256 hashes, per user settings profiles, quotas, and scoped grants.
First, hash each password:
echo -n 'your-write-password' | sha256sum | awk '{print $1}' # -> WRITE_HASH
echo -n 'your-readonly-password' | sha256sum | awk '{print $1}' # -> RO_HASH
Then define both users. readonly=1 blocks all writes/DDL at the query level; the <grants> block scopes each user to just the metrics database:
sudo tee /etc/clickhouse-server/users.d/z-app-users.xml >/dev/null <<'XML'
<clickhouse>
<profiles>
<rw_profile>
<max_memory_usage>1500000000</max_memory_usage>
<max_threads>2</max_threads>
<max_bytes_before_external_group_by>1000000000</max_bytes_before_external_group_by>
<max_bytes_before_external_sort>1000000000</max_bytes_before_external_sort>
</rw_profile>
<ro_profile>
<readonly>1</readonly>
<max_memory_usage>1500000000</max_memory_usage>
<max_threads>2</max_threads>
</ro_profile>
</profiles>
<users>
<!-- writer: the service running on this same box -->
<app_rw>
<password_sha256_hex>WRITE_HASH</password_sha256_hex>
<networks><ip>::1</ip><ip>127.0.0.1</ip></networks>
<profile>rw_profile</profile>
<quota>default</quota>
<grants>
<query>GRANT CREATE DATABASE ON metrics.*</query>
<query>GRANT ALL ON metrics.*</query>
</grants>
</app_rw>
<!-- reader: the remote web app, via the tunnel -->
<app_ro>
<password_sha256_hex>RO_HASH</password_sha256_hex>
<networks><ip>::1</ip><ip>127.0.0.1</ip></networks>
<profile>ro_profile</profile>
<quota>ro_quota</quota>
<grants>
<query>GRANT SELECT ON metrics.*</query>
</grants>
</app_ro>
</users>
<quotas>
<ro_quota>
<interval>
<duration>3600</duration>
<queries>2000</queries>
<result_rows>100000000</result_rows>
</interval>
</ro_quota>
</quotas>
</clickhouse>
XML
sudo systemctl restart clickhouse-server
💡 Why
networksis localhost for both users even the remote reader. The tunnel daemon (next section) opens a fresh connection tolocalhost:8123, so ClickHouse sees every request as coming from127.0.0.1, remote or not. Locking the users to loopback is both correct and the tightest setting.⚠️ Gotcha
GRANT ALL ON db.*does not includeCREATE DATABASE. When my writer first tried to create the database, it got:app_rw: Not enough privileges. To execute this query, it's necessary to have the grant CREATE DATABASE ON metrics.*Database level
ALLcovers everything inside the database (tables, inserts, selects, alters) but not the privilege to create the database itself that’s a separate grant. Hence the explicitGRANT CREATE DATABASE ON metrics.*line above. It’s scoped to the namemetrics, so the user can only ever create that one database.
Sanity check the read only user really is read only:
clickhouse-client -u app_ro --password -d metrics -q "CREATE TABLE t (x Int8) ENGINE=Memory"
# -> "Cannot execute query in readonly mode" ✅
5. Create the database and tables
Generic schema an events table with a monthly partition and a 12 month TTL, which is a sensible default for time series analytics:
clickhouse-client -u app_rw --password -q "CREATE DATABASE IF NOT EXISTS metrics"
clickhouse-client -u app_rw --password -d metrics --multiquery <<'SQL'
CREATE TABLE IF NOT EXISTS events
(
event_time DateTime64(3, 'UTC'),
event_type LowCardinality(String),
user_id String DEFAULT '',
payload String DEFAULT '{}',
duration_ms UInt32 CODEC(T64, LZ4)
)
ENGINE = MergeTree()
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_type, event_time)
TTL toDateTime(event_time) + INTERVAL 12 MONTH;
SQL
LowCardinality(String) for low distinct columns and the T64 codec on the duration give you strong compression for free.
6. Expose it to a remote app with a Cloudflare Tunnel
This is the part that keeps the database private. Instead of opening a port and firewalling it, cloudflared makes an outbound connection to Cloudflare; requests to a hostname get pushed back down that connection to the daemon, which forwards them to localhost:8123 (ClickHouse’s HTTP interface). Zero inbound ports.
I’d already created a named tunnel and installed the service. The routing is defined in a config file with ingress rules:
sudo mkdir -p /etc/cloudflared
sudo tee /etc/cloudflared/config.yml >/dev/null <<'YAML'
tunnel: <your-tunnel-uuid>
credentials-file: /home/ubuntu/.cloudflared/<your-tunnel-uuid>.json
ingress:
- hostname: db.example.com
service: http://localhost:8123
- service: http_status:404 # catch-all, must be last
YAML
cloudflared tunnel ingress validate # should print OK
Point the systemd service at that config. Edit the unit:
sudo systemctl edit --full cloudflared
Set ExecStart to:
ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run
Then reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart cloudflared
journalctl -u cloudflared -n 20 --no-pager # look for "Registered tunnel connection"
⚠️ Gotcha
--urlsilently ignores your ingress rules. My service was originally started ascloudflared tunnel run --url http://localhost:8000 <name>. That--urlflag is single service “quick” mode: it routes every hostname on the tunnel to that one URL and completely ignoresconfig.yml. So my database hostname was quietly being sent to port 8000 and returning502 Bad Gateway. The fix is exactly what’s above drop--urlentirely and use--config ... runso ingress rules take over.⚠️ Gotcha the
runsubcommand is mandatory.cloudflared tunnel --config <file>without a trailingrunjust prints help and exits, and systemd restart loops it. The flag goes beforerun:cloudflared tunnel --config <file> run.
Confirm the origin is healthy from the box itself:
curl -s localhost:8123 # -> "Ok."
7. Lock the tunnel with Cloudflare Access
Routing to 8123 alone would leave the database open to anyone who knows the URL. The gate is Cloudflare Access: create an Access application on db.example.com with a Service Auth policy and a service token. Every request must then carry CF-Access-Client-Id and CF-Access-Client-Secret headers Cloudflare checks them at its edge and returns 403 to anything without them, before the request ever reaches your box.
Your remote app connects like this (env vars):
DB_HOST=db.example.com
DB_PORT=443
DB_SECURE=true
CF_ACCESS_CLIENT_ID=<service-token-id>
CF_ACCESS_CLIENT_SECRET=<service-token-secret>
DB_USER=app_ro
DB_PASSWORD=<readonly-password>
End to end test from your laptop CF token and read only DB creds:
curl https://db.example.com/ \
-H "CF-Access-Client-Id: <id>" -H "CF-Access-Client-Secret: <secret>" \
-H "X-ClickHouse-User: app_ro" -H "X-ClickHouse-Key: <readonly-password>" \
--data-binary "SELECT count() FROM metrics.events"
Three outcomes confirm everything’s wired: missing CF headers → Cloudflare 403; valid headers + creds → a number; a write attempt with app_ro → ClickHouse “readonly mode” error.
💡 Use
X-ClickHouse-User/X-ClickHouse-Keyheaders, not-ubasic auth. Basic auth sets theAuthorizationheader; keeping ClickHouse creds in their own headers avoids any collision with Cloudflare’sCF-Access-*auth.⚠️ Gotcha the IP allowlist can’t gate the tunnel. Because
cloudflaredconnects to ClickHouse fromlocalhost, ClickHouse sees127.0.0.1for all tunnel traffic. Its<networks>allowlist therefore can’t distinguish a remote request from a local one Cloudflare Access is your access control, not the IP list. So: keep the Service Auth policy with no “allow everyone” bypass, give every ClickHouse user a strong password, and hand the remote app only the read only creds.
8. Automated nightly backups to S3
The last piece: I never want to lose this data. I used clickhouse-backup with the EC2 instance’s IAM role for S3 auth, so there are no AWS keys sitting on disk.
8a. Create an IAM role and attach it
In the AWS console: IAM → Policies → Create policy → JSON:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject","s3:GetObject","s3:DeleteObject","s3:ListBucket","s3:AbortMultipartUpload"],
"Resource": [
"arn:aws:s3:::my-clickhouse-backups",
"arn:aws:s3:::my-clickhouse-backups/*"
]
}]
}
Name it, then IAM → Roles → Create role → AWS service → EC2, attach that policy, name the role. Finally EC2 → your instance → Actions → Security → Modify IAM role and attach it. No reboot needed.
8b. Install and configure clickhouse backup
cd /tmp
curl -fL https://github.com/Altinity/clickhouse-backup/releases/latest/download/clickhouse-backup-linux-amd64.tar.gz -o chb.tar.gz
mkdir -p chb && tar -xzf chb.tar.gz -C chb
sudo mv "$(find chb -name clickhouse-backup -type f | head -1)" /usr/local/bin/
sudo chmod +x /usr/local/bin/clickhouse-backup
clickhouse-backup --version
Config — leaving access_key/secret_key out makes it fall back to the IAM role automatically:
printf 'general:\n remote_storage: s3\n backups_to_keep_local: 1\n backups_to_keep_remote: 14\nclickhouse:\n host: localhost\n port: 9000\n username: default\n password: "your-default-password"\ns3:\n bucket: my-clickhouse-backups\n region: us-east-1\n path: clickhouse/prod\n' | sudo tee /etc/clickhouse-backup/config.yml >/dev/null
sudo chmod 600 /etc/clickhouse-backup/config.yml
sudo clickhouse-backup print-config # confirms it parses
⚠️ Gotcha heredocs and YAML hate a flaky paste. My SSH client kept prepending a space to every pasted line. That broke the config two ways: heredocs never terminated (the closing delimiter got indented, so the shell waited forever at the
>prompt), and even when written, the YAML failed withdid not find expected keybecause root keys weren’t at column 0. The fix that always works: a single lineprintfwith\nbaked in (above) one logical line can’t be mis indented by a paste. Check withcat -A fileroot keys must be flush left with$right after and no^Itabs.
Test it manually before trusting the schedule:
sudo clickhouse-backup create_remote # freeze + upload in one step, auto-timestamped
sudo clickhouse-backup list remote # should list your backup in S3
⚠️ Gotcha
no EC2 IMDS role found. My first upload created the local backup fine, then failed on the S3 push:failed to refresh cached credentials, no EC2 IMDS role found, ... EC2 IMDS ... StatusCode: 404That 404 means no IAM role is attached to the instance the SDK has nowhere to get credentials. I’d forgotten step 8a. Confirm the role is live:
TOKEN=$(curl -sX PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60") curl -s -H "X-aws-ec2-metadata-token:$TOKEN" http://169.254.169.254/latest/meta-data/iam/security-credentials/ # -> your-role-name (empty = no role attached)Attach the role, wait ~30s, re run. If you genuinely can’t use a role, you can put
access_key/secret_keyin thes3:block instead but the role is cleaner and rotation free.
8c. The cron a fixed local time
I wanted 07:30 in my own timezone regardless of the box’s UTC clock. Cron supports CRON_TZ, which spares you any offset math:
CRON_TZ=Asia/Kolkata
30 7 * * * root /usr/local/bin/clickhouse-backup create_remote >> /var/log/clickhouse-backup.log 2>&1
Put that in /etc/cron.d/clickhouse-backup. create_remote with no name auto timestamps each backup, and backups_to_keep_remote: 14 prunes to the last 14 automatically no cleanup job needed.
⚠️ Gotcha no leading whitespace, and no dot in the filename. After my flaky paste,
cat -Ashowed a leading space on both lines. A space before30 7 ...cron tolerates, but a space beforeCRON_TZmeans cron may ignore the timezone line and run at 07:30 UTC instead. Strip it:sudo sed -i 's/^[[:space:]]*//' /etc/cron.d/clickhouse-backup. Also: files in/etc/cron.d/must not have a dot in the name cron silently ignoresclickhouse-backup.conf. Name itclickhouse-backup.
Verify:
cat -A /etc/cron.d/clickhouse-backup # two flush left lines
systemctl status cron --no-pager | head -3
Where it stands
That’s the whole thing: ClickHouse running on a shared box, bound to localhost, tuned so it plays nice with its neighbours; two scoped users; a Cloudflare Tunnel + Access combo that lets a remote app read the data with zero open ports; and a nightly IAM authenticated backup to S3 with 14 day retention.
A few things I deliberately left for later (YAGNI until proven otherwise):
-
Incremental backups (
-diff-from-remote) only worth it once full backup time actually hurts. - A “backup didn’t run” alert a second cron that pings me if no success line hits the log in ~26h. Worth adding the day the data becomes recovery critical.
-
TLS between
cloudflaredand ClickHouse unnecessary here since that hop is over loopback.
The recurring lesson, if there is one: read the error log. Every single failure I hit the boot refusal, the 502, the IMDS 404, the YAML parse error told me exactly what was wrong in its first line. The fixes were small; finding them was just a matter of looking.
Top comments (0)