A self-hostable backend for the PowerSync write path: a client uploads its queued local changes to an HTTP API, which persists them to your source database. PowerSync replicates that database back to clients.
Clone it, point it at your own database, and change the code.
This brings up a complete, self-contained system with nothing for you to configure: a seeded
Postgres, the PowerSync service, the write API, and a small demo client. The committed .env
already holds everything it needs, including a throwaway signing keypair.
docker compose up --build- Demo client: http://localhost:5173
- Write API: http://localhost:6060
- PowerSync: http://localhost:8080
- Example Postgres: localhost:5432
Open the demo client, add a todo, and watch it land in Postgres and sync back.
This is a smoke test, not the product. The todo schema, the seed data and the demo client are
scaffolding to prove the machinery works before you wire in anything of your own. Everything
specific to it lives under examples/ and can be deleted in one go.
Edit .env and select Adopter Mode:
COMPOSE_FILE=docker-compose.yaml
DATABASE_TYPE=postgres
DATABASE_URI=postgres://user:password@your-host:5432/your-dbPowerSync replicates by reading your database's change feed, and every flavour needs that turned on before anything syncs. This is the part that silently produces an empty app if skipped.
| Flavour | What must be true of your database |
|---|---|
| Postgres | a publication named powersync covering the replicated tables; a user with SELECT on them and replication rights. A Postgres source without a publication replicates nothing. |
| MongoDB | A replica set — change streams and the multi-document transactions the write API uses both require one. Post-images configured (post_images: auto_configure), since change streams alone do not carry the pre-update document. |
| MySQL | log_bin on, gtid_mode=ON, enforce_gtid_consistency=ON, binlog_format=ROW, binlog_row_image=FULL, a unique server-id; a user with REPLICATION SLAVE and SELECT. On managed MySQL these usually live in a parameter group and need a restart. |
| SQL Server | CDC enabled at database level and per replicated table; a CDC-enabled _powersync_checkpoints table; SQL Server Agent running, or CDC captures nothing while appearing enabled; the user needs cdc_reader, VIEW DATABASE PERFORMANCE STATE in the database, and VIEW SERVER PERFORMANCE STATE in master. |
Each examples/<flavour>/README.md has the worked SQL and the managed-hosting wrinkles. Those
live under examples/, which you are invited to delete — so the table above is the version that
survives that, deliberately.
Then describe your own schema in config/service.yaml and config/sync-config.yaml. Those two
files are yours from the first minute — no example ever writes to them.
Fill in config/sync-config.yaml before you start. It ships empty, because only you know your
schema, and PowerSync will restart in a loop logging 'streams' are required until it has at
least one stream. Remember auto_subscribe: true — without it a stream syncs nothing and reports
no error anywhere.
If your database runs on this machine rather than in Docker, reach it at host.docker.internal,
not localhost — inside a container, localhost is the container.
In Adopter Mode there is no bundled database and no demo client. Bring your own client.
Bucket storage — PowerSync's own internal store — always runs in a container this project owns, in every mode. We never create schemas in a database you merely pointed us at.
Mode selection is the COMPOSE_FILE line in .env, with the alternatives sitting there commented
out. The command stays a plain docker compose up, so down, logs and ps behave normally.
.env line |
What runs |
|---|---|
docker-compose.yaml:examples/postgres/compose.yaml |
Example Mode, Postgres |
docker-compose.yaml:examples/mongodb/compose.yaml |
Example Mode, MongoDB |
docker-compose.yaml:examples/mysql/compose.yaml |
Example Mode, MySQL (Beta) |
docker-compose.yaml:examples/mssql/compose.yaml |
Example Mode, SQL Server (Beta) |
docker-compose.yaml |
Adopter Mode, your database |
Only one runs at a time — they share ports, and each has its own Compose project name so switching never reuses the previous flavour's volumes.
Bring the current mode down before switching. Because each mode is its own Compose project,
docker compose down only stops the mode currently selected in .env. Edit the line first and the
old containers keep running and holding ports, and the new mode fails with
Bind for 0.0.0.0:6060 failed: port is already allocated. Down first, then switch.
If you would rather be explicit, the same thing without .env:
docker compose -f docker-compose.yaml -f examples/postgres/compose.yaml upwrite-api/
├── docker-compose.yaml # Base: PowerSync, bucket storage, write API
├── .env # Mode selection and throwaway dev keys
├── config/ # ADOPTER MODE config — yours to edit
│ ├── service.yaml
│ └── sync-config.yaml
├── docker-compose.dev.yaml # Overlay: edit backend code without rebuilding
├── examples/ # Delete this when you no longer need it
│ ├── postgres/ # Seeded Postgres + demo client
│ │ ├── compose.yaml
│ │ ├── powersync/ # This example's PowerSync config
│ │ └── init-scripts/ # Demo schema + seed data
│ ├── mongodb/ # No source container — shares the bucket-storage replica set
│ ├── mysql/ # Binlog config + seeded schema
│ └── mssql/ # CDC bootstrap container + seeded schema
├── backend/ # The write API (Express, port 6060)
│ └── openapi.yaml # Shared contract, read by both packages
└── frontend/ # Demo client (React/Vite) — a test fixture
Append the development overlay to whichever mode you are in:
COMPOSE_FILE=docker-compose.yaml:examples/postgres/compose.yaml:docker-compose.dev.yamlYour working tree is mounted into the container and the process restarts on save — an edit is
serving in about two seconds, with no image rebuild. It works in Adopter Mode too, which is
arguably where it matters more: wiring this into your own database is exactly when you are editing
src/persistance/ and src/auth/verifier.ts.
Without the overlay, changes ship on rebuild — the deployment-shaped path:
docker compose up --buildThe demo client is a Vite app, so its own loop is the usual one, on the host:
cd frontend && pnpm devThat reads .env.local at runtime, so changing a URL needs no rebuild. In the container the client
is a production build with its URLs baked in, which is why it is not part of the overlay.
Auth seams worth knowing: backend/src/auth/verifier.ts is where you swap the demo's tokens for
your own identity provider — see auth-verifiers.md for worked examples.
Replacing the throwaway signing keys is one command:
cd backend && pnpm generate-keys # prints both values for .envThe signing keys in
.envare a public throwaway pair, committed so the backend signs consistently across restarts. Replace them before this is anything but a demo.
Both packages generate TypeScript from backend/openapi.yaml:
cd backend && pnpm generate-types # -> src/generated/api.ts
cd frontend && pnpm generate # -> src/generated/api.d.ts