Zero to Production: Part 3 - The Sync Layer: PowerSync in the Wild
With the self-hosted Supabase instance locked down and tuned for logical replication, the data layer is secure. An offline-first architecture requires more than just a database—it requires a synchronization engine capable of streaming changes instantly to client devices.
For the current stack, the best engine for this purpose is PowerSync. It operates by reading the Supabase PostgreSQL Write-Ahead Log (WAL), instantly detecting data mutations, and pushing those deltas down to a local SQLite database running on the client device.
Integrating this into the current infrastructure presented a massive architectural crossroads, and taking the wrong path here can easily compromise the entire system design.
In this part, we will go over the service integration, bridging the internal Docker networks, and trample over undocumented deployment traps.
The Crossroads: Cloud vs. Self-Hosted
Initially, I planned to connect the newly provisioned Supabase instance to PowerSync’s managed Cloud platform to take advantage of their beautiful graphical interface and to simplify deployment. I quickly hit a hard blocker: PowerSync Cloud strictly requires PostgreSQL connections to be secured via SSL (sslmode: verify-ca).
Coolify’s Supabase template, however, does not enable SSL on Postgres by default. This left me with two options:
Option A: Force SSL for the Cloud UI (The Fragile Path)
I could manually generate a self-signed Certificate Authority, edit the Coolify Docker Compose file to mount those certificates into the Postgres container, and append -c ssl=on to the startup command.
But this is a maintenance nightmare. If the SSL file permissions aren't strictly set, Postgres will crash and bring down the whole stack. Furthermore, a future Coolify update could overwrite my custom compose file, breaking the connection. In addition, I need to login to the container and update periodically when the certificate expires.
Option B: Self-Host PowerSync via Coolify (The Bulletproof Path)
Instead of sending database traffic across the public internet, I could deploy the PowerSync engine as a Docker container directly alongside Supabase within Coolify.
This will ensure zero latency and ultimate security. The engine and the database communicate strictly over Docker's internal, isolated virtual network. No SSL certificates are needed, and the database port (5432) remains safely hidden behind the Oracle firewall, which reduces exposure to botnets and malicious actors.
So I chose Option B.
Phase 1: The Architecture & Single-File Magic
The self-hosted PowerSync stack actually consists of three distinct containers:
- The PowerSync Engine: The core Node.js application.
- MongoDB Cache: PowerSync requires a MongoDB database to act as its internal state cache and tracking layer.
- MongoDB Replica Set Init: A short-lived script that configures Mongo to run as a replica set, which is required for PowerSync to utilize Mongo's change streams.
Normally, configuring this requires managing multiple external YAML files on the docker container (service.yaml for engine settings, and sync-config.yaml for sync rules). However, Coolify has a brilliant feature that helps us avoid this complexity: Inline Bind Mounts (content: |).
This allows us to bake our configuration files directly into the Docker Compose template itself. These mounted files can also be edited directly through the Persistent Storage section inside the Coolify service configuration page. Now in your Coolify dashboard, create a new Docker Compose resource named PowerSync Engine and paste the following configuration:
services:
mongo:
image: mongo:7.0
command: --replSet rs0 --bind_ip_all --quiet
restart: unless-stopped
networks:
- coolify
volumes:
- mongo_storage:/data/db
# We add a healthcheck to ensure Mongo is actually ready
healthcheck:
test: ["CMD-SHELL", "mongosh --eval 'db.adminCommand(\"ping\")' || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mongo-rs-init:
image: mongo:7.0
depends_on:
- mongo
restart: on-failure
networks:
- coolify
entrypoint:
- bash
- -c
- 'mongosh --host mongo:27017 --eval ''try{rs.status().ok && quit(0)} catch {} rs.initiate({_id: "rs0", version: 1, members: [{ _id: 0, host : "mongo:27017" }]})'''
powersync:
image: journeyapps/powersync-service:latest
container_name: powersync
depends_on:
- mongo-rs-init
command: [ "start", "-r", "unified"]
restart: unless-stopped
environment:
- NODE_OPTIONS="--max-old-space-size=1000"
- POWERSYNC_CONFIG_PATH=/home/config/service.yaml
- PS_DATABASE_TYPE=postgresql
- PS_PORT=8080
- PS_MONGO_URI=${PS_MONGO_URI}
networks:
- coolify
- supabase_private
volumes:
- type: bind
source: ./volumes/config/sync-config.yaml
target: /home/config/sync-config.yaml
# INLINE MOUNT: Your sync rules live here
content: |
config:
edition: 3
streams:
user_tasks:
auto_subscribe: true
queries:
- SELECT * FROM tasks WHERE user_id = auth.user_id()
source: ./volumes/config/service.yaml
target: /home/config/service.yaml
# INLINE MOUNT: Your engine configuration lives here
content: |
telemetry: disable_telemetry_sharing: true
replication:
connections:
- type: !env PS_DATABASE_TYPE
uri: !env PS_BACKEND_DATABASE_URI
sslmode: disable
storage:
type: mongodb
uri: !env PS_MONGO_URI
port: !env PS_PORT
sync_config:
path: /home/config/sync-config.yaml
client_auth:
supabase: true
jwks_uri: !env PS_JWKS_URL
audience: ["authenticated"]
api:
tokens:
- auth_admin_token_placeholder
volumes:
mongo_storage:
networks:
coolify:
external: true
supabase_private:
external: true
name: <YOUR_SUPABASE_NETWORK_ID>Phase 2: Bridging the Network Gap
If you inspect the networks block for the powersync container above, you will notice it is attached to two distinct virtual networks: coolify and supabase_private.
This is a critical and important architectural step. By design, Coolify implements strict network isolation. Any database deployed without a public URL is placed on a completely isolated, private Docker network (named after the deployment's unique ID). If you only attach PowerSync to the standard coolify network, it will return a getaddrinfo EAI_AGAIN error trying to connect to the Supabase database, because it literally cannot see the Supabase database.
To build the bridge, you must find your specific database's secret network ID.
- SSH into the Oracle server.
- Run
docker network ls. - Look for the network associated with theSupabase deployment (it will be a long string, like
sds5j3k89dfgv354nbvjh3jbvop). - Replace
<YOUR_SUPABASE_NETWORK_ID>in the compose file with that exact string.
Now, PowerSync straddles both networks. It can receive public API requests via the coolify network, and securely query Postgres via the supabase_private network.
Phase 3: Environment Variables & The S1105 Trap
With the code in place, navigate to the Environment Variables tab for the new PowerSync resource in Coolify.
First, set PS_BACKEND_DATABASE_URI to postgresql://powersync_role:<PASSWORD>@supabase-db-<ID>:5432/postgres. Ensure the hostname matches the exact Supabase Postgres container name in Coolify.
Second, set PS_JWKS_URL to https://supabase.<domain.com>/auth/v1/user/jwt/jwks. This connects PowerSync to the live Supabase Auth instance to cryptographically validate user login tokens. So we do not require a separate auth service when using Powersync, it will reuse the authorization already configured in Supabase.
Finally, set PS_MONGO_URI to mongodb://mongo:27017/powersync.
Phase 4: Debugging the Deployment
Deploying complex infrastructure rarely works flawlessly on the first click. These are the hurdles I hit and how I bypassed them.
1. The YAML Indentation Crash
Docker is ruthlessly strict about YAML formatting. If the deployment logs show Failed to update sync config 'streams' are required, the indentation in the content: | inline bind mount is off. If the spacing is incorrect, Docker mounts a blank file, causing Powersync to panic. The Coolify persistent storage editor is also a bit finnicky to use as it does not support the Tab key for tab spaces and I had to manually enter spaces for the correct indentation.
2. The "Degraded" False Alarm
After a successful deployment, Coolify's dashboard flagged my PowerSync container as Running (unhealthy) and marked the stack as Degraded. The logs, however, showed the engine running perfectly.
The real culprit was Docker Health checks. It is standard practice to add a curl command to a Docker file to ping a web container and ensure it is responding. However, the PowerSync Docker image is built on a highly secure, stripped-down Linux base image to reduce its attack surface. It does not have curl installed. When Docker attempted to run the curl healthcheck, it received a "Command not found" error and flagged the container as unhealthy. The fix was simply to remove the health check block from the PowerSync container in the compose file (as reflected in the YAML above).
Phase 5: Routing, SSL, and the Cloudflare Trap
To make the sync engine accessible to the internet, we need to expose it securely to the internet.
In Coolify, navigate to the specific powersync service settings and enter your domain. However, because PowerSync listens internally on port 8080, Traefik (Coolify's internal proxy) needs an explicit instruction, otherwise you will receive a 502 Bad Gateway error.
- The Correct Domain Format:
https://powersync.<domain.com>:8080
The Final Liveness Test
With the network bridged, the databases connected, and the SSL certificate active, it is time to verify the engine.
Instead, navigate to the official liveness probe:
https://powersync.<domain.com>/probes/liveness
If the configuration is correct, you will be greeted by a clean JSON response:
{"ready":true,"started":true,"touched_at":"2026-06-07T17:53:38.655Z"}Our sync engine has successfully discovered the Supabase schema, locked onto the replication slot, and is now actively listening for websocket connections.
The core data pipeline is officially complete. Next up, we have to tackle the storage bottleneck: Evaluating object storage solutions and pivoting away from Supabase MinIO.
Next Post: Part 4 - Rethinking Object Storage: The Garage Pivot