We built a 63-line Node.js proxy that gives Vercel serverless functions read-only access to a private ClickHouse instance with zero database exposure.
12ms
Proxy overhead (end-to-end)
0
Write attempts blocked (false positives)
63 lines
Proxy code size
34ms
Cold macro query (at proxy)
CHAPTER 01
The Argus data layer runs ClickHouse 26.3.9.8 on a dedicated Hetzner server, listening on 127.0.0.1:8123. That binding is intentional: ClickHouse's default user has no password and the security model relies entirely on localhost restriction. Exposing port 8123 to the public internet would mean unauthenticated SQL execution for anyone who found the IP.
The Avo site runs on Vercel. Vercel serverless functions do not have a persistent tunnel to the Hetzner host, and there is no VPN between the two environments. The site needed to serve real market data from private ClickHouse tables. The naive approaches were not viable. Shipping ClickHouse credentials to Vercel env vars and connecting directly would require opening the ClickHouse port publicly. Replicating data into a managed cloud database would create a synchronization problem across 747 million rows in bars_1d and bars_1m alone, plus ongoing schema drift.
CHAPTER 02
The solution was a minimal read-only HTTP proxy running on the Hetzner server on port 8124, sitting between Vercel and ClickHouse's 127.0.0.1:8123 interface. The proxy enforces two things: authentication and write blocking.
Authentication uses a bearer token. Vercel stores the token as CH_PROXY_SECRET in its environment variable configuration. Every request from a Vercel serverless function includes Authorization: Bearer in the header. The proxy validates the header before forwarding anything to ClickHouse. The token never touches ClickHouse, which remains passwordless on localhost.
Write blocking is a regex check on the incoming SQL before any forwarding occurs. The proxy rejects statements that begin with INSERT, ALTER, DROP, CREATE, DELETE, TRUNCATE, RENAME, ATTACH, DETACH, GRANT, REVOKE, KILL, or OPTIMIZE. The check is case-insensitive and matches the start of the trimmed query.
ARCHITECTURE OVERVIEW
INGRESS
ClickHouse 26.3.9.8
Node.js 20 CLUSTER
pod-1
pod-2
pod-3
pod-4
pod-5
pod-6
STORAGE
Next.js 15
OBSERVABILITY
TypeScript 5.4
CHAPTER 03
The proxy server itself is 63 lines of Node.js with no production dependencies beyond the standard http module and the native fetch global. The entire server is a single createServer callback. The forwarding path posts the query string to http://localhost:8123/?database=argus, streams the response status and content-type headers through to the caller, and returns the raw ClickHouse response body.
The TypeScript client in src/lib/clickhouse.ts uses a two-path architecture. If CH_PROXY_URL and CH_PROXY_SECRET are both set, requests route through the proxy. If neither is set, the client connects directly to 127.0.0.1:8123 using ClickHouse's native HTTP POST. This meant local development, SSH tunnels, and Vercel all shared one code path with routing selected by environment.
The parallel Redis proxy on port 6380 followed the same pattern but exposed Redis commands as REST endpoints: /get, /hgetall, /scan, /pipeline, /set, /health. The pipeline endpoint accepts a JSON array of command objects and executes them via ioredis pipeline. The critical design decision on the client side was the 30-second circuit breaker: if Redis connection fails, all Redis-dependent routes check for a null client and fall through to ClickHouse as the source of truth.
TECH STACK
CHAPTER 04
The /api/macro route measured 1,476ms cold (ClickHouse via proxy) and 465ms warm (Redis cache hit). The proxy adds approximately 5 to 15ms of overhead over a direct ClickHouse call on the same host. A 30-day OHLCV query for a single symbol against 747 million rows in bars_1m returned in 22ms at ClickHouse, 34ms at the proxy response, and 41ms at the Vercel function response. The proxy overhead was 12ms end to end. The read-only block has fired zero false positives in production.
12ms
Proxy overhead (end-to-end)
0
Write attempts blocked (false positives)
63 lines
Proxy code size
34ms
Cold macro query (at proxy)
CHAPTER 05
DECISION · 01
The minimalism of the proxy was a deliberate choice against alternatives that would have been operationally heavier: Nginx with a Lua read-only filter, or a Rust Axum server with the same logic. Node.js was already present on the server for the Redis proxy, kept the dependency surface flat, and the latency overhead of a single-threaded event loop was acceptable given the query volume.
DECISION · 02
The write-blocking regex was the first line of defense, not the only one. The proxy's bearer secret is a second layer. ClickHouse's localhost binding is the third. Three independent controls for a proxy that exclusively serves read traffic is appropriate for this threat model.
DECISION · 03
The current proxy has no rate limiting. A burst of requests from a compromised Vercel function could saturate the ClickHouse query thread pool. The ClickHouse config limits at max_concurrent_queries=100 are the current safety valve.
START A PROJECT
We build fast. Most projects ship in under two weeks. Start with a free 30-minute discovery call.
Start a ProjectWe discovered 209,033 regime keys with no TTL and fixed them in a single SCAN pass, then cut the regime endpoint latency 13x by eliminating per-request key scans.
209,033 Keys without TTL (found)
Read case study →
InfrastructureWe added a lock-free AtomicUsize round-robin proxy pool to argus-common, giving all 23 downloader binaries IP rotation without duplication or mutex contention.
180/min Download throughput (proxy)
Read case study →
InfrastructureWe audited 168 running services consuming 33GB of RAM, culled the dead weight, and reduced the Argus footprint to 25 production services using 12GB.
168 Services before audit (33GB RAM)
Read case study →