Syncle

Self-hosting & security

Syncle is built to run on your own machine or a trusted network. This page covers the protections it ships with, what remains your responsibility if you expose it further, which volumes hold your data, and what the common failure messages mean.

What is protected, and how

Every API route sits behind an account. The first one is created on first run, guarded by a one-time setup token, with no signup (the quickstart walks through it); it is an admin, and can make more — see accounts and roles below. The password is hashed with scrypt, and the session is a signed httpOnly cookie named db_session that expires after one week of inactivity by default — using the app renews it — with the length configurable in-app under Settings › Security. Changing the password bumps the account's session version, which instantly invalidates every outstanding cookie on every device.

Sign-in attempts are throttled twice over. Five failures from one address for one user name lock that pair out, for 30 seconds and doubling up to 15 minutes. And ten failures for a user name from anywhere pause sign-in for that name for a few seconds, doubling up to one minute. The second exists because the first can be dodged: the address is taken from X-Forwarded-For, which a client can set to anything it likes, so a guesser who changed it on every attempt was never locked out. The per-name throttle holds such a guesser to about one attempt a minute, at the cost of making the operator wait up to a minute during an attack. Setup attempts are limited per address, and the setup token is 72 random bits.

One thing Syncle does not ship is TLS. It serves plain HTTP. The security policy is explicit that TLS termination, and network-level control over who can reach the port, become the operator's responsibility as soon as anything beyond localhost can connect.

Accounts and roles

Settings › Security › Accounts is where an admin adds them. Every account has a role:

RoleMay
admineverything — including the accounts, the API keys, the settings, the alert channels, the workspaces, the master key and the activity log
operatorthe work: connections, bridges, runs, verifications, the data browser (including DDL and raw queries on connections that allow them). Not the things above
viewerlook at everything, change nothing — every GET, and their own password

A role change takes effect on the next request, without signing in again. An account can be disabled — it cannot sign in and its sessions are over at once, but it keeps its name, so what it did stays attributed to it in the activity log — or deleted. Two things are refused because they would lock everybody out: the last admin that can sign in cannot be demoted, disabled or deleted, and nobody deletes the account they are signed in with. An admin can set another account's password (which ends that account's sessions) or end its sessions outright. API keys are not accounts: their scope (read or full) says what they may do, as before, and only an admin manages them. With more than one account, the password-reset code is asked for by user name (syncle reset-password <user>); unnamed, it is the first admin's.

The activity log

Every change made through the API — by an account or an API key — and every sign-in, succeeded or not, is one entry: who (by name as well as by id, so the entry outlives the account), what, to what, from which address, and a few words of detail (which engine, which role, which setting). Never a secret: a password, a connection's credentials or an API key are not details, and what Syncle does by itself — the slot guard giving up a replication slot — is recorded as Syncle. Reads are not recorded; they are the ordinary use of the app. Settings › Activity shows it newest first, narrowed by what was done and by whom; GET /api/audit answers the same to an admin. Entries are kept for auditRetentionDays (Settings › Security; default 365, 0 = for ever) and pruned by the same retention sweep as delivery details. An entry that could not be written never fails the request it was about; it is said in the server log instead.

What the compose stack exposes

The Docker install publishes exactly one host port. The API, Postgres and Redis containers are reachable only on the compose network:

ContainerHost portWhy
syncle-web3002 (SYNCLE_PORT changes it)The GUI and its /api proxy — the only published port.
syncle-apinoneThe browser reaches it through the web container's /api proxy. A commented ports mapping in docker-compose.app.yml exposes 4002 directly if you need to call the HTTP API without the proxy.
syncle-postgresnoneSyncle's own metadata store.
syncle-redisnoneThe job queue.

Encryption and the master key

Connection passwords, SSH secrets, TLS client keys and bridge auth secrets are encrypted at rest with AES-256-GCM under SYNCLE_MASTER_KEY, and only ever returned to the browser redacted. Session cookies are signed with an HKDF-derived sub-key of the same master key, so encryption and signing stay independent. The key must be base64 encoding exactly 32 bytes — anything else fails with SYNCLE_MASTER_KEY must be a base64-encoded 32-byte value the first time the key is needed: a login, the first-run setup, or saving a connection. The API itself still boots and answers /api/health.

The installer generates a key into ~/.syncle/.env (mode 600) and preserves it across every syncle update. If you run docker-compose.app.yml by hand without exporting one, the API generates a random key on first use and writes it to master.key inside the syncle-api-data volume, logging a warning — the key then lives right next to the data it protects, and is lost with the volume. Set it explicitly in anything you care about.

Changing the master key

A key that may have leaked, an operator who has left, a policy that says yearly: the key can be changed without a moment at which anything is unreadable.

  1. Generate a new key (openssl rand -base64 32). Put it in SYNCLE_MASTER_KEY, and the key you had in SYNCLE_MASTER_KEY_PREVIOUS (comma-separated if there are several). On a launcher install both go in ~/.syncle/.env.
  2. Restart. From this moment both keys open everything — the old one is only ever used to decrypt — and at start the API re-encrypts whatever is still under it with the new key: connection passwords and connection strings, SSH and TLS secrets, webhook credentials (including the copies inside jobs that can still be resumed), alert channels. It is safe to interrupt and safe to repeat.
  3. When the log says Nothing depends on a previous master key any more — Settings → Security shows the same, and GET /api/settings/encryption answers { previousKeys, reencrypted, unreadable } — take SYNCLE_MASTER_KEY_PREVIOUS out and restart once more.

Nobody is signed out: a session signed under the previous key stays valid while that key is listed, and is re-signed under the new one the next time it is renewed. An instance that started without a key in its environment (so with a generated master.key in its data directory) and is then given one needs no SYNCLE_MASTER_KEY_PREVIOUS at all: the file is treated as a previous key. A count of unreadable above zero means a secret fits none of the keys given — a key is missing from the list; nothing is touched until it is there.

Exposing Syncle beyond localhost

Before opening the port to a wider network:

  • Complete first-run setup first. Create the admin account while the port is still private, so the setup screen is never reachable from the outside.
  • Terminate TLS in front. The API trusts X-Forwarded-Proto to decide whether session cookies get the Secure attribute. If your reverse proxy does not forward that header, set SYNCLE_SECURE_COOKIES=true to force it — the decision is not keyed off NODE_ENV.
  • Keep the Host header. A request that changes something is only taken from the app itself (see below). Current browsers say so themselves; for older ones the API compares the request's Origin with the host it was reached under, so let your proxy pass that on (nginx: proxy_set_header Host $host;— Caddy and Traefik do by default), or name the public address in WEB_ORIGIN.
  • Let the event stream through. The page listens to GET /api/events, a stream of server-sent events that stays open for up to 15 minutes (see the API reference). A proxy must pass it on unbuffered and not cut it short: the response carries Cache-Control: no-transform and X-Accel-Buffering: no, which nginx, Caddy and Traefik honour — but a read timeout shorter than 15 minutes (nginx's proxy_read_timeout defaults to 60 seconds) ends the stream early; the browser reopens it, at the cost of a full refresh of the page's data each time. Raise the timeout for /api/events, or leave it: without the stream the page falls back to asking every few seconds, as it always did.
  • Restrict destinations. Set SYNCLE_BLOCK_PRIVATE_DESTINATIONS=true so bridge deliveries refuse loopback, private and link-local addresses — see the destination guard below.
  • Jail SQLite paths. Set SYNCLE_SQLITE_DIR so SQLite connections may only open files under that directory on the server.
  • Control network access. A firewall or VPN deciding who can reach the port at all is still the outermost layer; the single admin login is the only thing behind it.

On a Docker install, note that the stock compose file passes only a fixed set of variables to the api container — adding the hardening variables to ~/.syncle/.env does nothing on its own. Add them to the api service's environment block instead:

~/.syncle/docker-compose.app.yml (api service)

environment:
  # ...existing entries...
  SYNCLE_BLOCK_PRIVATE_DESTINATIONS: 'true'
  SYNCLE_SECURE_COOKIES: 'true'

The full list of environment variables, with defaults, is on the configuration page.

The outbound destination guard

Bridge deliveries to HTTP destinations never follow redirects — a public host that 302s to an internal address would otherwise bypass any pre-flight check. Cloud metadata endpoints (169.254.169.254 and its equivalents) are always refused, and hostnames are resolved before delivery, so a public DNS name pointing at an internal IP is caught the same as a literal address.

Blocking loopback, private and link-local destinations is opt-in via SYNCLE_BLOCK_PRIVATE_DESTINATIONS=true, because posting to a service on localhost is a primary local use case. The value is compared to the literal string true — 1 or TRUE leaves the guard off.

Monitoring

Two probes are public, because an orchestrator needs them before anyone can log in, and neither says more than "up" or "down":

  • GET /api/health — the API is alive and reaches its metadata store (503 when it does not). It reports Redis in checks but does not fail on it: a live bridge delivers without Redis, and restarting the API does not bring Redis back. The compose stack's container health check uses this one.
  • GET /api/health/ready — 503 unless the store and Redis answer. Point an uptime monitor here. Redis is asked on a connection of its own that neither queues nor waits, so the probe answers in a second and a half even when Redis is gone — the job queue's own connection would wait for it to come back.

GET /api/metrics speaks the Prometheus text format. A scraper cannot hold a session, so the route has a token of its own — and does not exist until SYNCLE_METRICS_TOKEN is set:

prometheus.yml

scrape_configs:
  - job_name: syncle
    metrics_path: /api/metrics
    authorization:
      credentials: "<the value of SYNCLE_METRICS_TOKEN>"
    static_configs:
      - targets: ["syncle.internal:3002"]
MetricWhat it is
syncle_up{component}1 when database / redis answers
syncle_bridges{trigger,enabled}, syncle_jobs{status}bridges by trigger, jobs by status. Alert on syncle_jobs{status="failed"} rising
syncle_deliveries_total{status}deliveries recorded by the jobs that still exist
syncle_dead_letter_rows{bridge_id,bridge}rows waiting in a bridge's dead-letter queue
syncle_source_retained_bytes{bridge_id,bridge}change log a bridge makes its source keep (a PostgreSQL slot pinning WAL), as the slot guard last measured it
syncle_instance_leader{instance}1 on the API process that leads, 0 on the others. Summed over every process you scrape it should be exactly 1
syncle_build_info{version}, process_*, nodejs_*the running version; memory, uptime and event-loop lag of the API process

Everything is read from the metadata store at scrape time with aggregates over small tables — never a scan of the delivery log — so a 15-second scrape interval is fine. Set SYNCLE_LOG_LEVEL=log to have the API also log lifecycle events (it logs warnings and errors only by default).

Where a request may come from

The session is a cookie, and a browser sends a cookie with every request to the app — also one that another site made it send. SameSite=Lax stops most of that and not all of it (a sibling subdomain is the same "site"). So every request that changes something — anything but GET, HEAD and OPTIONS — is checked before it reaches a route:

  • Sec-Fetch-Site: same-origin, which every current browser sends and no page can set, is the browser's own word that a page of the app made the request;
  • otherwise the Origin header has to be the address the app was reached under, or one of WEB_ORIGIN;
  • a request with no Origin at all is not a browser (curl, a script with an API key) and is not what this is about;
  • anything else is answered 403 with details.reason: "cross-origin" — including a login with the right password.

Every response also says what it is. The API's are data: nosniff, X-Frame-Options: DENY, a Content-Security-Policy of default-src 'none', Cache-Control: no-store, and Strict-Transport-Security when the browser came over HTTPS. The web app's pages allow scripts, styles, fonts and workers from the app itself and nowhere else, connections only to the app (and to NEXT_PUBLIC_API_URL where that is set), and may not be framed. Nothing is loaded from a CDN: the query editor is served by the app, so Syncle works on a network with no internet. (Inline scripts are allowed — Next.js hydrates through them; what the policy takes away is script from another origin.)

Alerts

A bridge that stops at three in the morning used to say so in one place: its own page. Settings › Alerts adds places to say it out loud — a webhook, a Slack incoming webhook, or e-mail over your SMTP server — each subscribed to the events it cares about:

  • bridge.failed — a bridge or a replay stopped because of a failure (critical), or a run finished with deliveries that failed along the way (warning). A stop or a cancel somebody asked for is not an alert.
  • bridge.position_lost — a live bridge lost its place in the source's change log and cannot resume without accepting a gap.
  • bridge.dead_letters — rows were set aside. The bridge carries on, so nothing else would tell you they are waiting.
  • source.hold — a bridge is making its source keep change log beyond the warning level. Sent when that gets worse, not on every check.
  • bridge.schema_drift — a bridge's source table is no longer the one it was built on. Critical when the bridge uses a column that is gone and has stopped rather than write NULL over the copy; a warning when the change is harmless (a column added, a type changed). See When the source table changes. (A stop of this kind is this one alert, not a second bridge.failed.)

Alerts are throttled per channel, kind of event and bridge (five minutes by default, SYNCLE_ALERT_THROTTLE_SECONDS): a bridge that fails every thirty seconds is one message per window, and the next says how many were held back. Sending never blocks or fails a bridge; a channel that does not take an alert is tried once more, and the outcome of the last send is shown next to the channel. The Test button sends one now.

A webhook receives the event as JSON — { app, version, type, severity, title, message, bridgeId, bridgeName, jobId, at, suppressed? } — with an X-Syncle-Event header. Give the channel a signing secret and every request also carries X-Syncle-Signature: sha256=<hex>, the HMAC-SHA256 of the exact body bytes, so the receiver can tell a real alert from anyone who found the URL:

Verifying an alert (Node.js)

const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const given = req.headers['x-syncle-signature'] ?? '';
const ok = given.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));

A channel's whole configuration is encrypted at rest with the master key — a Slack webhook URL is its credential — and the API never hands a secret back: everything after a URL's origin, the signing secret, header values and the SMTP password read as ••••••••. Alert requests are held to the same destination guard as bridge deliveries, and redirects are not followed.

Kubernetes (Helm)

The repository ships a Helm chart at deploy/helm/syncle: the API, the web GUI, and — unless you point it at your own — a PostgreSQL for Syncle's own metadata and a Redis for its job queue, as single-replica StatefulSets on persistent volumes.

helm install syncle ./deploy/helm/syncle \
  --namespace syncle --create-namespace \
  --set masterKey.value="$(openssl rand -base64 32)"

# the first-run setup token
kubectl -n syncle logs deploy/syncle-api | grep -A2 'setup token'

# the GUI, until you enable the ingress
kubectl -n syncle port-forward svc/syncle 3002:3002

The master key is the one value you must set — the chart refuses to install without masterKey.value or masterKey.existingSecret, because a key generated inside a pod would be lost with it, and with it every stored credential. Keep it. Everything else has a default:

  • postgres.enabled=false with postgres.external.url (or existingSecret/key) to use your own PostgreSQL; the same under redis.
  • ingress.enabled, ingress.host, ingress.tls to expose it — with TLS in front, since Syncle serves plain HTTP. WEB_ORIGIN is derived from the host (or set webOrigin).
  • api.replicas above one is safe: the replicas share the same key, database and Redis, one of them leads, and another takes over if it goes.
  • every SYNCLE_* tunable from the configuration page goes under api.env, by name; api.metricsToken turns the metrics endpoint on.
  • helm upgrade with a new image.tag upgrades the whole release; the API applies its own migrations at start.

Running more than one API process

One API process is what the compose stack runs, and all most installations need. A second one — a replica for availability, or the minute during which a rolling deploy overlaps — is safe: the processes find each other through the Redis they share, and one of them leads.

  • The leader reads the live (CDC) bridges and runs the periodic sweeps (the slot guard, delivery retention). A source is never read by two processes: that used to mean a PostgreSQL slot fought over, a MySQL server throwing out one reader after the other (both connect under the same replication server id), and on MongoDB and Redis every change delivered twice.
  • Runs, polls, schedules and verifications are jobs on the queue, picked up by whichever process is free — that part does scale out. A polling bridge is polled by one process at a time.
  • Any process can be asked anything. Start or stop a live bridge through a process that does not lead and it is relayed to the leader, and answered when the leader has done it. A setting saved through one process reaches the others; a cancel reaches the process that is running the job; the first-run setup token is the same whichever process prints it.
  • Failover. The lead is a lease in Redis, renewed three times within SYNCLE_LEADER_TTL_SECONDS (default 20). A leader that is shut down hands it over at once; one that dies is replaced when the lease runs out. The new leader resumes every live bridge from its saved position — the same thing a restart does — so nothing is lost and nothing is delivered twice.

What it needs from you:

  • the same DATABASE_URL, REDIS_URL and SYNCLE_MASTER_KEY on every process. A master key generated into one container's data directory is not shared: set it explicitly (the first-run token differing between processes is the first sign that it is not);
  • the same version everywhere, apart from the minutes of a rolling upgrade;
  • a load balancer in front that can send any request to any process — no sticky sessions are needed.

What to back up

The stack keeps its state in three named Docker volumes:

VolumeWhat it holds
syncle-postgres-dataThe metadata store: workspaces, connections (credentials encrypted), bridges, job history and per-row delivery logs.
syncle-api-dataThe API's local state: the first-run setup token while it exists, and the auto-generated master key if SYNCLE_MASTER_KEY was never set.
syncle-redis-dataThe job queue that lets running bridge jobs survive a restart and auto-resume.

A backup of the Postgres volume is only useful together with the master key — without it the stored credentials cannot be decrypted. Back up ~/.syncle/.env alongside the volumes, and store it separately from them if you can.

syncle down stops the containers and keeps all three volumes. syncle uninstall is the destructive one: it asks This deletes all Syncle containers, images and DATA. Continue? [y/N] and then runs compose down -v --rmi all, deleting the volumes with everything in them.

Reporting a vulnerability

Do not open a public issue for security problems. Email osmanahmadxai@gmail.com with a description of the issue and its impact, steps to reproduce, and any suggested fix. You get an acknowledgement within a few days, disclosure timing is coordinated with you, and you are credited in the release notes unless you prefer to stay anonymous.

Security fixes land on main and the latest release only, so stay on the newest release. Always in scope: credential handling, SQL or command injection through the adapters, payload-template injection, auth bypasses, and SSRF that dodges the destination guard.

Troubleshooting

  • Port <n> is already in use. Stop the other process or set PORT. — the API found its port taken and exited with code 1. Stop whatever holds the port, or change PORT — and keep the web app's proxy target in step with it.
  • Every screen shows Cannot reach the Syncle API — the web app's proxy could not reach the API (it returns a 503 with code NETWORK). The api container is down or still starting; check syncle status and syncle logs api.
  • Could not refresh images — using what is already downloaded. — syncle up could not pull newer images (offline, or the registry is unreachable) and started the cached ones instead. Not an error; the next successful syncle up or syncle update refreshes them.
  • Still starting — check `syncle logs`. — syncle up polls the GUI every 2 seconds for up to 60 attempts and gave up waiting. The containers usually keep starting in the background; syncle logs shows what they are doing.
  • Lost the setup token, or the process died mid-setup — a fresh token is minted on the next boot as long as no account exists yet, and syncle logs api prints it.
  • Logged out everywhere after a password change — deliberate. A password change invalidates all outstanding sessions and re-issues only the one that made the change.
  • Bridges can be built and previewed, but jobs will not run — Redis is down. Only running a job needs Redis; connecting databases, browsing and building or previewing bridges all work without it. Check syncle logs redis.
  • SYNCLE_MASTER_KEY must be a base64-encoded 32-byte value — the key is not valid base64 of exactly 32 bytes. On a fresh install, generate one with openssl rand -base64 32. If data already exists, recover the original key rather than making a new one — see above.