Implement self-hosted PowerSync on web-arm (Cloonar fit) #38

Closed
opened 2026-05-24 23:49:20 +02:00 by dominik.polakovics · 1 comment

Blocked by #37 — do not begin implementation until that issue is closed. The artifacts produced there (Supabase role + publication, reptide-powersync-source-dsn sops secret, sync rules export, Flutter URL constant location) are inputs to the work described here.

Problem Statement

The Cloonar fit Flutter app currently syncs against PowerSync Cloud, paying a monthly subscription for a service that fits cleanly into the existing web-arm fleet host. The goal is to retire the Cloud dependency and run PowerSync Service ourselves, replicating from the same Supabase Postgres source, storing bucket state in web-arm's existing Postgres 14 instance, and serving sync clients at powersync.reptide.eu.

Solution

Add a self-contained NixOS module to web-arm that runs journeyapps/powersync-service as a virtualisation.oci-containers (podman) container — matching the existing collabora.nix / rustdesk.nix / pa11y.nix pattern — with:

  • A new powersync_storage Postgres database + role on the existing PG14 instance.
  • An nginx vhost on powersync.reptide.eu with lego/ACME TLS and WebSocket-aware proxy config.
  • Sync rules mounted read-only from a versioned file copied out of the cloonar-fit repo (operator supplies content via #37).
  • The container image pinned by both tag and sha256 digest, bumped via a scripted update.sh helper following the turn-runner cadence.
  • A blackbox-exporter probe on PowerSync's /probes/liveness endpoint.

The Flutter SDK keeps using its existing Supabase user JWTs unchanged; PowerSync verifies them via Supabase's published JWKS URI. There is no shared HS256 secret to store and no client code change at the auth layer.

User Stories

  1. As an operator, I want PowerSync Service running on web-arm as a podman container under virtualisation.oci-containers, so that it slots into the existing systemd-managed container pattern without introducing a new runtime concept.
  2. As an operator, I want the image pinned by tag and sha256 digest, so that upgrades are deliberate PR-reviewed events and a surprise upstream re-tag can't ship to my fleet.
  3. As an operator, I want an update.sh helper under utils/pkgs/powersync-service/ that resolves Docker Hub's :latest (or a specified tag) to <tag>@<sha256> and rewrites the .nix file, so that bumps follow the same scripted shape as turn-runner bumps.
  4. As an operator, I want PowerSync's bucket storage living in a dedicated powersync_storage database on the existing PG14 instance with its own powersync_storage role, so that backup (via services.postgresqlBackup), monitoring, and lifecycle all stay inside the existing Postgres operational story.
  5. As an operator, I want shared_buffers raised from 80MB to 512MB on web-arm's PG14 in the same change, so that PowerSync's bucket-heavy write workload doesn't thrash a tiny page cache.
  6. As an operator, I want PowerSync's container port 8080 bound to 127.0.0.1 only, so that nginx is the sole ingress path and the service isn't reachable on the LAN even if a downstream firewall rule loosens.
  7. As an operator, I want an nginx vhost on powersync.reptide.eu with enableACME + forceSSL, proxyWebsockets = true, and proxy_read_timeout 3600s, so that the Flutter SDK's long-lived WebSocket sync connections aren't killed by nginx's default 60s timeout.
  8. As an operator, I want the Supabase replication DSN consumed from the sops secret reptide-powersync-source-dsn (created by #37), so that the agent never touches secret material directly.
  9. As an operator, I want PowerSync configured with client_auth.jwks_uri pointing at the Supabase project's /.well-known/jwks.json, so that Flutter clients keep using their existing Supabase user JWTs unchanged through cutover.
  10. As an operator, I want the sync rules YAML mounted read-only into the container, with a file-header comment naming the cloonar-fit source path, so that the relationship to the upstream is documented in-file rather than only in PR history.
  11. As an operator, I want a blackbox-exporter probe added against https://powersync.reptide.eu/probes/liveness, so that a wedged or crash-looping container triggers alerts before the Supabase replication slot starts accumulating WAL on the source side.
  12. As an operator, I want the new PowerSync module imported from web-arm's configuration.nix in the same shape as existing ./modules/<service>.nix imports, so that the host's import block stays consistent.
  13. As an end user of the Cloonar fit app, I want no client-side change required on cutover other than a future Flutter URL release, so that the migration is invisible to my workflow until the rollout phase.
  14. As a future maintainer reading the PR, I want the live PowerSync service.yaml derivable from a single visible source in the repo (rendered by Nix or kept as a static YAML in the module dir), so that the running config is auditable without podman exec into the container.
  15. As a reviewer of bumps in the future, I want digest-pin changes to show up as a 2-line diff on a single .nix file, so that the PR is trivially reviewable and the change is obvious in git blame.

Implementation Decisions

Module shape: a single new module under the web-arm host (alongside collabora.nix, rustdesk.nix, etc.) that colocates the oci-containers definition, the rendered service config, the nginx vhost, the postgres role/database bootstrap, the sops secret reference, and the blackbox probe entry. The sync rules YAML lives as a sibling file inside the module directory and is mounted read-only.

Postgres storage bootstrap: a new role powersync_storage (no replication, no superuser) and a new database powersync_storage owned by that role, both declared via services.postgresql.ensureUsers / ensureDatabases. PowerSync auto-migrates its schema on first startup — no manual DDL.

Postgres tuning: bump shared_buffers from "80MB" to "512MB" in the existing services.postgresql.settings block on the host. This requires a Postgres restart, which nixos-rebuild switch performs automatically; note in the commit message because it briefly interrupts every PG-using service on the host.

Image pinning: image string is journeyapps/powersync-service:<tag>@sha256:<digest> with both components pinned. No --pull=newer. The current latest stable tag should be selected at implementation time. Conservative tag policy: don't pick up major or minor bumps automatically; patch versions can be bumped via the helper script in a normal PR.

update.sh helper: under utils/pkgs/powersync-service/ (mirroring utils/pkgs/<package>/update.sh from the turn-runner and claude-code packages, even though this isn't a Nix derivation). It resolves a tag to <tag>@<sha256> via Docker Hub's manifest API and rewrites the image string in the module's .nix file. No actual derivation under that path — just the script.

Container config: port 8080 bound to 127.0.0.1 only; sync rules and the rendered service.yaml mounted read-only; environment file from the sops secret containing PS_SOURCE_DSN; extraOptions consistent with the existing podman containers (no --pull=newer).

Service.yaml content (rendered by pkgs.writeText with Nix interpolation for the JWKS URI; the DSN is read from env at runtime via !env PS_SOURCE_DSN):

  • replication.connections[0].type: postgresql, URI from env, slot_name: powersync_selfhost.
  • storage.type: postgresql, URI pointing at the local PG via unix socket against the new powersync_storage database.
  • sync_rules.path pointing at the mounted sync-rules.yaml.
  • client_auth.jwks_uri = https://<supabase-project>.supabase.co/auth/v1/.well-known/jwks.json. The project subdomain is not secret (it's in every public JWKS URL) — hard-coding it in the module is acceptable.
  • client_auth.audience: ["authenticated"].
  • api.port: 8080.

Nginx vhost lives in the same new module (mirroring how collabora.nix colocates its vhost): enableACME = true; forceSSL = true; acmeRoot = null;; locations."/" proxying to http://127.0.0.1:8080; proxyWebsockets = true; extraConfig setting proxy_read_timeout 3600s; proxy_send_timeout 3600s;. No Authelia ForwardAuth — powersync.reptide.eu is not in any Authelia access_control rule (those are scoped to *.cloonar.com), and the Flutter SDK couldn't satisfy a browser-cookie auth challenge anyway.

sops integration: sops.secrets.reptide-powersync-source-dsn references the entry the operator placed in hosts/web-arm/secrets.yaml via #37. restartUnits includes the podman service so secret rotation triggers a container restart.

blackbox-exporter probe: an additional entry against the new vhost added to the existing hosts/web-arm/modules/blackbox-exporter.nix. Probe interval and module match the existing entries.

No firewall changes: fw already forwards 443 to web-arm for the 30+ existing vhosts.

No reuse module under utils/modules/: PowerSync is single-tenant on web-arm only. If a second host ever needs PowerSync, extract then.

Testing Decisions

This is NixOS infrastructure config; "tests" mean dry-build + functional verification, not unit tests. Prior art for tests in this repo for OCI-container services: there isn't any beyond the pre-commit dry-build gate. collabora.nix, rustdesk.nix, and pa11y.nix all rely exclusively on the same workflow.

Dry-build gate (pre-commit hook):

  • git commit invokes scripts/pre-commitscripts/test-configuration web-armnixos-rebuild dry-build. The change must build clean before the commit lands. No --no-verify.

Functional verification after deploy (operator-driven, on web-arm):

  • systemctl status podman-powersync.service shows active (running), no recent restarts.
  • journalctl -u podman-powersync.service shows replication start, slot creation, initial backfill messages without errors.
  • On Supabase (operator runs): SELECT * FROM pg_replication_slots WHERE slot_name = 'powersync_selfhost'; shows active = t.
  • curl -sf https://powersync.reptide.eu/probes/liveness from anywhere returns 200.
  • The nginx vhost serves a valid lego-issued certificate.
  • blackbox-exporter scrape success visible in Prometheus / Grafana.
  • End-to-end: a debug Flutter build pointed at powersync.reptide.eu produces identical data to a build still on PowerSync Cloud (operator-driven, requires #37's prereqs done).

Out of Scope

  • Supabase-side role, publication, and DSN secret material (covered by #37).
  • Sync rules content authoring — agent mechanically copies the export the operator provides via #37.
  • The Flutter client release with the new URL — separate work in the cloonar-fit repo.
  • PowerSync Cloud account teardown — happens after parallel-run validation; tracked here as a closing step in the cutover plan but not part of the merged PR.
  • Deeper observability (Supabase-side replication-slot WAL-lag alerts) — follow-up after the basic blackbox liveness probe proves the pattern is working.
  • Daily compact / vacuum job for bucket storage — PowerSync docs recommend it at production scale, but at <100 clients we defer until growth on the storage DB is actually observed.
  • Remote-config-driven cutover (the rejected option (b) from the design discussion). The chosen plan is hard cutover via a Flutter release.
  • A reusable utils/modules/powersync.nix shared across hosts. PowerSync runs on web-arm only; if a second tenant ever needs it, extract then.

Further Notes

  • Design context was captured in a /grill-with-docs interview before this issue was created; that conversation walked through and resolved every decision branch (storage DB = Postgres on existing PG14, runtime = podman/oci-containers, image pinning = tag + digest with scripted bumps, auth = Supabase JWKS URI, hostname = powersync.reptide.eu, sync rules location, source replication setup, hard-cutover migration plan).
  • No ADR exists for this work yet. If the implementer or reviewer feels the architecture warrants one — particularly given it's the first PowerSync deployment in the repo and introduces a new external-database-replication relationship to Supabase — the natural location is docs/adr/0007-self-hosted-powersync-on-web-arm.md. Not a blocker for landing the PR.
  • The #1 self-hosted PowerSync footgun (per upstream docs and operator-folklore) is that an offline replication container silently bloats the Supabase WAL via the still-open slot. The blackbox liveness probe is the launch-blocking mitigation; the deeper slot-lag alert is the deferred follow-up. Don't ship without the probe.
  • Commit message convention for image bumps: chore(web-arm): bump powersync-service to <tag> matching the existing chore(web-arm): bump turn-runner to <rev> cadence.
**Blocked by #37** — do not begin implementation until that issue is closed. The artifacts produced there (Supabase role + publication, `reptide-powersync-source-dsn` sops secret, sync rules export, Flutter URL constant location) are inputs to the work described here. ## Problem Statement The Cloonar fit Flutter app currently syncs against PowerSync Cloud, paying a monthly subscription for a service that fits cleanly into the existing `web-arm` fleet host. The goal is to retire the Cloud dependency and run PowerSync Service ourselves, replicating from the same Supabase Postgres source, storing bucket state in `web-arm`'s existing Postgres 14 instance, and serving sync clients at `powersync.reptide.eu`. ## Solution Add a self-contained NixOS module to `web-arm` that runs `journeyapps/powersync-service` as a `virtualisation.oci-containers` (podman) container — matching the existing `collabora.nix` / `rustdesk.nix` / `pa11y.nix` pattern — with: - A new `powersync_storage` Postgres database + role on the existing PG14 instance. - An nginx vhost on `powersync.reptide.eu` with lego/ACME TLS and WebSocket-aware proxy config. - Sync rules mounted read-only from a versioned file copied out of the cloonar-fit repo (operator supplies content via #37). - The container image pinned by both tag and `sha256` digest, bumped via a scripted `update.sh` helper following the `turn-runner` cadence. - A blackbox-exporter probe on PowerSync's `/probes/liveness` endpoint. The Flutter SDK keeps using its existing Supabase user JWTs unchanged; PowerSync verifies them via Supabase's published JWKS URI. There is no shared HS256 secret to store and no client code change at the auth layer. ## User Stories 1. As an operator, I want PowerSync Service running on `web-arm` as a podman container under `virtualisation.oci-containers`, so that it slots into the existing systemd-managed container pattern without introducing a new runtime concept. 2. As an operator, I want the image pinned by tag and `sha256` digest, so that upgrades are deliberate PR-reviewed events and a surprise upstream re-tag can't ship to my fleet. 3. As an operator, I want an `update.sh` helper under `utils/pkgs/powersync-service/` that resolves Docker Hub's `:latest` (or a specified tag) to `<tag>@<sha256>` and rewrites the .nix file, so that bumps follow the same scripted shape as `turn-runner` bumps. 4. As an operator, I want PowerSync's bucket storage living in a dedicated `powersync_storage` database on the existing PG14 instance with its own `powersync_storage` role, so that backup (via `services.postgresqlBackup`), monitoring, and lifecycle all stay inside the existing Postgres operational story. 5. As an operator, I want `shared_buffers` raised from 80MB to 512MB on `web-arm`'s PG14 in the same change, so that PowerSync's bucket-heavy write workload doesn't thrash a tiny page cache. 6. As an operator, I want PowerSync's container port `8080` bound to `127.0.0.1` only, so that nginx is the sole ingress path and the service isn't reachable on the LAN even if a downstream firewall rule loosens. 7. As an operator, I want an nginx vhost on `powersync.reptide.eu` with `enableACME` + `forceSSL`, `proxyWebsockets = true`, and `proxy_read_timeout 3600s`, so that the Flutter SDK's long-lived WebSocket sync connections aren't killed by nginx's default 60s timeout. 8. As an operator, I want the Supabase replication DSN consumed from the sops secret `reptide-powersync-source-dsn` (created by #37), so that the agent never touches secret material directly. 9. As an operator, I want PowerSync configured with `client_auth.jwks_uri` pointing at the Supabase project's `/.well-known/jwks.json`, so that Flutter clients keep using their existing Supabase user JWTs unchanged through cutover. 10. As an operator, I want the sync rules YAML mounted read-only into the container, with a file-header comment naming the cloonar-fit source path, so that the relationship to the upstream is documented in-file rather than only in PR history. 11. As an operator, I want a blackbox-exporter probe added against `https://powersync.reptide.eu/probes/liveness`, so that a wedged or crash-looping container triggers alerts before the Supabase replication slot starts accumulating WAL on the source side. 12. As an operator, I want the new PowerSync module imported from `web-arm`'s `configuration.nix` in the same shape as existing `./modules/<service>.nix` imports, so that the host's import block stays consistent. 13. As an end user of the Cloonar fit app, I want no client-side change required on cutover other than a future Flutter URL release, so that the migration is invisible to my workflow until the rollout phase. 14. As a future maintainer reading the PR, I want the live PowerSync `service.yaml` derivable from a single visible source in the repo (rendered by Nix or kept as a static YAML in the module dir), so that the running config is auditable without `podman exec` into the container. 15. As a reviewer of bumps in the future, I want digest-pin changes to show up as a 2-line diff on a single .nix file, so that the PR is trivially reviewable and the change is obvious in `git blame`. ## Implementation Decisions **Module shape**: a single new module under the `web-arm` host (alongside `collabora.nix`, `rustdesk.nix`, etc.) that colocates the oci-containers definition, the rendered service config, the nginx vhost, the postgres role/database bootstrap, the sops secret reference, and the blackbox probe entry. The sync rules YAML lives as a sibling file inside the module directory and is mounted read-only. **Postgres storage bootstrap**: a new role `powersync_storage` (no replication, no superuser) and a new database `powersync_storage` owned by that role, both declared via `services.postgresql.ensureUsers` / `ensureDatabases`. PowerSync auto-migrates its schema on first startup — no manual DDL. **Postgres tuning**: bump `shared_buffers` from `"80MB"` to `"512MB"` in the existing `services.postgresql.settings` block on the host. This requires a Postgres restart, which `nixos-rebuild switch` performs automatically; note in the commit message because it briefly interrupts every PG-using service on the host. **Image pinning**: image string is `journeyapps/powersync-service:<tag>@sha256:<digest>` with both components pinned. No `--pull=newer`. The current latest stable tag should be selected at implementation time. Conservative tag policy: don't pick up major or minor bumps automatically; patch versions can be bumped via the helper script in a normal PR. **`update.sh` helper**: under `utils/pkgs/powersync-service/` (mirroring `utils/pkgs/<package>/update.sh` from the `turn-runner` and `claude-code` packages, even though this isn't a Nix derivation). It resolves a tag to `<tag>@<sha256>` via Docker Hub's manifest API and rewrites the image string in the module's .nix file. No actual derivation under that path — just the script. **Container config**: port `8080` bound to `127.0.0.1` only; sync rules and the rendered service.yaml mounted read-only; environment file from the sops secret containing `PS_SOURCE_DSN`; `extraOptions` consistent with the existing podman containers (no `--pull=newer`). **Service.yaml content** (rendered by `pkgs.writeText` with Nix interpolation for the JWKS URI; the DSN is read from env at runtime via `!env PS_SOURCE_DSN`): - `replication.connections[0].type: postgresql`, URI from env, `slot_name: powersync_selfhost`. - `storage.type: postgresql`, URI pointing at the local PG via unix socket against the new `powersync_storage` database. - `sync_rules.path` pointing at the mounted sync-rules.yaml. - `client_auth.jwks_uri` = `https://<supabase-project>.supabase.co/auth/v1/.well-known/jwks.json`. The project subdomain is not secret (it's in every public JWKS URL) — hard-coding it in the module is acceptable. - `client_auth.audience: ["authenticated"]`. - `api.port: 8080`. **Nginx vhost** lives in the same new module (mirroring how `collabora.nix` colocates its vhost): `enableACME = true; forceSSL = true; acmeRoot = null;`; `locations."/"` proxying to `http://127.0.0.1:8080`; `proxyWebsockets = true`; `extraConfig` setting `proxy_read_timeout 3600s; proxy_send_timeout 3600s;`. No Authelia ForwardAuth — `powersync.reptide.eu` is not in any Authelia `access_control` rule (those are scoped to `*.cloonar.com`), and the Flutter SDK couldn't satisfy a browser-cookie auth challenge anyway. **sops integration**: `sops.secrets.reptide-powersync-source-dsn` references the entry the operator placed in `hosts/web-arm/secrets.yaml` via #37. `restartUnits` includes the podman service so secret rotation triggers a container restart. **blackbox-exporter probe**: an additional entry against the new vhost added to the existing `hosts/web-arm/modules/blackbox-exporter.nix`. Probe interval and module match the existing entries. **No firewall changes**: `fw` already forwards `443` to `web-arm` for the 30+ existing vhosts. **No reuse module under `utils/modules/`**: PowerSync is single-tenant on `web-arm` only. If a second host ever needs PowerSync, extract then. ## Testing Decisions This is NixOS infrastructure config; "tests" mean dry-build + functional verification, not unit tests. Prior art for tests in this repo for OCI-container services: there isn't any beyond the pre-commit dry-build gate. `collabora.nix`, `rustdesk.nix`, and `pa11y.nix` all rely exclusively on the same workflow. **Dry-build gate** (pre-commit hook): - `git commit` invokes `scripts/pre-commit` → `scripts/test-configuration web-arm` → `nixos-rebuild dry-build`. The change must build clean before the commit lands. No `--no-verify`. **Functional verification after deploy** (operator-driven, on `web-arm`): - `systemctl status podman-powersync.service` shows `active (running)`, no recent restarts. - `journalctl -u podman-powersync.service` shows replication start, slot creation, initial backfill messages without errors. - On Supabase (operator runs): `SELECT * FROM pg_replication_slots WHERE slot_name = 'powersync_selfhost';` shows `active = t`. - `curl -sf https://powersync.reptide.eu/probes/liveness` from anywhere returns 200. - The nginx vhost serves a valid lego-issued certificate. - blackbox-exporter scrape success visible in Prometheus / Grafana. - **End-to-end**: a debug Flutter build pointed at `powersync.reptide.eu` produces identical data to a build still on PowerSync Cloud (operator-driven, requires #37's prereqs done). ## Out of Scope - Supabase-side role, publication, and DSN secret material (covered by #37). - Sync rules content authoring — agent mechanically copies the export the operator provides via #37. - The Flutter client release with the new URL — separate work in the cloonar-fit repo. - PowerSync Cloud account teardown — happens after parallel-run validation; tracked here as a closing step in the cutover plan but not part of the merged PR. - Deeper observability (Supabase-side replication-slot WAL-lag alerts) — follow-up after the basic blackbox liveness probe proves the pattern is working. - Daily compact / vacuum job for bucket storage — PowerSync docs recommend it at production scale, but at <100 clients we defer until growth on the storage DB is actually observed. - Remote-config-driven cutover (the rejected option (b) from the design discussion). The chosen plan is hard cutover via a Flutter release. - A reusable `utils/modules/powersync.nix` shared across hosts. PowerSync runs on `web-arm` only; if a second tenant ever needs it, extract then. ## Further Notes - Design context was captured in a `/grill-with-docs` interview before this issue was created; that conversation walked through and resolved every decision branch (storage DB = Postgres on existing PG14, runtime = podman/oci-containers, image pinning = tag + digest with scripted bumps, auth = Supabase JWKS URI, hostname = `powersync.reptide.eu`, sync rules location, source replication setup, hard-cutover migration plan). - No ADR exists for this work yet. If the implementer or reviewer feels the architecture warrants one — particularly given it's the first PowerSync deployment in the repo and introduces a new external-database-replication relationship to Supabase — the natural location is `docs/adr/0007-self-hosted-powersync-on-web-arm.md`. Not a blocker for landing the PR. - The **#1 self-hosted PowerSync footgun** (per upstream docs and operator-folklore) is that an offline replication container silently bloats the Supabase WAL via the still-open slot. The blackbox liveness probe is the launch-blocking mitigation; the deeper slot-lag alert is the deferred follow-up. Don't ship without the probe. - Commit message convention for image bumps: `chore(web-arm): bump powersync-service to <tag>` matching the existing `chore(web-arm): bump turn-runner to <rev>` cadence.
Author
Owner

This was generated by AI during triage.

Agent Brief — update (supersedes the source-connection guidance in the issue body)

This issue was written before two things now on main that change the source connection and the container networking. The prereq #37 is closed; the items below replace its DSN guidance and add a hard networking requirement. Everything else in the issue body still stands.

Category: enhancement (unchanged)

What changed since the body was written

1. The replication source must be Supabase's direct endpoint, not the pooler.
PowerSync replicates over the Postgres logical-replication protocol, which Supabase's pooler (Supavisor) cannot carry — it proxies ordinary SQL but rejects the replication handshake (IDENTIFY_SYSTEMsyntax error) while plain SELECT still succeeds. Confirmed empirically: IDENTIFY_SYSTEM returns a row against db.majxbigjafpzayzboxsf.supabase.co:5432 in replication mode, and syntax-errors through the pooler. This resolves the open risk #37's closing note handed to this issue.

  • The reptide-powersync-source-dsn sops secret must hold the direct DSN: postgresql://powersync_selfhost:<pw>@db.majxbigjafpzayzboxsf.supabase.co:5432/postgres?sslmode=require.
  • Do not put replication=database in the stored DSN — PowerSync opens its own replication connection and also uses the URI for normal queries; baking the flag in breaks the latter. (Operator pre-deploy step; the agent never edits secrets.)

2. The direct endpoint is IPv6-only, so the container must use the v6egress network.
db.<project>.supabase.co resolves to AAAA only. Host outbound IPv6 exists (ADR-0010) but does not reach default-bridge containers. PR #86 / ADR-0011 added an opt-in dual-stack podman network named v6egress (NAT66 from a ULA to the host GUA) created by a oneshot unit named init-v6egress-network.service. The PowerSync container must join that network and order after that unit, or it lands on the IPv4-only default bridge and silently fails to reach the source — the dry-build will not catch this.

  • Attach the container via its extraOptions with --network=v6egress.
  • Order the generated podman-powersync unit after + requires init-v6egress-network.service.
  • Publishing the API port to 127.0.0.1:8080 for nginx is independent of the egress network and still works (ingress vs egress are separate paths).

Added acceptance criteria (on top of the issue body's)

  • The rendered service config's source connection targets db.majxbigjafpzayzboxsf.supabase.co:5432 (direct), not a *.pooler.supabase.com host.
  • The stored DSN does not contain replication=database.
  • The PowerSync container is attached to the v6egress network and its unit is ordered after init-v6egress-network.service.
  • Post-deploy, from inside the running container, the source DSN reaches Supabase and PowerSync creates its logical-replication slot powersync_selfhost (pg_replication_slots.active = t on Supabase).

Unchanged / still in scope

Everything else in the issue body: the podman/oci-containers module shape, the powersync_storage DB + role, the shared_buffers 80MB→512MB bump, the nginx vhost on powersync.reptide.eu (WebSocket + 3600s timeouts), image tag+digest pinning with the update.sh helper, JWKS auth via the Supabase project, the read-only sync-rules mount, and the blackbox liveness probe.

Out of scope (unchanged)

Supabase-side role/publication/secret material (#37, done), sync-rules authoring, the Flutter cutover release, and PowerSync Cloud teardown.

> *This was generated by AI during triage.* ## Agent Brief — update (supersedes the source-connection guidance in the issue body) This issue was written before two things now on `main` that change the source connection and the container networking. The prereq #37 is closed; the items below replace its DSN guidance and add a hard networking requirement. **Everything else in the issue body still stands.** **Category:** enhancement (unchanged) ### What changed since the body was written **1. The replication source must be Supabase's _direct_ endpoint, not the pooler.** PowerSync replicates over the Postgres logical-replication protocol, which Supabase's pooler (Supavisor) cannot carry — it proxies ordinary SQL but rejects the replication handshake (`IDENTIFY_SYSTEM` → `syntax error`) while plain `SELECT` still succeeds. Confirmed empirically: `IDENTIFY_SYSTEM` returns a row against `db.majxbigjafpzayzboxsf.supabase.co:5432` in replication mode, and syntax-errors through the pooler. This resolves the open risk #37's closing note handed to this issue. - The `reptide-powersync-source-dsn` sops secret must hold the **direct** DSN: `postgresql://powersync_selfhost:<pw>@db.majxbigjafpzayzboxsf.supabase.co:5432/postgres?sslmode=require`. - **Do not** put `replication=database` in the stored DSN — PowerSync opens its own replication connection and also uses the URI for normal queries; baking the flag in breaks the latter. (Operator pre-deploy step; the agent never edits secrets.) **2. The direct endpoint is IPv6-only, so the container must use the `v6egress` network.** `db.<project>.supabase.co` resolves to AAAA only. Host outbound IPv6 exists (ADR-0010) but does **not** reach default-bridge containers. PR #86 / ADR-0011 added an opt-in dual-stack podman network named **`v6egress`** (NAT66 from a ULA to the host GUA) created by a oneshot unit named **`init-v6egress-network.service`**. The PowerSync container **must** join that network and order after that unit, or it lands on the IPv4-only default bridge and silently fails to reach the source — the dry-build will not catch this. - Attach the container via its `extraOptions` with `--network=v6egress`. - Order the generated `podman-powersync` unit `after` + `requires` `init-v6egress-network.service`. - Publishing the API port to `127.0.0.1:8080` for nginx is independent of the egress network and still works (ingress vs egress are separate paths). ### Added acceptance criteria (on top of the issue body's) - [ ] The rendered service config's source connection targets `db.majxbigjafpzayzboxsf.supabase.co:5432` (direct), not a `*.pooler.supabase.com` host. - [ ] The stored DSN does **not** contain `replication=database`. - [ ] The PowerSync container is attached to the `v6egress` network and its unit is ordered after `init-v6egress-network.service`. - [ ] Post-deploy, from inside the running container, the source DSN reaches Supabase and PowerSync creates its logical-replication slot `powersync_selfhost` (`pg_replication_slots.active = t` on Supabase). ### Unchanged / still in scope Everything else in the issue body: the podman/oci-containers module shape, the `powersync_storage` DB + role, the `shared_buffers` 80MB→512MB bump, the nginx vhost on `powersync.reptide.eu` (WebSocket + 3600s timeouts), image tag+digest pinning with the `update.sh` helper, JWKS auth via the Supabase project, the read-only sync-rules mount, and the blackbox liveness probe. ### Out of scope (unchanged) Supabase-side role/publication/secret material (#37, done), sync-rules authoring, the Flutter cutover release, and PowerSync Cloud teardown.
dominik.polakovics 2026-06-03 23:33:00 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos#38
No description provided.