This Helm chart deploys PowerSync services on a Kubernetes cluster.
- Kubernetes 1.21+
- Helm 3.0+ (Helm 4 also supported and verified by the local test pipeline — see Local testing)
- A bucket-storage database (MongoDB or Postgres) and a source database
- An NGINX-compatible Ingress controller (or any L7 controller with HTTP/2 + WebSockets)
- For autoscaling on
powersync_concurrent_connections: Prometheus + prometheus-adapter, or KEDA
# Copy the default values and customize them
cp values.yaml my-values.yaml
# Edit my-values.yaml with your specific configuration
helm install powersync ./powersync-helm-chart -f my-values.yaml| Workload | Kind | Notes |
|---|---|---|
*-api |
Deployment | Sync API. Behind a Service + Ingress. HPA + PDB. |
*-replication |
Deployment | Warm-standby (2 replicas) — only one actively replicates; the other waits for the replication lock and takes over on failure. PDB + anti-affinity included. |
*-compact |
CronJob | Daily bucket-storage compaction. |
*-migrate |
Job | Pre-install/pre-upgrade hook running migrate up. |
*-config |
Secret | Renders powersyncConfig to JSON. |
*-sync-streams |
ConfigMap | Sync Stream definitions (edition 3 sync config). |
* Ingress |
Ingress | TLS + NGINX streaming annotations. |
*-api HPA |
HPA | Scales on powersync_concurrent_connections + CPU. |
*-api PDB |
PodDisruptionBudget | minAvailable: 1. |
*-api NetworkPolicy |
NetworkPolicy | Optional, off by default. |
Numbers and constraints from the PowerSync deployment-architecture docs. The chart defaults follow these.
| Pod | Limit | What happens at the limit |
|---|---|---|
| API | ~100 target / 200 hard cap concurrent client connections per pod | Pod returns error code PSYNC_S2304 (max concurrent connections reached). Scale out before you hit 200. |
| Replication | 1 active replicator at any time (warm-standby pattern, default 2 pods) | The lock holder actively replicates; the standby pod blocks on PSYNC_S1003 and takes over instantly if the leader exits. Mirrors the PowerSync Cloud paid-plan default. Older service versions log noisily on the standby — set replication.replicas: 1 + strategy: Recreate if needed. |
| Component | Replicas | Memory (req / limit) | CPU | Notes |
|---|---|---|---|---|
| API | 2+ (HPA) | 1Gi / 2Gi | 1 vCPU | Stateless. Scale horizontally on connection count. |
| Replication | 2 (1 active + 1 warm standby) | 1Gi / 2Gi | 1 vCPU | Lock arbitration ensures only one replicates at a time. Standby is idle (low CPU) but takes over instantly on leader failure. |
| Compact (CronJob) | 1× daily | 512Mi / 1Gi | 100m / 1 | Off-peak. |
| Bucket storage (Postgres) | 3 (1 primary + 2 replicas) | 2Gi+ | 1+ vCPU | Out of scope for this chart — deploy via CloudNativePG. |
Scaling rules:
- API → add 1 pod per ~100 concurrent client connections. HPA does this automatically using
powersync_concurrent_connections. - Replication → vertical only. Scales with source-database write throughput, not client count.
- For larger rows / heavier load, double API + replication to 2Gi / 2 vCPU.
NODE_OPTIONS=--max-old-space-size-percentage=80 — the V8 old-generation heap is sized as 80% of the container memory limit, so it auto-tracks resources.limits.memory. No manual recalculation when you change limits. (Older PowerSync deployment guides hard-code --max-old-space-size=800 for a 1Gi container; the percentage flag is the same idea, just dynamic.)
A single PowerSync instance (one replicator + horizontally-scaled API pods) can handle roughly 50,000–100,000 concurrent client connections, depending on the size of the rows being synced from the source database. Beyond that, configure a second instance — a separate deployment with its own bucket-storage database, sharing the same source DB.
Critical client-side constraint: each instance maintains its own copy of the bucket data, so a client must always connect to the same instance every time. Switching instances forces a full resync from scratch. Multiple instances cannot be load-balanced behind the same subdomain. Recommended routing: have the client fetch its endpoint from your backend (or compute it deterministically, e.g. hash(user_id) % n) and pin it.
This chart deploys one instance; for multi-instance, install the chart multiple times under different release names, ingress hosts, and bucket-storage configs.
Defaults use file-system probes (MICRO_PROBE_TYPE=fs, reading /app/.probes/{startup,poll,ready}). HTTP equivalents are also available on port 8080:
| Probe | HTTP path | File path |
|---|---|---|
| Startup | GET /probes/startup |
/app/.probes/startup |
| Liveness | GET /probes/liveness |
/app/.probes/poll |
| Readiness | GET /probes/readiness |
/app/.probes/ready |
To switch to HTTP probes, set env.MICRO_PROBE_TYPE: "http" and edit the deployment templates.
Exposed on container port 9464. Enable in powersyncConfig.telemetry. Key metrics:
| Metric | Type | Use |
|---|---|---|
powersync_concurrent_connections |
Gauge | HPA scaling signal; alert when nearing 200/pod |
powersync_replication_lag_seconds |
Gauge | Alert when lag spikes |
powersync_operations_synced_total |
Counter | Sync throughput |
powersync_data_synced_bytes_total |
Counter | Egress volume (uncompressed) |
powersync_data_sent_bytes_total |
Counter | Egress volume (compressed) |
powersync_data_replicated_bytes_total |
Counter | Source-DB → bucket-storage volume |
powersync_rows_replicated_total |
Counter | Replication throughput |
powersync_transactions_replicated_total |
Counter | Replication throughput |
powersync_replication_storage_size_bytes |
Gauge | Bucket-storage growth |
powersync_operation_storage_size_bytes |
Gauge | Bucket-storage growth |
| From | To | Protocol | Why |
|---|---|---|---|
| Client | Ingress → API | HTTPS (long-lived) | Streaming API — needs proxy-buffering: off |
| API pods | Bucket-storage DB | TCP | Read materialised buckets |
| API pods | JWKS endpoint | HTTPS (egress) | Verify client JWTs |
| Replication pod | Source DB | TCP (logical replication / oplog) | CDC stream |
| Replication pod | Bucket-storage DB | TCP | Write materialised buckets |
| Compact CronJob | Bucket-storage DB | TCP | Daily compaction |
The chart ships with an HPA disabled by default. Enable with:
api:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetConnectionsPerPod: 100
targetCPUUtilizationPercentage: 70The HPA reads the powersync_concurrent_connections Prometheus gauge as a Pods-type metric. To make this metric visible to Kubernetes, deploy prometheus-adapter with a rule that maps the metric to the custom.metrics.k8s.io API. Sample rule:
rules:
custom:
- seriesQuery: 'powersync_concurrent_connections{namespace!="",pod!=""}'
resources:
overrides:
namespace: { resource: namespace }
pod: { resource: pod }
name:
matches: "^(.*)$"
as: "$1"
metricsQuery: 'avg_over_time(<<.Series>>{<<.LabelMatchers>>}[2m])'Verify it's working:
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/<ns>/pods/*/powersync_concurrent_connections"Alternatively, use KEDA's prometheus scaler — set autoscaling.enabled: false and define a ScaledObject outside this chart.
The chart sets these NGINX annotations by default — without them, HTTP streaming sync breaks:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"Use a dedicated subdomain for the API (e.g. powersync.example.com). PowerSync cannot share a subdomain with another service.
powersyncConfig is rendered into a Kubernetes Secret. Storing real credentials in values.yaml is fine for a demo, but for production prefer:
- Helm
--set-filefor individual values - Sealed Secrets
- External Secrets Operator backed by Vault, AWS Secrets Manager, etc.
The chart's example client_auth.jwks uses a shared-secret HS256 key for demo purposes only. For production, use asymmetric keys (RS256, EdDSA, ECDSA) via jwks_uri.
powersyncConfig.migrations.disable_auto_migration: true is set by default. The *-migrate Job runs migrate up as a Helm pre-install and pre-upgrade hook so migrations execute before pods start. To disable, set migration.enabled: false.
| Parameter | Description | Default |
|---|---|---|
namespace |
Kubernetes namespace | powersync-poc |
image.repository |
Image repository | journeyapps/powersync-service |
image.tag |
Image tag | 1.20.5 |
api.replicas |
API replicas (when HPA disabled) | 2 |
api.autoscaling.enabled |
Enable HPA | false |
replication.replicas |
Replication pods (warm-standby pattern) | 2 |
replication.podAntiAffinity.enabled |
Spread replication pods across nodes | true |
api.autoscaling.targetConnectionsPerPod |
HPA target | 100 |
api.pdb.enabled |
Enable PodDisruptionBudget | true |
compact.enabled |
Enable daily compact CronJob | true |
compact.schedule |
Cron schedule | 0 3 * * * |
migration.enabled |
Run migrate-up as a Helm hook | true |
networkPolicy.enabled |
Restrict API ingress | false |
ingress.host |
Ingress hostname | YOUR_FQDN_HERE.example.com |
The repo ships with a self-contained pipeline that lints and renders the chart, deploys it to a local kind cluster against in-cluster MongoDB fixtures, and runs assertions against the result. Use it before any chart change.
| Tool | Purpose | Install |
|---|---|---|
helm (v4+) |
Chart lint/render/install | brew install helm |
kind |
Local Kubernetes cluster | brew install kind |
kubectl |
Cluster client | brew install kubernetes-cli |
kubeconform |
K8s schema validation | brew install kubeconform |
yq |
YAML parsing | brew install yq |
jq |
JSON parsing | usually preinstalled |
| Docker | Container runtime for kind | Docker Desktop / OrbStack |
One-liner:
brew install helm kind kubernetes-cli kubeconform yq jq./test/setup-fixtures.sh # idempotent — kind + ingress-nginx + 2× MongoDB + TLS secret (~3 min)
./test/run.sh --audit # full pipeline with evidence per assertion
./test/teardown.sh # delete the kind cluster when finished| Stage | What it does |
|---|---|
| Static | helm lint, helm template -f test/values-test.yaml, kubeconform -strict on the rendered manifests |
| Deploy | helm upgrade --install against the kind cluster, waits for rollout, captures pod state on failure |
| Smoke | Runs every assertion declared in test/assertions.yaml (deployment readiness, file probes, listening ports, migrate Job success, replication leader election, initial replication + streaming, kubeconform, built-in helm test) |
./test/run.sh # quiet PASS/FAIL summary
./test/run.sh --audit # verbose evidence per assertion
./test/run.sh --only=static # static stage only (no cluster needed)
./test/run.sh --only=smoke # re-check assertions against the current install
./test/run.sh --self-test # failure-injection battery — proves the pipeline catches what it claims--self-test runs each scenario in test/injections/*.yaml in an isolated git worktree and confirms the right stage/assertion catches each break (template typo, bad memory limit, mismatched ConfigMap name, bad source-DB URI, wrong migrate args, renamed metrics port, bad image tag).
Every run dumps evidence to test/.last-run/ (gitignored):
test/.last-run/
├── stages/ # structured JSON per stage
│ ├── 1-static.json
│ ├── 2-deploy.json
│ └── 3-smoke.json
├── rendered/all.yaml # helm template output
├── events.log # cluster events
├── workload.summary # pods/deploys/jobs + leader/standby IDs
└── pods/
├── api/ # one log file per API replica
├── migrate.log # pre-install Job
├── replication-leader.log # active replicator (initial sync, streaming, resume tokens)
└── replication-standby.log # idle warm standby (PSYNC_S1003 lock contention)
Useful greps:
grep -E 'locked for replication|Initial replication|Resume streaming|Idle change stream' test/.last-run/pods/replication-leader.log
grep PSYNC_S1003 test/.last-run/pods/replication-standby.log
jq '.checks[] | select(.ok==false)' test/.last-run/stages/3-smoke.jsontest/assertions.yaml is the single source of truth for what the smoke stage tests. Append an entry; the smoke runner picks it up on the next run. Supported types: kubectl (jsonpath), kubectl-exec (cmd in pod), log-grep (pattern + match count), kubeconform, helm-test.
# Pod logs
kubectl logs -n powersync-poc -l app=<release>-api
kubectl logs -n powersync-poc -l app=<release>-replication
# Pod status
kubectl get pods -n powersync-poc
# HPA status (when enabled)
kubectl get hpa -n powersync-poc
kubectl describe hpa -n powersync-poc
# Migration job output
kubectl logs -n powersync-poc job/<release>-migrate