feat(web-arm): self-host PowerSync Service for Cloonar fit #87

Merged
dominik.polakovics merged 1 commit from afk/38 into main 2026-06-03 23:33:00 +02:00

Summary

Retires PowerSync Cloud for the Cloonar fit app by self-hosting
journeyapps/powersync-service on web-arm as a podman oci-container, in the
same shape as collabora.nix / rustdesk.nix. Implements #38 (the #37 operator
prereqs are done — the reptide-powersync-source-dsn secret is in place).

New self-contained module hosts/web-arm/modules/powersync/:

  • Container pinned by tag and sha256 digest (1.21.0@sha256:66e7d37…), on
    the v6egress network so it can reach the Supabase direct (IPv6-only)
    endpoint; ordered after init-v6egress-network.service.
  • Replication from the Supabase direct DSN (sops reptide-powersync-source-dsn,
    injected as PS_SOURCE_DSN via a sops template), verify-full TLS (PowerSync
    bundles Supabase's CA), slot prefix powersync_selfhost.
  • Bucket storage in a new powersync_storage DB/role on the local PG14, added
    to services.postgresqlBackup.
  • nginx vhost powersync.reptide.eu (lego DNS-01 via the fueltide token,
    forceSSL, proxyWebsockets, 3600s timeouts); 8080 bound to 127.0.0.1 only.
  • Supabase-JWKS client auth — Flutter keeps its existing JWTs unchanged.
  • blackbox probe against /probes/liveness (the open-slot WAL-bloat guard).
  • utils/pkgs/powersync-service/update.sh to bump the digest pin (1-line diff).
  • shared_buffers 80MB → 512MB (restarts PG on apply, briefly interrupting
    every PG-using service).
  • ADR-0012.

Sync rules copied byte-for-byte from #37 into modules/powersync/sync-rules.yaml.

Notable deviations from the issue spec (forced by PowerSync)

  • The issue specified passwordless storage over a unix socket. PowerSync's
    Postgres layer supports neither — it connects over TCP, rejects ports < 1024,
    and throws on an empty password. So storage uses a TCP connection over the
    v6egress bridge gateway (10.89.0.1), authorised by a pg_hba trust rule
    scoped to exactly the powersync_storage db+role from the v6egress subnet
    (5432 stays closed to the internet). The URI password is a non-secret
    placeholder. This needed enableTCPIP + a scoped iptables allow. Hardening to
    a generated-password + scram rule is a noted follow-up in ADR-0012.
  • The config field is slot_name_prefix, not slot_name, so the live Supabase
    slot is powersync_selfhost<suffix> — verify with
    … WHERE slot_name LIKE 'powersync_selfhost%'.
  • The blackbox blacklist + liveness scrape are colocated in the module
    (list-merge) rather than editing blackbox-exporter.nix, to keep the module
    self-contained.

Verification

  • Pre-commit dry-build: web-arm OK (all hosts OK).

Operator functional checks after deploy (per #38):

  • systemctl status podman-powersync active; journal shows replication start +
    slot creation without errors.
  • curl -sf https://powersync.reptide.eu/probes/liveness → 200, valid cert.
  • On Supabase, the powersync_selfhost% replication slot is active.
  • If replication hits a TLS error, switch the source sslmode to verify-ca
    with the Supabase CA (PS_PG_CA_CERT).
  • Confirm reptide-powersync-source-dsn holds the raw direct DSN (no
    replication=database).

Closes #38

## Summary Retires PowerSync Cloud for the Cloonar fit app by self-hosting `journeyapps/powersync-service` on web-arm as a podman oci-container, in the same shape as collabora.nix / rustdesk.nix. Implements #38 (the #37 operator prereqs are done — the `reptide-powersync-source-dsn` secret is in place). New self-contained module `hosts/web-arm/modules/powersync/`: - Container pinned by tag **and** sha256 digest (`1.21.0@sha256:66e7d37…`), on the **v6egress** network so it can reach the Supabase **direct** (IPv6-only) endpoint; ordered after `init-v6egress-network.service`. - Replication from the Supabase direct DSN (sops `reptide-powersync-source-dsn`, injected as `PS_SOURCE_DSN` via a sops template), `verify-full` TLS (PowerSync bundles Supabase's CA), slot prefix `powersync_selfhost`. - Bucket storage in a new `powersync_storage` DB/role on the local PG14, added to `services.postgresqlBackup`. - nginx vhost `powersync.reptide.eu` (lego DNS-01 via the fueltide token, forceSSL, proxyWebsockets, 3600s timeouts); 8080 bound to `127.0.0.1` only. - Supabase-JWKS client auth — Flutter keeps its existing JWTs unchanged. - blackbox probe against `/probes/liveness` (the open-slot WAL-bloat guard). - `utils/pkgs/powersync-service/update.sh` to bump the digest pin (1-line diff). - `shared_buffers` 80MB → 512MB (restarts PG on apply, briefly interrupting every PG-using service). - ADR-0012. Sync rules copied **byte-for-byte** from #37 into `modules/powersync/sync-rules.yaml`. ## Notable deviations from the issue spec (forced by PowerSync) - The issue specified **passwordless storage over a unix socket**. PowerSync's Postgres layer supports neither — it connects over TCP, rejects ports < 1024, and throws on an empty password. So storage uses a **TCP** connection over the v6egress bridge gateway (`10.89.0.1`), authorised by a `pg_hba` **trust** rule scoped to exactly the `powersync_storage` db+role from the v6egress subnet (5432 stays closed to the internet). The URI password is a non-secret placeholder. This needed `enableTCPIP` + a scoped iptables allow. Hardening to a generated-password + `scram` rule is a noted follow-up in ADR-0012. - The config field is `slot_name_prefix`, not `slot_name`, so the live Supabase slot is `powersync_selfhost<suffix>` — verify with `… WHERE slot_name LIKE 'powersync_selfhost%'`. - The blackbox blacklist + liveness scrape are colocated in the module (list-merge) rather than editing `blackbox-exporter.nix`, to keep the module self-contained. ## Verification - Pre-commit dry-build: **web-arm OK** (all hosts OK). Operator functional checks after deploy (per #38): - `systemctl status podman-powersync` active; journal shows replication start + slot creation without errors. - `curl -sf https://powersync.reptide.eu/probes/liveness` → 200, valid cert. - On Supabase, the `powersync_selfhost%` replication slot is active. - If replication hits a TLS error, switch the source `sslmode` to `verify-ca` with the Supabase CA (`PS_PG_CA_CERT`). - Confirm `reptide-powersync-source-dsn` holds the **raw** direct DSN (no `replication=database`). Closes #38
Replace PowerSync Cloud with journeyapps/powersync-service running as a
podman oci-container on web-arm (#38). It replicates from the Supabase
project's direct (IPv6-only) Postgres endpoint over the v6egress network,
stores buckets in a new powersync_storage DB on the local PG14, and serves
clients at https://powersync.reptide.eu via WebSocket-aware nginx + lego TLS.
Flutter clients authenticate with their existing Supabase JWTs through the
project JWKS endpoint (no shared secret, no client change).

The image is pinned by tag + sha256 digest, bumped via
utils/pkgs/powersync-service/update.sh. A blackbox probe watches
/probes/liveness to catch a wedged container before its open replication
slot bloats the Supabase WAL. Sync rules are mounted byte-for-byte from #37.

Storage connects over TCP (PowerSync has no unix-socket support and requires
a non-empty password), scoped via a pg_hba trust rule to the powersync_storage
db+role from the v6egress subnet only; see ADR-0012. Raises web-arm PG14
shared_buffers 80MB -> 512MB, which restarts PostgreSQL and briefly
interrupts every PG-using service on the host.

Closes #38
Author
Owner

This was generated by AI while landing a PR.

Landed via /land-pr. Verdict: PASS.

Signal relied on: the repo's commit-time pre-commit dry-build (eval-only; the PR body attests "web-arm OK / all hosts OK"), supplemented by static review of what that gate can't see.

Checked:

  • Conventions — Conventional Commits title, single clean afk/38 commit, Closes #38 present, no secrets.yaml/stateVersion change, module imported by explicit path, update.sh present.
  • Eval-gate blind spots, verified by hand — storage URI 10.89.0.1:5432 and the firewall 10.89.0.0/24 both match v6egress.nix's --subnet=10.89.0.0/24; blacklistDomains, the http_200_final blackbox module, and victoriametrics.extraScrapeConfigs all exist; both sops keys are present in hosts/web-arm/secrets.yaml; the image is a runtime OCI digest pin (no vendorHash-class build risk).
  • Spec deviations — all forced by PowerSync and documented in ADR-0012's "Notable deviations" section: TCP+trust storage vs. the spec's unix-socket/passwordless, slot_name_prefix vs. slot_name, the added v6egress-scoped firewall rule, ADR renumber 0007→0012.

Runtime-only, deferred to #38's post-deploy checklist (no static gate can prove these): the Supabase replication handshake, the v6 egress path, JWT verification, the lego cert, and the start -r unified invocation. PG-on-TCP behind a subnet-scoped trust rule is a conscious tradeoff, with scram hardening noted as an ADR-0012 follow-up.

Merging with --style merge; CD (deploy action → SFTP chroot → bento-upgrade nixos-rebuild switch within ~5 min) takes it from here.

> *This was generated by AI while landing a PR.* **Landed via `/land-pr`. Verdict: PASS.** **Signal relied on:** the repo's commit-time pre-commit dry-build (eval-only; the PR body attests "web-arm OK / all hosts OK"), supplemented by static review of what that gate can't see. **Checked:** - Conventions — Conventional Commits title, single clean `afk/38` commit, `Closes #38` present, no `secrets.yaml`/`stateVersion` change, module imported by explicit path, `update.sh` present. - Eval-gate blind spots, verified by hand — storage URI `10.89.0.1:5432` and the firewall `10.89.0.0/24` both match `v6egress.nix`'s `--subnet=10.89.0.0/24`; `blacklistDomains`, the `http_200_final` blackbox module, and `victoriametrics.extraScrapeConfigs` all exist; both sops keys are present in `hosts/web-arm/secrets.yaml`; the image is a runtime OCI digest pin (no vendorHash-class build risk). - Spec deviations — all forced by PowerSync and documented in ADR-0012's "Notable deviations" section: TCP+`trust` storage vs. the spec's unix-socket/passwordless, `slot_name_prefix` vs. `slot_name`, the added v6egress-scoped firewall rule, ADR renumber 0007→0012. **Runtime-only, deferred to #38's post-deploy checklist** (no static gate can prove these): the Supabase replication handshake, the v6 egress path, JWT verification, the lego cert, and the `start -r unified` invocation. PG-on-TCP behind a subnet-scoped `trust` rule is a conscious tradeoff, with scram hardening noted as an ADR-0012 follow-up. Merging with `--style merge`; CD (deploy action → SFTP chroot → `bento-upgrade` `nixos-rebuild switch` within ~5 min) takes it from here.
Sign in to join this conversation.
No reviewers
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!87
No description provided.