← all work
Case study

Atlas

A multi-tenant analytics platform that replaced legacy Python ETLs and oversized row-store OLAP instances with ClickHouse + dbt, fed by a zero-downtime Postgres CDC pipeline — and migrated 100+ live client dashboards without a hard cutover.

20×
faster nightly ELT
85%
DB cost cut
100+
tenants
1–2s
query latency

The problem

Analytics for 100+ tenants ran on a stack of legacy Python ETL scripts writing into large row-store Postgres (RDS) instances that doubled as the OLAP layer. It worked, but every part of it was straining: the nightly batch took around five hours, dashboard queries routinely took 15+ seconds, and the only lever for performance was a bigger instance — the fleet had already reached 8xlarge class.

The goal: move analytics onto a columnar engine, turn the ETL into declarative, testable models, keep data fresh without a nightly-only window, and do all of it without breaking the 100+ BI dashboards clients already relied on.

Architecture

Atlas · Postgres → CDC → ClickHouse → dbt → BI
Postgres 4 microservice DBs PeerDB CDC ECS Fargate mirrors ClickHouse · raw ECS on EC2 · AWS CDK dbt ELT tenant-aware models + tests ClickHouse · marts 100+ tenants BI dashboards 100+ migrated MCP servers BI + ClickHouse tools SQL translator legacy SQL → ClickHouse SQL CloudWatch slot-lag metric → step scaling
Operational Postgres stays the source of truth. CDC mirrors it into ClickHouse raw tables; dbt builds tenant-aware marts on top; BI dashboards and MCP servers read the marts.

The dashed mint arrow is the scaling loop: replication-slot lag is published as a custom CloudWatch metric and drives step-scaling on the CDC workers — not CPU.

ELT on ClickHouse + dbt

The old pipeline was imperative Python: extract, transform in memory, load row by row. I flipped it to ELT — land raw data in ClickHouse, then express every transformation as a dbt model that runs inside the engine. Transformations became versioned SQL with tests and lineage, and the heavy lifting moved to a columnar engine built for exactly this kind of scan-and-aggregate work.

Nightly batch runtime
Legacy Python ETL
5 h / night
ClickHouse + dbt
15 min / night
Same data, same outputs — 5 hours down to 15 minutes, a 20× speedup.
Analytical query latency
Before (row-store OLAP)
15+ s
After (ClickHouse)
1–2 s
Dashboard queries went from 15+ seconds to 1–2 seconds once they hit columnar marts instead of row-store tables.

Retiring the OLAP fleet

With analytics moved off Postgres, the db.m7g.8xlarge RDS instances that had been serving as the OLAP layer could be decommissioned. ClickHouse runs on AWS ECS, provisioned with AWS CDK and backed by EC2 capacity providers — so the cluster is code, reproducible, and sized for the workload rather than for the worst query.

Storage footprint
Row-store footprint
~1 TB
Columnar footprint
~200 GB
Columnar compression shrank the same dataset to about a fifth of its row-store size.

Zero-downtime CDC

Nightly batches alone meant analytics were always a day stale. I built a Postgres → ClickHouse change-data-capture pipeline with PeerDB running on ECS Fargate, mirroring 4 microservices' databases across multi-tenant mirrors — with no downtime on the source systems.

Scale on the signal that matters

CPU is the wrong autoscaling signal for CDC. A worker can sit at low CPU while its replication slot quietly falls behind — and on Postgres, a lagging slot holds WAL on the primary, so the risk lands on the production database, not the analytics side. I replaced CPU autoscaling with step-scaling driven by a custom CloudWatch metric for replication-slot lag, keeping lag under 1 GB across all mirrors.

Dashboard cutover

100+ client BI dashboards were written against the old Postgres schema and dialect. Rewriting them by hand would have been slow and error-prone, and a hard cutover would have put every client at risk at once.

MCP servers

I built custom Model Context Protocol (MCP) servers for the BI layer and for ClickHouse, so AI agents can inspect dashboards, run live queries, and translate SQL directly. They turned the migration's long tail — odd queries, dialect edge cases, “why is this number different” debugging — into agentic workflows instead of manual digging.

Results

What I'd add next

Stack