Syncle

Configuration

Syncle is configured through environment variables, plus a small set of runtime settings edited in the web interface and stored in its metadata database. This page lists every variable with its shipped default, where each configuration file lives, and how the in-app settings layer over the env values.

Where configuration lives

Which file matters depends on how you run Syncle. There is no dotfile config — no .synclerc, no syncle.config.js; configuration is exclusively environment variables plus the in-app settings described at the end of this page.

FileApplies toHow it gets there
$SYNCLE_HOME/.env (default ~/.syncle/.env)Docker installWritten by install.sh with mode 600; passed to Docker Compose as the env file. Holds SYNCLE_MASTER_KEY and the pinned SYNCLE_IMAGE, and is where you add any of the API settings below.
apps/api/.envAPI, source checkoutCopied from apps/api/.env.example by scripts/setup-env.mjs.
apps/web/.env.localWeb app, source checkoutCopied from apps/web/.env.example by the same script.

scripts/setup-env.mjs runs before pnpm dev and pnpm start — not on pnpm install — and never overwrites a file that already exists, so your edits survive every run.

In the Docker install, docker-compose.app.yml fixes the wiring (PORT=4002, a DATABASE_URL pointing at the bundled Postgres, the Redis URL, the data directory) and passes every other API setting on this page through from $SYNCLE_HOME/.env. To change one, add a line and bring the stack up again:

~/.syncle/.env

SYNCLE_CDC_SPOOL=on
SYNCLE_DELIVERY_RETENTION_DAYS=90

apply it

syncle up      # not "syncle restart": a restart keeps the old environment

A setting you leave out keeps its default. (Releases up to 1.3 passed only SYNCLE_MASTER_KEY and SYNCLE_IMAGE, so none of the others could be changed on a Docker install at all; run syncle update to get the newer compose file.) The syncle launcher itself reads SYNCLE_PORT and SYNCLE_HOME from your shell — the installation page covers those.

API environment variables

Read from apps/api/.env (or the container environment) at boot. Defaults below are the shipped apps/api/.env.example values or, where that file has no line, the code defaults.

VariableDefaultPurpose
PORT4002Port the API listens on. Must match the web app's SYNCLE_API_ORIGIN.
DATABASE_URLpostgresql://postgres:postgres@localhost:5433/syncle?schema=publicPostgreSQL URL for Syncle's own metadata store (connections, bridges, jobs) — not a database you sync. No code fallback: the API cannot start without it.
REDIS_URLredis://localhost:6379Redis behind the job queue. Only running bridge jobs needs it; the API boots fine with Redis down.
SYNCLE_MASTER_KEYunset (auto-generated)Base64 32-byte key that encrypts stored credentials and signs session cookies. See the master key.
SYNCLE_MASTER_KEY_PREVIOUS—While changing the master key: the old key or keys, comma-separated. Still accepted for decrypting, never used for encrypting; what is under them is re-encrypted at start. Remove it when the API says nothing depends on a previous key any more.
SYNCLE_DATA_DIRapps/api/.syncleDirectory for local state — the auto-generated master.key, the first-run setup token, and syncle.db. Created with mode 700.
WEB_ORIGINhttp://localhost:3002Origins a browser may use Syncle from besides the app's own, comma-separated. They are allowed by credentialed CORS, and they are the only other origins a request that changes something is taken from. Needed when the browser calls the API directly via NEXT_PUBLIC_API_URL; otherwise only behind a reverse proxy that rewrites the Host header, for browsers too old to send Sec-Fetch-Site.
WEB_PORT3002The web app's port; sets the default CORS origin and the address in the ready banner.
SYNCLE_JOB_CONCURRENCY5How many bridge jobs may execute concurrently.
SYNCLE_CDC_BATCH_SIZE100000Rows per CDC delivery to a database destination. Database destinations only — HTTP keeps the bridge's own batch size. The default is the measured peak; a larger batch buys nothing and lengthens what a crash has to replay.
SYNCLE_CDC_BATCH_BYTES67108864Byte ceiling for one batch. A row cap alone is unsafe, since 100,000 wide rows could be gigabytes — the row size is estimated once per batch and whichever limit binds first wins.
SYNCLE_CDC_LINGER_MS50How long a partial CDC batch waits for more changes before it is sent anyway.
SYNCLE_CDC_SPOOLoffon spools changes through Redis before writing, so the source is acknowledged without waiting for the destination.
SYNCLE_CDC_SPOOL_MAX50000Unwritten changes held in the spool before the reader is throttled.
SYNCLE_DEAD_LETTER_MAX_ROWS10000Undelivered rows one bridge may hold in its dead-letter queue. At the limit a continue bridge stops, without moving its cursor, rather than grow the metadata store without bound.
SYNCLE_MAX_CONSECUTIVE_FAILURES5Batches in a row that may deliver nothing before a continue bridge stops. One bad row fails one batch; a destination that is down fails all of them.
SYNCLE_SLOT_CHECK_SECONDS60How often to measure what each CDC bridge is holding on its source — for PostgreSQL, the WAL pinned by its replication slot — and to retry dropping slots that could not be dropped. 0 turns it off.
SYNCLE_SLOT_WARN_BYTES1073741824WAL pinned by one bridge before it is flagged in the job view and the log (1 GiB). 0 never warns.
SYNCLE_SLOT_MAX_BYTES0WAL pinned by a bridge that is not running before Syncle drops its replication slot to protect the source. 0 (the default) never does: a dropped slot is a gap in that bridge. Prefer max_slot_wal_keep_size on the server, which also works while Syncle is down — see replication slots.
SYNCLE_SNAPSHOT_HOLD_MAX100000A Redis CDC bridge that copies its keys before following them holds the changes made meanwhile in memory — the newest per key, since Redis has no log to read them back from. This is how many keys may be held before the bridge stops rather than grow without limit. PostgreSQL, MySQL and MongoDB keep those changes in their own log and are not affected.
SYNCLE_LOG_LEVELwarnerror, warn, log (or info), debug, verbose. The default logs what it always did — warnings and errors; log adds the lifecycle lines: a bridge started, a table copied, a slot released, a retention sweep.
SYNCLE_METRICS_TOKENunsetTurns on GET /api/metrics (Prometheus) for requests carrying Authorization: Bearer <token>. Unset, the endpoint does not exist. See monitoring.
SYNCLE_ALERT_THROTTLE_SECONDS300Alerts are throttled per channel, kind of event and bridge: one per this many seconds, the next one saying how many were held back. 0 sends every one.
SYNCLE_VERIFY_RECHECK_MS1500Verifying a bridge that is delivering: a row that looks wrong is looked at again this many milliseconds later, from both ends, before it counts. A change that was only in flight is not a difference.
SYNCLE_SHARED_SLOT_JOIN_WAIT_MS60000A bridge joining a shared replication slot waits for the transactions that were open when its table was published to end. After this long it gives up and names the transaction it was waiting for.
SYNCLE_AUDIT_RETENTION_DAYS365Days the activity log is kept — the default for the in-app setting (Settings › Security). 0 keeps it for ever.
SYNCLE_LEADER_TTL_SECONDS20With more than one API process: how long the leader's lease lasts. It is how long a failover takes at most after a process dies, and how long a leader that cannot reach Redis keeps reading the live bridges before it stops.
SYNCLE_ECHO_TTL_SECONDS300Loop prevention: how long a write to a table that another bridge reads is remembered, so that bridge knows it when it comes back. It has to outlast how far behind that bridge can fall; a change that comes back later than this is sent on once more and dies out there. 0 switches loop prevention off.
SYNCLE_DELIVERY_RETENTION_DAYS30Default for the deliveryRetentionDays setting: days a delivery's details are kept. 0 keeps them for ever. See delivery history.
SYNCLE_DELIVERY_MAX_PER_JOB100000Default for the deliveryMaxPerJob setting: how many deliveries a live (watch / CDC) bridge keeps, however recent. 0 is no limit.
SYNCLE_RETENTION_SWEEP_MINUTES60How often delivery history is pruned. 0 never prunes on a timer (POST /api/bridges/retention/run still does it on demand).
SYNCLE_MAX_QUERY_ROWS5000Reported as the settings default. The working ad-hoc query cap is the built-in 5000, or a per-connection maxQueryRows option.
SYNCLE_POOL_IDLE_MS300000Idle milliseconds before a pooled database connection is closed.
SYNCLE_SECURE_COOKIESunset (auto-detect)true forces the Secure attribute on session cookies, false forces it off; unset detects HTTPS from the request.
SYNCLE_BLOCK_PRIVATE_DESTINATIONSunset (off)true refuses HTTP destinations that resolve to loopback, private, or link-local addresses.
SYNCLE_SQLITE_DIRunset (no restriction)When set, SQLite connections may only open files under this directory.

Three details worth knowing. SYNCLE_HOOK_CONCURRENCY is the legacy name for SYNCLE_JOB_CONCURRENCY and is still honored as a fallback; prefer the new name. SYNCLE_BLOCK_PRIVATE_DESTINATIONS is compared to the literal string true — 1 or TRUE leaves the guard off (cloud metadata endpoints are blocked regardless of this flag). And Secure-cookie detection follows the request's X-Forwarded-Proto header via Express trust-proxy — NODE_ENV plays no part in it, despite a stale comment in the env example.

The master key

SYNCLE_MASTER_KEY is a base64-encoded 32-byte key with two jobs: it encrypts stored connection credentials with AES-256-GCM, and an HKDF-derived sub-key signs login session cookies. Generate one with either of:

openssl rand -base64 32
# or, without openssl:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

Left unset, the API generates a random key on first run and writes it to master.key in the data directory with mode 600, logging a warning to set the variable in production. That auto-generated key sits beside the data it protects: a data-directory backup carries both, and losing the volume loses the key. The installer avoids this by generating a key into $SYNCLE_HOME/.env and preserving it on every re-run. A key of the wrong length is rejected at boot with SYNCLE_MASTER_KEY must be a base64-encoded 32-byte value.

Web app environment variables

Read from apps/web/.env.local (or the container environment). The browser normally calls a relative /api on the web app's own origin, and the web server proxies that to the API — so the API's address is never baked into the browser bundle.

VariableDefaultPurpose
SYNCLE_API_ORIGINhttp://127.0.0.1:4002Where the /api proxy forwards requests; must match the API's PORT. Read at request time, not build time.
WEB_PORT3002Port the web app runs on.
API_PORT4002Fallback port for the proxy's default origin when SYNCLE_API_ORIGIN is unset.
NEXT_PUBLIC_API_URLunsetOptional absolute API URL (e.g. https://api.example.com/api) that makes the browser call the API directly, skipping the proxy.
NEXT_OUTPUTunsetSet to standalone to build the self-contained server the Docker image runs.

NEXT_PUBLIC_API_URL is inlined into the browser bundle at build time. Setting it turns on cross-origin requests, so the API's WEB_ORIGIN must then list the web app's origin. Leave it unset for the default same-origin proxy — the self-hosting page discusses when the direct route is worth it.

In-app settings

The Settings dialog (in the user menu) edits a handful of server-wide values at runtime. They persist as a single row in the metadata database and layer over the env values — the env vars act as defaults, not ceilings. The same values are readable and writable over HTTP via GET /api/settings and PUT /api/settings, covered on the API page.

SettingDefaultRangePurpose
defaultPollIntervalMs50001000 – 3,600,000Default poll cadence (ms) for polling bridges.
defaultMaxPerPoll5001 – 5000Default rows fetched per poll.
defaultCdcOperationsinsert, update, deletenon-empty subset of the threeDefault operation set for CDC bridges.
maxQueryRowsSYNCLE_MAX_QUERY_ROWS, else 50001 – 1,000,000Cap on rows from one ad-hoc query.
poolIdleMsSYNCLE_POOL_IDLE_MS, else 300,00010,000 – 86,400,000Idle ms before a pooled connection closes.
jobConcurrencySYNCLE_JOB_CONCURRENCY, else 51 – 100Concurrent bridge jobs.
sessionTtlMinutes10080 (one week)15 – 43,200Minutes before a login session expires.
deliveryRetentionDaysSYNCLE_DELIVERY_RETENTION_DAYS, else 300 – 3,650Days a delivery's details are kept. 0 keeps them for ever.
deliveryMaxPerJobSYNCLE_DELIVERY_MAX_PER_JOB, else 100,0000 – 100,000,000Deliveries a live (watch / CDC) bridge keeps, however recent. 0 is no limit.

Delivery history

Every delivery is recorded with what was sent and what came back (up to 16 KB each), which is what the timeline shows. Nothing used to remove those rows, and a live bridge writes them for as long as it runs — ten deliveries a second is 26 million rows a month in the metadata store. Two settings, under Settings › Engine, bound it, and both take effect at the next hourly sweep without a restart:

  • Keep delivery details for (days) — a finished replay loses its details all at once, when the job is older than this (never row by row, which would leave a timeline with holes in it); a live bridge loses them as each row passes that age.
  • Deliveries kept per live bridge — a watch or CDC bridge never finishes, so age alone does not bound it: only the newest this-many are kept.

What is never removed: the job's delivered / failed / skipped totals, which are stored on the job and do not change when details go; a failed delivery whose rows are still waiting in the dead-letter queue; and anything belonging to a replay that is still queued or running. On the timeline a delivery whose details are gone is drawn as a dashed cell — delivered, details removed — rather than as queued. Retrying failed deliveries needs those details, so it is available for as long as they are kept; after that, run the bridge again. Settled dead letters (retried successfully, or discarded) expire on the same schedule; pending ones are data and are kept until you deal with them.

All of them take effect without a restart. The three default* values are what the bridge builder starts a new bridge from; existing bridges keep their own. maxQueryRows caps ad-hoc queries on every connection that does not set its own maxQueryRows option, and reaches connections that are already open. jobConcurrency is applied to the replay worker as soon as it is saved (jobs already running finish as they are). sessionTtlMinutes has no env var and is edited only here, under Settings › Security. A settings row persisted before the bridges rename under the old hookConcurrency key is migrated to jobConcurrency automatically.

Releases up to 1.3 stored these values and reported them back, but only sessionTtlMinutes did anything: the builder used a 5 second poll, 500 rows per poll and all three CDC operations whatever was saved, the query cap was the built-in 5000, and concurrency came from SYNCLE_JOB_CONCURRENCY alone.