Real-time change data capture from PostgreSQL to ClickHouse, streamed through Kafka using Debezium for CDC and the Confluent JDBC Sink connector for delivery. Messages are Avro-encoded via Schema Registry to keep them small. AKHQ provides a UI for monitoring topics and connectors.
| Component | Role | Image |
|---|---|---|
| PostgreSQL | Source database (logical replication enabled) | postgres:16 |
| Kafka | Event streaming backbone (KRaft mode) | confluentinc/cp-kafka:7.6.1 |
| Schema Registry | Stores Avro schemas for topic keys/values | confluentinc/cp-schema-registry:7.6.1 |
| Kafka Connect | Runs the Debezium source + JDBC sink connectors | custom build, see connect/Dockerfile |
| ClickHouse | Destination OLAP database | clickhouse/clickhouse-server:24.3 |
| AKHQ | Web UI for Kafka topics, consumer groups, and connectors | tchiotludo/akhq:0.24.0 |
Two demo tables are replicated end-to-end: public.customers and public.orders.
- Docker and Docker Compose v2
- Ports
5432,8080,8081,8083,8123,9000,9092free on the host
All ports, credentials, and database names live in .env (loaded automatically by Docker Compose):
cp .env.example .envThe connect container also forwards the Postgres/ClickHouse values from .env into its own
environment, and the Debezium/JDBC sink connector configs in connectors/ pull
them from there via Kafka Connect's built-in EnvVarConfigProvider (${env:POSTGRES_PASSWORD},
${env:CLICKHOUSE_PASSWORD}, etc.) — no secrets are hardcoded in the connector JSON.
If you change CLICKHOUSE_DB or POSTGRES_DB from their defaults, also update the database name
used in clickhouse/init/01-init.sql (and, for Postgres, nothing else
needs to change — the official image creates $POSTGRES_DB automatically).
docker compose up -d --buildThis will:
- Start PostgreSQL and seed
customers/orderstables, setREPLICA IDENTITY FULL, and create thedbz_publicationpublication (postgres/init/01-init.sql). - Start ClickHouse and create the
cdc_dbdatabase withReplacingMergeTreetarget tables (clickhouse/init/01-init.sql). - Start Kafka, Schema Registry, and Kafka Connect (built from connect/Dockerfile, bundling the Debezium PostgreSQL connector, the Confluent JDBC sink connector, the Avro converter, and the ClickHouse JDBC driver).
- Once Connect is healthy, the one-shot
connect-initservice registers both connectors from connectors/ automatically. - Start AKHQ at http://localhost:8080 (Schema Registry wired in, so Avro topics render as decoded JSON in the UI).
Check everything is up:
docker compose psIf the automatic registration didn't run (e.g. you started Connect separately), register the connectors manually:
# from Git Bash / WSL / Linux / macOS
sh scripts/register-connectors.sh # set CONNECT_URL=http://localhost:8083 if running outside the compose network
# from native PowerShell
./scripts/register-connectors.ps1Check connector status any time:
sh scripts/check-status.sh-
Insert a row in Postgres:
docker exec -it postgres psql -U postgres -d cdc_db -c "INSERT INTO customers (full_name, email) VALUES ('Grace Hopper', 'grace@example.com');"
-
Confirm the change landed in ClickHouse:
docker exec -it clickhouse clickhouse-client --query "SELECT * FROM cdc_db.customers_latest ORDER BY id"
-
Update and delete a row, then re-check
customers_latest— updates apply in place and deletes disappear from the view (they're soft-deleted under the hood, see below). -
Watch it happen live in AKHQ (http://localhost:8080): browse the
pg1.public.customers/pg1.public.orderstopics for raw CDC events, and the Kafka Connect tab for connector/task status.
Both connectors use io.confluent.connect.avro.AvroConverter for keys and values instead of JSON — set once at the Kafka Connect worker level (CONNECT_KEY_CONVERTER/CONNECT_VALUE_CONVERTER in docker-compose.yml) and inherited by every connector, rather than repeated in each connector's JSON. Avro encodes each record as compact binary plus a small schema ID, with the full schema stored once in Schema Registry rather than repeated in every message — meaningfully smaller messages and lower Kafka storage/network overhead than schema-carrying JSON, at the cost of needing Schema Registry up before Connect starts (see depends_on in docker-compose.yml). AKHQ is schema-registry-aware (akhq/application.yml) so topics still show up as readable decoded records in the UI rather than raw bytes.
The Debezium source connector uses the ExtractNewRecordState SMT (transforms.unwrap) to flatten Debezium's before/after/op envelope into a plain "current row" record, adding three bookkeeping fields:
__op—c(create),u(update), ord(delete)__ts_ms— source event timestamp__deleted—trueon delete, sincedelete.handling.mode=rewriteturns deletes into a rewritten record instead of a Kafka tombstone
This shape is what the JDBC sink connector (and ClickHouse) actually receive — see connectors/source/postgres-source.json.
ClickHouse's MergeTree family doesn't support in-place UPDATE/DELETE the way an OLTP database does, so the sink connector only ever INSERTs (insert.mode=insert). Correctness is recovered on the ClickHouse side:
- Tables use
ReplacingMergeTree(__ts_ms)keyed onid, so the newest version of a row wins. - The
customers_latest/orders_latestviews runFINAL(forcing dedup at query time, not waiting for a background merge) and filter out rows where__deleted = true.
See connectors/sink/clickhouse-sink.json and clickhouse/init/01-init.sql for the details.
created_at/updated_at/__ts_ms arrive from Debezium as raw epoch numbers (microseconds for the first two, milliseconds for __ts_ms). The sink connector's TimestampConverter transforms (createdAtToTimestamp/updatedAtToTimestamp/tsMsToTimestamp in connectors/sink/clickhouse-sink.json) convert all three to real DateTime64(3) values before they're written, so no conversion is needed on read.
.
├── .env.example # copy to .env; ports, credentials, db names
├── docker-compose.yml # all services
├── connect/Dockerfile # Kafka Connect image: Debezium + JDBC sink + ClickHouse driver
├── postgres/init/ # schema, replica identity, publication, seed data
├── clickhouse/init/ # destination database/tables/views
├── connectors/
│ ├── source/postgres-source.json # Debezium PostgreSQL source connector config
│ └── sink/clickhouse-sink.json # Confluent JDBC sink connector config
├── akhq/application.yml # AKHQ cluster/connect configuration
└── scripts/ # connector registration + status helpers
- Add the table to
postgres/init/01-init.sql, withREPLICA IDENTITY FULL. - Add it to the publication (or recreate
dbz_publicationto include it). - Add
public.<table>totable.include.listinconnectors/source/postgres-source.json. - Add the matching topic (
pg1.public.<table>) totopicsinconnectors/sink/clickhouse-sink.json. - Create the mirrored
ReplacingMergeTreetable (+_latestview) inclickhouse/init/01-init.sql. - Re-run
scripts/register-connectors.sh(or the.ps1version) to apply the updated connector configs.
docker compose down -v # -v also drops the named volumes (postgres/kafka/clickhouse data)