Skip to content

Dockerize backend - #11

Merged
Chriztiaan merged 19 commits into
mainfrom
feat/dockerize
Sep 17, 2026
Merged

Chriztiaan merged 19 commits into
mainfrom
feat/dockerize

Conversation

@Chriztiaan

Copy link
Copy Markdown
Collaborator

Self-hosting: one docker compose up from a clean clone

Turns the demo into a starter template someone can actually take. Before this, running it meant
three terminals, two .env files to fill in, and a keypair to generate first. Now a fresh clone
comes up complete — seeded database, PowerSync service, write API, demo client — with nothing to
configure, and pointing it at your own database is one line in .env.

Branches off and merges feat/base (PRs #1, #5, #7).

Two run modes, one line apart

docker-compose.yaml is the base: bucket storage, the write API, the PowerSync service. On its own
it is Adopter Mode — your database, no demo client. An overlay under examples/ adds a seeded
source database, the demo client and its own PowerSync config, giving Example Mode.

Selection is the COMPOSE_FILE line in .env, so the command stays a plain docker compose up
and down, logs and ps all behave normally.

docker-compose.yaml:examples/postgres/compose.yaml   # Example Mode, Postgres
docker-compose.yaml                                   # Adopter Mode, your database

Four flavours are bundled: Postgres, MongoDB, MySQL (Beta) and SQL Server (Beta). Each has its own
overlay, seed data, PowerSync config and README, and each was brought up from a dropped volume and
verified end to end — seeded rows synced, and a write round-tripped through the source database and
back. They differ where the databases differ: MySQL needs binlog settings mounted before the server
starts rather than applied afterwards, SQL Server needs a one-shot CDC bootstrap container and runs
under amd64 emulation, and MongoDB adds no container at all, using a second database on the replica
set bucket storage already runs.

Bucket storage always stays in a container this project owns. We never create schemas in a database
you merely pointed us at.

Also in here

A development overlay. Append docker-compose.dev.yaml to any mode and your working tree is
mounted in with the watch process running — an edit is serving in about two seconds, no rebuild.
Works in Adopter Mode too, which is where it matters most: wiring in your own database is exactly
when you are editing src/persistance/ and src/auth/verifier.ts.

Sync rules move to the compiled-streams format (edition 3), renamed to sync-config.yaml.
Whatever ships here is what an adopter copies for their own schema, and upstream now documents only
the new shape.

A committed throwaway keypair. Without a stable key the backend mints a new one on every restart
and PowerSync rejects tokens it accepted moments earlier (PSYNC_S2101). The keys in .env are
public and marked as such; pnpm generate-keys prints a replacement.

Verification

Every mode was brought up and exercised by hand; test.txt is the manual test sheet.

AI disclosure

This PR was created with the help of Claude Code. Help constitutes assistance in research, planning,
and rough outline of implementation. Beyond having a hand in the implementation, I have also manually
tested this work.

Chriztiaan and others added 18 commits September 16, 2026 16:17
Design for turning this repo into a starter template someone can clone, point
at their own source database, and edit: two run modes selected by one line of
configuration, four Example Source Databases, and a development loop that does
not require rebuilding an image.

Records the rejected Compose layouts with the specific failure each has, and
the two load-bearing mechanisms verified against Compose v2.38.2 rather than
assumed — overlay volume replacement keyed on mount target, and the Compose
file list being read from the environment file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend image booted, logged that it was running, and then answered every
request with 500 "openapi.validator: spec could not be read at /openapi.yaml".
The contract it validates against lived at the repo root, outside the backend's
Docker build context, so it was never copied into the image — and the validator
loads it lazily on first request, so nothing failed at startup. A container that
looked healthy and served nothing.

Move the contract beside app.ts, inside the build context. Both packages still
generate their types from that one file, so the API stays single-sourced.

Two further defects surfaced while fixing it:

The backend had no .dockerignore, so COPY / ./ copied the host's node_modules
over the ones installed in the image — macOS-native binaries into a Linux
container — along with local env files and logs.

The root route was unreachable. It is registered after the OpenAPI validator,
which rejects any path absent from the spec, so it 404'd even on a machine where
the spec resolved. It now has an explicit exemption alongside the auth routes,
which also gives the backend a liveness probe for Compose to depend on.

Adds the first test at the existing HTTP seam, covering assembly rather than any
one route: if the contract cannot be resolved, every test here fails at once.

Verified against a built image — root route 200, token and JWKS endpoints
responding, a contract-violating request rejected with 400 rather than falling
through to the auth gate, and the image's node_modules the Linux ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migrate from legacy bucket definitions to the edition 3 streams format, and
rename the sync file to match. The service configuration now references it
under sync_config; the deprecated sync_rules key is gone, since specifying both
is a hard error.

The legacy format still works and is not deprecated, so this is not forced. The
reason to move is that whatever ships here is what an adopter copies when
writing rules for their own schema, and upstream documentation now describes
only the new shape.

auto_subscribe is set explicitly on the stream. It defaults to false, and the
legacy format had no equivalent concept, so omitting it syncs nothing while
reporting no error anywhere.

Verified end to end: the service accepted the config and replicated both tables,
a sync stream opened with no explicit subscription reported the stream as
default and delivered all seeded rows, and a write through the API came back
through sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standards axis:

The test-file comment claimed the validator resolves the contract at
construction. It does not — it resolves lazily on the first request, which is
precisely why the container booted cleanly and only then failed everything.
Corrected, and the claim that both tests are a regression net is now stated as
verified rather than asserted: spec resolution runs before the ignorePaths
check, so removing the contract fails both tests, including the exempted root
route. Confirmed by deleting the file and watching them go red.

The ignorePaths regex packed two unrelated exemptions into one opaque literal
with no explanation; commented.

.dockerignore used *.test.ts, which in Docker's matching does not cross a path
separator, so any future src/**/*.test.ts would still have shipped.

backend/README.md coined "flavour" as a domain term while the glossary is
deliberately untouched; reworded. Test prose used bare "batch", which the
glossary lists under Transaction's avoid-list.

Spec axis:

The README project layout still listed sync_rules.yaml, renamed in the previous
commit — and had been stale for longer than that, still naming the backend and
frontend by directory names they no longer use.

self-host-plan.md described the service.yaml rename as part of the streams
migration when it belongs with the directory restructure, and claimed .scratch
issues were out of scope when the work is in fact tracked there.

Two review findings were checked and rejected. The root route is not the only
path outside the contract — only /api/data is declared, so the auth routes have
always been exempt too, and a liveness probe follows that precedent. The sync
config's editor schema URL resolves and is the path upstream ships on its own
edition 3 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One command from a fresh clone now brings up a complete working system with no
configuration and no credentials: seeded Postgres, bucket storage, the write API
built from local source, and the demo client.

Splits compose into a base definition plus overlays. The base is everything both
modes share and is Adopter Mode on its own; examples/postgres/compose.yaml adds
the source database, the demo client, and its own PowerSync config. Mode
selection is the COMPOSE_FILE line in .env, so the command stays a plain
docker compose up and every other subcommand behaves normally.

Two pieces of wiring change now that the backend is containerised. PowerSync
reaches it by service name rather than through the host, and gates startup on
its healthcheck — which works only because ticket 01 made the root route
reachable, so that exemption is now load-bearing for startup ordering.

The signing keypair is committed as a public throwaway. Without it the backend
mints a new key on every restart and PowerSync rejects tokens it just accepted
with PSYNC_S2101; that failure was hit for real while verifying the previous
commit. Tested directly here: after restarting the backend, both a token issued
before the restart and a freshly issued one are accepted, with no PowerSync
restart.

config/service.yaml and config/sync-config.yaml are pulled forward from the next
ticket. The base mounts ./config, and moving the demo config into examples/ left
that path empty, so Compose would have created an empty directory and PowerSync
would have died looking for /config/service.yaml.

Verified from a down -v: all services healthy, seeded rows delivered through a
sync stream with no explicit subscription, and a write round-tripping through
Postgres and back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopter Mode now works against a database this project does not own. Verified
against a standalone Postgres started outside this Compose file, with its own
schema and credentials, reached over host.docker.internal: the adopter's rows
synced, a write through the API landed in their database, and it came back.

The fixed rule was checked rather than asserted — after a full run against that
external database, it contained zero powersync schemas. Bucket storage stayed in
the container this project owns.

Refusing to start is now a readable failure. It already failed before listening,
but by throwing a raw stack trace, which reads as a bug in the adopter's code
rather than a setting they have not filled in. A ConfigurationError distinguishes
misconfiguration from a genuine fault, and the message names the fix, including
the alternative of selecting a bundled example. An unsupported DATABASE_TYPE now
lists what is supported.

Written test-first at a process-level seam. The spec claimed Seam 1 covered this;
it does not, and cannot — Seam 1 drives HTTP against an assembled application,
and the behaviour here is a refusal to reach the point where there is anything to
send a request to. The check spawns the backend and asserts on exit code and
output, in the backend's existing runner rather than a third architectural seam.

config/sync-config.yaml ships empty because only the adopter knows their schema,
and PowerSync treats that as fatal and restart-loops on 'streams' are required.
Shipping a stream that references tables which may not exist would be worse, so
the template and README both state that this is expected and must be filled in
before starting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guards the two mechanisms holding the mode switch together, both of which fail
silently. If an example's config mount appended to the base's instead of
replacing it, or a mode selection failed to resolve, the stack would come up
perfectly healthy pointing at the wrong sync rules — nothing crashes, nothing
logs, and the only symptom is data that does not sync.

Compose is asked to resolve each mode rather than run it, so no containers start
and no images are pulled; the suite takes about a second. It asserts the service
set, the project name, that exactly one directory is mounted at /config and it is
the right one, and that the write API and replication point at the same database.

Two assertions go past the ticket's criteria deliberately: bucket storage must
point at the owned mongo service in every mode, which is the spec's one fixed
rule, and PowerSync must reach the backend by service name rather than through
the host.

Mutation-tested: breaking the overlay's mount target turns exactly one test red
and leaves the rest green.

Lives at the repo root with its own runner, because the topology is a repo-level
concern and the backend's suite must stay runnable without Docker. The root had
no package.json and no node_modules ignore rule; both added, the latter scoped so
it cannot match the lockfiles the Dockerfiles install from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a config at the repo root silently broke the backend's suite. Vitest walks
up looking for a config file; the backend had none of its own, so it inherited
the root's include pattern, found nothing matching, and exited 1 reporting "No
test files found" — with both of its test files sitting right there.

The backend now has an explicit config. Both suites verified by running them:
4 tests in the backend, 12 at the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds no database container at all. PowerSync already runs a Mongo replica set for
bucket storage, so replication points at a second database on that same server —
powersync_demo_source alongside powersync_bucket_storage. Separate databases, one
process, so trying this flavour costs nothing extra.

No seed script either: collections are created implicitly by the first write, so
the app starts empty and fills as you use it.

The sync rules project _id onto id, which is what makes documents arrive with the
identifier the rest of the system keys on. The comment explaining it was wrong —
inherited from upstream's wording, it claimed * excludes underscore-prefixed
fields. The synced documents carry _id alongside id, so the comment now describes
what actually happens; the extra field is harmless.

The topology suite covers this mode too, including that it introduces no source
service. Eighteen assertions across three modes.

Verified: a write returned success, landed in the source database with the
client-generated string as _id, and came back through sync with a usable id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Append docker-compose.dev.yaml to any mode and your working tree is mounted into
the container with the watch process running: an edit is serving in about two
seconds, no image rebuild. Verified in both modes by editing a route handler
while the stack ran, including in Adopter Mode against an external Postgres.

The keypair dependency was verified rather than assumed — a token issued before
an edit triggered a reload was still accepted afterwards. That is precisely why
the previous work committed a fixed pair; ephemeral keys would have fired this on
every save.

An anonymous volume preserves the image's node_modules. Without it the bind mount
shadows them with the host's, which on macOS means binaries the container cannot
run.

The demo client stays out of the overlay deliberately. The overlay applies to both
modes and Adopter Mode has no frontend service, so an override would try to create
one with no build context. Its loop is the ordinary `pnpm dev` on the host, which
reads .env.local at runtime — documented instead.

pnpm generate-keys prints a replacement keypair in the form .env expects.

Also documents a trap hit while testing: each mode is its own Compose project, so
`docker compose down` only stops the mode currently selected in .env. Switching
without bringing the old one down leaves containers holding ports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A seeded MySQL Example Source Database, selected by the same one-line switch.

The binlog configuration is mounted into the server's config directory rather
than applied by a setup script, because PowerSync reads the binary log and it has
to be in the right shape before the server starts — by the time a script could
run it is too late. That is the structural difference from the Postgres example,
where wal_level is a command-line flag and the publication is created afterwards
in SQL.

Two users deliberately: the write API connects as root while PowerSync connects
as a restricted powersync user holding REPLICATION SLAVE to read the binary log
and SELECT to take the initial snapshot, showing what least privilege replication
actually needs.

Verified by querying the running server for the settings rather than assuming the
config was read — log_bin, gtid_mode, enforce_gtid_consistency, binlog_format ROW,
binlog_row_image FULL and a server id all confirmed. Seeded rows synced, and a
write round-tripped through MySQL and back.

The connector is Beta, stated plainly in the example README and marked in the root
README's mode table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The heaviest of the four. SQL Server replicates through Change Data Capture
rather than log streaming, and has no entrypoint directory to drop SQL into, so
the bootstrap is a one-shot container gated on service_completed_successfully:
CDC at database level, a _powersync_checkpoints table with CDC enabled on it, CDC
per replicated table, and a restricted replication user. SQL Server Agent is
enabled because CDC capture runs as Agent jobs — without it CDC appears enabled
and captures nothing.

Fixes a permission bug found by running it rather than reading about it. With CDC
fully configured, replication still failed with "The user does not have permission
to perform this action", preceded by VIEW SERVER PERFORMANCE STATE being denied in
master. That is a server-level permission on the login; the script had granted
only the database-level VIEW DATABASE PERFORMANCE STATE. Both are needed, and the
two read alike in the documentation.

Microsoft publishes no arm64 image, so this runs under linux/amd64 emulation with
a long healthcheck start period. The other three flavours run natively.

The README leads with the sharp edge rather than burying it: CDC captures a
table's shape when enabled, so schema changes are not adopted automatically and an
added column never arrives with nothing erroring — which matters most in a repo
whose premise is replacing the schema.

Verified from a dropped volume to prove the bootstrap works from nothing: CDC
state confirmed on the server, seeded rows synced, and a write round-tripped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The serious one: Adopter Mode's readable configuration failure was a restart
loop. The backend carried restart: unless-stopped, so exit(1) respawned forever —
eight times in twenty-five seconds — scrolling the message past repeatedly, and
powersync gates on backend: service_healthy, so an unconfigured Adopter Mode hung
on a dependency that could never pass. This was verified on the host with tsx and
never in the container, which is how it survived. Now restart: on-failure:3: it
stops after four attempts with the message as the last thing on screen, while a
transient crash still recovers.

The boot test could not have caught it, and had a matching flaw: it resolved only
on 'close', so a regression that DID start the server left the promise pending
until the suite timed out and leaked a listening process. It now kills the child
on a deadline and asserts explicitly that it was not still running. Its stack
trace assertion also hardcoded a function name, so renaming that function would
have made it vacuously true; it now matches the shape of a stack frame. Spawns
the local tsx rather than npx, which can reach the network on a cold cache.

Four copies had already drifted apart: examples/postgres/powersync/service.yaml
came through as a rename and never picked up the schema header or storage comment
the other three carry, and the mysql and mssql overlays silently dropped the
comment explaining why the client's URLs are localhost.

The SQL Server password was written literally four times, including in the
bootstrap entrypoint, while the healthcheck two blocks above correctly used the
env var. Defined once as an anchor now. Database credentials across the examples
are marked as public throwaways the way the signing keys already were.

Topology test table had optional fields that every mode sets and every assertion
dereferences, so omitting one would have passed against undefined; now required.
Resolutions are memoised — the suite was shelling out to Compose once per
assertion. A doc comment described a null that cannot occur.

Adds the per-flavour smoke procedure the spec promised and I never wrote, as the
manual check the topology suite deliberately does not replace. The Postgres one
was run verbatim rather than written from memory.

The README's "no configuration and no credentials" was untrue — the committed
.env is both; it now says there is nothing for you to configure. The layout block
showed only one of the four examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four documents still described a workflow that no longer exists.

backend/README.md: everything from "Running the app" onward predated Compose —
cp .env.template && pnpm start on the host, and a whole ngrok section tunnelling
to the hosted PowerSync dashboard, which has nothing to do with self-hosting. It
also cited port 8000. Replaced with how the backend actually runs, the variables
it reads and where they come from, the boot-time configuration failure, the
verifier seam, and pnpm generate-keys. The endpoint and error-classification
sections were already accurate and are kept.

diagram.png: deleted. It showed the pre-batching, pre-auth, pre-container
architecture — a request body of { crud: CrudEntry_API[] } where the contract now
takes a transaction batch, no auth at all though a Write Token and Verifier both
exist, Postgres as the only flavour, and the seed schema at a path it no longer
occupies. It was the first thing in the root README, so it was also the first
thing to mislead.

frontend/README.md: three sentences, two of them wrong. It claimed changes to
Postgres sync to clients, true for one of four flavours. Now says plainly that
the client is a test fixture bound to the demo schema and absent from Adopter
Mode, and documents the dev loop, the batching variables and type generation.

backend/.env.template listed variables as though the backend were configured
there; in Compose they come from the root .env and the overlays. It now says so,
and warns about the port 6060 collision with the container.

Also drops file line numbers from self-host-plan.md and fixes frontend's
.env.template, which told you to copy a file that has never existed under that
name. Batching defaults in the new docs were checked against DemoConnector rather
than copied from the old text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root .env is the file an adopter edits, and it offered `# DATABASE_TYPE=postgres`
with no indication that three other values exist. The options were only written
down in backend/README.md and backend/.env.template, neither of which someone
configuring Adopter Mode necessarily opens.

Also records the spelling trap: PowerSync's replication config in
config/service.yaml calls the Postgres connector `postgresql`, while this
variable is read by the write API and spells it `postgres`. Two adjacent config
files, same concept, different spelling. The failure is loud rather than silent —
the backend refuses to start and lists what it supports — but the note saves the
trip.

Adds a connection-string shape per type, and the reminder that a database on the
host is reachable at host.docker.internal rather than localhost. All four shapes
are the ones the working examples actually use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adopter Mode told you to set DATABASE_URI and fill in a sync config, but never
that your database needs its change feed turned on first — no publication for
Postgres, no binlog settings for MySQL, no CDC for SQL Server, no replica set for
MongoDB. Skipping any of them produces an empty app rather than an error, which
is the worst way to learn.

The per-flavour prerequisites were documented, but only inside examples/, which
the same README invites you to delete. Deleting it as intended removed the only
place stating that a Postgres source without a publication replicates nothing.

The Adopter Mode section now carries a table of what must be true of your
database for each flavour, and says plainly that it is the version which survives
deleting examples/. The example READMEs keep the worked SQL and the
managed-hosting wrinkles.

MongoDB also gained the "pointing at your own instance instead" section the other
three already had: replica set requirements, post-images and privileges, the note
that Azure DocumentDB shares the connector but does not support post-images, and
that bucket storage stays in our container even though the bundled example shares
one Mongo process between source and storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Chriztiaan
Chriztiaan changed the base branch from main to feat/base September 17, 2026 12:40
@Chriztiaan
Chriztiaan marked this pull request as ready for review September 17, 2026 14:17
kobiebotha
kobiebotha previously approved these changes Sep 17, 2026
@Chriztiaan
Chriztiaan changed the base branch from feat/base to main September 17, 2026 14:20
@Chriztiaan
Chriztiaan dismissed kobiebotha’s stale review September 17, 2026 14:20

The base branch was changed.

@Chriztiaan
Chriztiaan merged commit 745646d into main Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants