Quickstart
Replicate one MySQL table into Apache Iceberg, on your machine, in one process. No Kubernetes, no broker.
1. Prerequisites
- Go ≥ 1.26 (to build the binary)
- Docker + Docker Compose (for MySQL and Iceberg — you won't run these in production, they're here so you have something to point the pipeline at)
2. Build the binary
git clone https://github.com/maltzsama/urutau
cd urutau
make build
This produces bin/urutau (the collapsed CLI — coordinator and worker in
one process). make build also builds bin/urutau-coordinator,
bin/urutau-worker, and bin/urutau-operator, which you don't need yet.
3. Start a source and a destination
The repo's e2e stack gives you a real MySQL (binlog enabled) and a real Iceberg catalog (Polaris REST catalog + Trino to read the result back) with one command:
docker compose -f test/e2e/docker-compose.yml up -d --wait mysql polaris trino rustfs bucket-init polaris-setup
This starts only what this quickstart needs — the full compose file also
has Postgres, ClickHouse, and Couchbase for other pipelines, which you can
skip for now. Give it a minute; --wait blocks until the containers report
healthy.
MySQL comes up with a shop database and an orders table already
created (see test/e2e/mysql/init/init.sql), plus a replication user
(repl/replpass).
4. Write a pipeline spec
# pipeline.yaml
pipeline: orders-demo
source:
kind: mysql
uri: mysql://repl:replpass@127.0.0.1:3306/shop
serverId: "1101"
sink:
type: iceberg+rest
uri: http://localhost:8181/api/catalog
warehouse: quickstart_catalog
namespace: bronze
clientId: root
clientSecret: s3cr3t
scope: PRINCIPAL_ROLE:ALL
tables:
- source: shop.orders
target: bronze.orders
primaryKey: [id]
writeMode: upsert
createIfNotExists: true
A replicator reading a MySQL binlog identifies itself to the server with a
server id — every client attached to the same MySQL, replicas and
other CDC tools included, needs a distinct one. serverId is a string in
the YAML (quoted, as above) but must hold a number that fits a uint32;
Validate rejects it at boot otherwise. When set in the spec it wins over
whatever --server-id the binary was started with — the flag is only a
default for when the spec is silent. If you point a second pipeline at
the same MySQL, give it a different serverId; there is no uniqueness
check across pipelines, and a collision corrupts binlog state for both.
5. Run it
./bin/urutau run -f pipeline.yaml
This is the collapsed mode: reader, worker, and sink writer in one process, one binary, no Kubernetes. It:
- Snapshots the existing rows in
shop.orders(DBLog: chunkedSELECT, safe under concurrent writes). - Switches to streaming the binlog live.
- Writes both into
bronze.ordersin Iceberg, upsert byid.
Leave it running — it's a long-lived process, like any replicator.
6. Watch it on the dashboard
Add --metrics-addr :9090 to enable the embedded monitoring dashboard:
./bin/urutau run -f pipeline.yaml --metrics-addr :9090
Open http://localhost:9090 in your browser. You'll see the pipeline status, the table stream with live throughput charts, and the connected worker — all updated in real time via Server-Sent Events.
The dashboard also has a JSON API at /api/v1/*. See
Dashboard and
Dashboard API for details.
7. Prove it worked — read it back
Don't trust the write; read it back through a real SQL engine:
docker exec -it $(docker compose -f test/e2e/docker-compose.yml ps -q trino) \
trino --execute "SELECT * FROM iceberg.bronze.orders"
init.sql only creates the orders table's schema, no rows — this query
comes back empty, which is correct. What matters is that the table exists
in Iceberg with the right columns (DESCRIBE iceberg.bronze.orders if you
want to check).
8. Change some data, watch it replicate
In another terminal, connect to MySQL and mutate shop.orders:
docker exec -it $(docker compose -f test/e2e/docker-compose.yml ps -q mysql) \
mysql -uroot -prootpass shop \
-e "INSERT INTO orders (id, v, amount) VALUES (101, 'hello', 9.99);"
Query Trino again (step 6). The insert shows up in a couple seconds. An
UPDATE/DELETE on the same id takes a little longer to show up — the
worker batches changes on a commit interval (a few seconds, not instant) —
but applies as an upsert / equality-delete in Iceberg, never a new
appended row next to the old one:
docker exec -it $(docker compose -f test/e2e/docker-compose.yml ps -q mysql) \
mysql -uroot -prootpass shop \
-e "UPDATE orders SET v='updated' WHERE id=101;"
9. Stop and clean up
# Ctrl-C the running `urutau run` process, then:
docker compose -f test/e2e/docker-compose.yml down
What you just proved
- Upsert reflects state: an
UPDATE/DELETEin MySQL changes the row in Iceberg, it doesn't append a new version next to the old one. - No broker:
urutau runread the binlog and wrote Iceberg directly — nothing sat in between. - The position lives in the sink: restart
urutau runright now against the same spec — it resumes from the last committed position (stored in Iceberg's own table properties), it does not re-snapshot.
Next steps
- Postgres or Kafka as the source instead of MySQL: see
Sources for what each source needs (Postgres
needs
slotName; Kafka reads its brokers fromuri, same as every other source, and needs an explicitcolumns:block since it has nothing to introspect). - ClickHouse or Couchbase as the sink instead of Iceberg: same spec
shape, different
sink.typeand connection fields — see Sinks. - Distributed mode (coordinator + worker, for when one process isn't
enough):
urutau-coordinatorandurutau-workerinstead ofurutau run. - Enrichment (joining events against a small reference table before they land): see Enrichment.
- Writing your own source/sink: Plugins.
- The full behavior contract (delivery guarantees, ordering, what happens on a poison batch): Delivery guarantees.
If something in this page doesn't work as written, that's a doc bug — open an issue rather than silently working around it.