Terraform for a Change Data Capture (CDC) pipeline on AWS: stream every INSERT / UPDATE / DELETE from RDS PostgreSQL into Amazon MSK (Kafka) using a Debezium connector running on MSK Connect — no app code, no polling.
┌─────────────┐ WAL ┌──────────────┐ Kafka topic ┌─────────────┐
│ RDS │─────────▶│ Debezium │───────────────▶│ Consumer │
│ PostgreSQL │ (logical │ (MSK │ rds.public.* │ (any app) │
│ │ repl.) │ Connect) │ │ │
└─────────────┘ └──────────────┘ └─────────────┘
you write reads the WAL, search index,
normal SQL emits 1 event/row change cache, warehouse…
Change Data Capture turns a database into a stream of events. Instead of polling
a table asking "what changed?", CDC reads the database's own transaction log (the
PostgreSQL write-ahead log / WAL) and emits one event per row change — with the
operation type (c create, u update, d delete, r snapshot read) plus the
before/after row values.
Debezium reads the log; Kafka (MSK) carries the events; any consumer subscribes. The source database does no extra work beyond what it already writes to its WAL.
- Sync DB changes to a search index, cache, or data warehouse in real time
- Append-only audit log of every change ever made
- Event-driven microservices that react to DB changes
- Zero-downtime database migrations and replication
The pieces are deliberately decoupled — each module hands a value to the next:
-
networkingbuilds an isolated10.20.0.0/16VPC with two public subnets (two AZs, because MSK and the RDS subnet group both require multi-AZ). It also creates one self-referencing security group: anything inside the SG can talk to anything else inside the SG, so RDS↔MSK↔Connect "just works" without per-port rules. An S3 gateway endpoint (free) lets MSK Connect download the Debezium plugin without a NAT Gateway. -
iamcreates the service execution role MSK Connect assumes — scoped to read the plugin from S3 and write its worker logs to CloudWatch. -
rdsprovisions PostgreSQL with a custom parameter group that setsrds.logical_replication = 1(→wal_level = logical). This is the switch that makes the WAL readable by Debezium. Without it, no CDC. -
mskstands up akafka.t3.small2-broker cluster, PLAINTEXT, withauto.create.topics.enable = false. -
msk_connectis where the actual CDC engine runs:- A
local-execprovisioner downloads the Debezium Postgres connector tarball from Maven, repacks it as a flat ZIP, and uploads it to an S3 bucket. - That ZIP is registered as an MSK Connect custom plugin.
- The connector runs the plugin against the RDS host + MSK brokers. It
opens a logical replication slot named
debezium, auto-creates a Postgres publication (publication.autocreate.mode = filtered) for the captured tables, and streams changes. - Because the broker won't auto-create topics, the connector self-creates them
via Kafka Connect's
topic.creation.*config (replication factor 2 = broker count, 1 partition).
- A
| From → To | Value handed over |
|---|---|
networking → all |
subnet_ids, lab_security_group_id |
iam → msk_connect |
service_execution_role_arn |
rds → msk_connect |
endpoint (DB host) + db_password |
msk → msk_connect |
bootstrap_brokers_plaintext |
Debezium emits to topics named {topic_prefix}.{schema}.{table}. With the
defaults (topic_prefix = "rds", table_include_list = "public.users") that's a
single topic rds.public.users. A row update looks roughly like:
{
"op": "u",
"before": { "id": 7, "email": "old@x.com" },
"after": { "id": 7, "email": "new@x.com" },
"source": { "schema": "public", "table": "users", "lsn": 24034896 }
}| Module | Purpose |
|---|---|
networking |
Dedicated VPC, 2 public subnets (2 AZs), IGW, self-ref SG, S3 endpoint |
iam |
MSK Connect service execution role (scoped S3 read + CloudWatch logs) |
rds |
Parameter group (logical replication) + PostgreSQL instance |
msk |
kafka.t3.small cluster, 2 brokers, PLAINTEXT |
msk_connect |
Builds + uploads Debezium plugin, registers it, runs the connector |
cdc-infra/
├── providers.tf # AWS provider, default tags
├── variables.tf # region, project_name, vpc_cidr, db_password, my_ip_cidr
├── main.tf # module wiring
├── outputs.tf # endpoints, brokers, connector name, plugin bucket
├── terraform.tfvars # secrets (gitignored)
├── terraform.tfvars.example
└── modules/
├── networking/ # VPC, subnets, IGW, SG, S3 gateway endpoint
├── iam/ # MSK Connect service execution role
├── rds/ # parameter group + Postgres instance
├── msk/ # Kafka cluster
└── msk_connect/ # Debezium plugin build + connector
- Dedicated VPC — nothing touches the account default VPC;
terraform destroyremoves everything cleanly. - Self-referencing security group — one SG shared by RDS, MSK, and Connect;
intra-SG traffic fully allowed. Optional
my_ip_cidropens psql (5432) and Kafka (9092) to your laptop only. - S3 gateway endpoint — free; lets MSK Connect pull the plugin ZIP without a NAT Gateway or internet egress.
- Logical replication — set in the RDS parameter group with
apply_method = pending-reboot; the instance must reboot once before the slot can be created. kafka.t3.small— smallest MSK broker; the console blocks it, so it must be provisioned via API / Terraform.- Self-creating topics — broker has
auto.create.topics.enable = false, so the connector creates topics itself (topic.creation.*). - Plugin built at apply time — a
null_resource+local-execfetches the Debezium tarball from Maven; needscurl,tar,zip, and the AWS CLI on the machine running Terraform.
# One-time
cp terraform.tfvars.example terraform.tfvars # set db_password (+ my_ip_cidr)
terraform init
# Build / inspect
terraform plan
terraform apply
# Tear down
terraform destroy# 1. Connect to RDS (needs my_ip_cidr set), make a change
psql -h "$(terraform output -raw rds_endpoint)" -U postgres -d postgres
# create table users (id serial primary key, email text);
# insert into users (email) values ('a@x.com');
# update users set email = 'b@x.com' where id = 1;
# 2. Read the events off Kafka
kafka-console-consumer.sh \
--bootstrap-server "$(terraform output -raw msk_bootstrap_brokers)" \
--topic rds.public.users --from-beginning| Name | Default | Notes |
|---|---|---|
region |
us-east-1 |
— |
project_name |
lab |
prefix for all resource names |
vpc_cidr |
10.20.0.0/16 |
CIDR for the dedicated VPC |
my_ip_cidr |
"" |
home IP /32 for psql/kafka access |
db_password |
(required) | RDS master password; set in tfvars |
The defaults capture public.users into topic rds.public.users. Everything you
need to change lives in the msk_connect module's variables — wire them through
from the msk_connect block in main.tf.
| Goal | Change |
|---|---|
| Capture different / more tables | Set table_include_list (comma-separated schema.table, e.g. public.users,public.orders). One topic per table. |
| Capture a whole schema | Use a regex, e.g. table_include_list = "public.*" (Debezium treats entries as regex). |
| Rename the topic namespace | Set topic_prefix (e.g. prod → prod.public.users). |
| Different database / user | Set db_name, db_user, db_port. |
| Newer Debezium | Bump plugin_version; the build step refetches from Maven. The null_resource trigger rebuilds + reuploads. |
| More throughput | Raise mcu_count / worker_count in the connector capacity block, and topic partitions. |
| Multiple environments | Change project_name (prefixes every resource) and run in a separate workspace / state. |
- Encryption / auth — this lab uses PLAINTEXT +
authentication_type = NONEfor simplicity. For real use, switch MSK to TLS or IAM auth and update the connector'skafka_cluster_client_authentication/encryption_in_transit. - Private subnets + NAT or VPC endpoints — move RDS/MSK to private subnets; the S3 endpoint already keeps plugin downloads private.
- Schema registry — swap
JsonConverterfor Avro + Glue/Confluent registry if consumers need versioned schemas.
- Remote state: S3 backend + native locking
- GitHub Actions:
plan.yml(PR),apply.yml(merge),destroy.yml(manual) - OIDC auth for GitHub Actions (no static keys)
