# # rsync.ai — Quickstart (single-command install) # # Usage: # docker compose -f docker-compose.quickstart.yml --env-file .env up -d # # All images are pre-built and pulled from GHCR — no source code required. # Requires: Docker 24+, docker compose v2, and an .env file (see install.sh). # # Every first-party image is `${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/: # ${RSYNC_VERSION:-latest}`: both halves are one variable each, so a mirror, an # air-gapped registry or a pre-release build moves every image together. The # same variable name is the Kubernetes installer's (install-k8s.sh). # # ── Sizing ─────────────────────────────────────────────────────────────────── # Targets a 4 vCPU / 16 GiB host. Every service carries an explicit `mem_limit`, # which is doing two jobs: it stops one runaway container from taking the host # down via the OOM killer (which picks the largest RSS on the box — Kafka or # Postgres — not the container at fault), and it is what makes the orchestrator's # own memory-aware sizing engage at all. resolveTableConcurrencyWithReason() reads the # CGROUP limit (backend-orchestrator/internal/agents/executor/table_concurrency.go # detectMemoryLimitBytes); with no limit set, that read returns "unlimited" and # the memory guard is dead code, so a 4-core box fans out to 8 concurrent table # loads budgeted at ~384 MiB each. # # These are CEILINGS, not reservations: nothing is pre-allocated, and the sum # deliberately exceeds host RAM (~13.8 GiB default, ~16.8 GiB with the cdc # profile) because no two services peak together. Steady state for the default # profile is far lower. The number that is NOT slack is the orchestrator's 2 GiB # — that one is a real buffer budget, since concurrent table loads are sized # against it. Shrink it and throughput drops; the stack will not fall over. # # ── Optional profiles ──────────────────────────────────────────────────────── # The default `up` starts the batch data plane only. Two opt-in profiles: # # --profile cdc change-data-capture: kafka-connect + debezium-mcp. # ~2 GiB, and REQUIRED for any CDC pipeline — the # orchestrator's preflight hard-requires both plus # kafka-mcp-sink (internal/workers/infra_preflight.go # requiredServices), so without this profile a CDC # pipeline fails preflight rather than degrading. # kafka-mcp-sink is NOT in the profile: batch runs # write through it too, so it always starts. # --profile generate context7-mcp, API-doc research used only while GENERATING # a new connector. Existing connectors run without it. # --profile connectors sample-data-mcp pre-started. Off by default: the first # connection that needs it (or the first "Try the demo") # deploys it on demand through connector-deployer, as it # already does postgresql/mysql/aws-s3 and every other # catalog connector. Turn it on only to skip that first wait. # # docker compose -f docker-compose.quickstart.yml --profile cdc up -d # name: rsync-ai # ───────────────────────────────────────────────────────────────────────────── # Kafka client security — BYO / managed broker (MSK, Confluent Cloud, Aiven, …) # ───────────────────────────────────────────────────────────────────────────── # Merged into every service that opens a Kafka client. It exists because Compose # injects ONLY what a service declares: a KAFKA_SASL_PASSWORD sitting in the # root .env reaches interpolation but NOT the container, so the operator sets # credentials, the app connects anonymously, and an ACL-enforcing broker rejects # it with an error naming the broker rather than the missing setting. # # Every value defaults to empty, and empty is read as unset # (shared/go/kafkaclient/config.go:240 protocolWasSet) — so with none of these in # your .env the rendered config is byte-identical to the bundled-broker default. # # Minimum for SASL/SCRAM against a managed cluster: # KAFKA_BROKERS=b-1.example.amazonaws.com:9096,b-2.example.amazonaws.com:9096 # KAFKA_SECURITY_PROTOCOL=SASL_SSL # KAFKA_SASL_MECHANISM=SCRAM-SHA-512 # KAFKA_SASL_USERNAME=... / KAFKA_SASL_PASSWORD=... # # Setting the credentials WITHOUT KAFKA_SECURITY_PROTOCOL is the common slip; the # client logs "SASL settings are configured … they are IGNORED" once and connects # anonymously (config.go:281-297). Read that warning as a hard failure. x-kafka-security: &kafka-security KAFKA_SECURITY_PROTOCOL: ${KAFKA_SECURITY_PROTOCOL:-} KAFKA_SASL_MECHANISM: ${KAFKA_SASL_MECHANISM:-} KAFKA_SASL_USERNAME: ${KAFKA_SASL_USERNAME:-} KAFKA_SASL_PASSWORD: ${KAFKA_SASL_PASSWORD:-} # OIDC client-credentials, for KAFKA_SASL_MECHANISM=OAUTHBEARER. KAFKA_SASL_OAUTHBEARER_CLIENT_ID: ${KAFKA_SASL_OAUTHBEARER_CLIENT_ID:-} KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET: ${KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET:-} KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT: ${KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT:-} KAFKA_SASL_OAUTHBEARER_SCOPE: ${KAFKA_SASL_OAUTHBEARER_SCOPE:-} KAFKA_SASL_OAUTHBEARER_EXTENSIONS: ${KAFKA_SASL_OAUTHBEARER_EXTENSIONS:-} # Re-permits a plain-http token endpoint off loopback, which every runtime # otherwise refuses at start-up (the client-credentials grant POSTs # CLIENT_SECRET on every fetch). Carried here for ONE reason: kafka-connect # and the kafka-init job read their whole security profile from this anchor, # so without a passthrough a rig with a throwaway http IdP can configure the # Go and Python services but not the JVM ones -- the same image behaving # differently under compose than under the chart, which does make it # reachable. The default is empty, i.e. unset, i.e. the refusal stays armed; # a passthrough is not a setting. Never give it a value in this file -- # llm-service/tests/test_insecure_token_endpoint_rule_is_one_rule.py fails if # any file but the Kafka security matrix harness sets it to true. KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT: ${KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT:-} # JVM-only, and only the CDC profile has a JVM in it (kafka-connect). Carried # here anyway because Debezium's schema-history client is configured from the # connector container's environment, so the name has to survive the trip. KAFKA_SASL_OAUTHBEARER_LOGIN_CALLBACK_HANDLER: ${KAFKA_SASL_OAUTHBEARER_LOGIN_CALLBACK_HANDLER:-} # AWS_MSK_IAM. The Go services implement it (kafkaclient/tokenauth/msk.go); the # Python tier and the JVM Connect image do not, so an IAM-only cluster runs the # Go data plane and leaves the CDC profile unable to authenticate. KAFKA_AWS_REGION: ${KAFKA_AWS_REGION:-} # Paths INSIDE the container. Mount the files yourself — this file cannot guess # a host path. KAFKA_SSL_CA_LOCATION: ${KAFKA_SSL_CA_LOCATION:-} # mTLS, the two-path shape the Go and Python clients read. KAFKA_SSL_CERT_LOCATION: ${KAFKA_SSL_CERT_LOCATION:-} KAFKA_SSL_KEY_LOCATION: ${KAFKA_SSL_KEY_LOCATION:-} # The SAME keypair in the shape a JVM can load: ONE file holding chain + key. # Optional -- kafka-init and kafka-connect build that file from the pair above # when this is unset (the connect image's connect-entrypoint.sh). Set it only # to use a combined file you already have; it then wins over the pair. Inert # in the Go and Python services. KAFKA_SSL_KEYSTORE_LOCATION: ${KAFKA_SSL_KEYSTORE_LOCATION:-} # Disables certificate verification, and is logged unconditionally when set: a # self-signed lab, not prod. # # TWO SPELLINGS FOR ONE SETTING, and that is not tidy-uppable here. Python # reads only KAFKA_SSL_SKIP_VERIFY (kafka_security.py:54); Go reads both # (config.go:80 and the alias at :115). Shipping the Go spelling alone leaves # verification silently ON for the Python half -- the exact split the Go alias # was added to end. So both names are emitted, each defaulting to the other, # and an operator who sets either one gets both. KAFKA_SSL_SKIP_VERIFY: ${KAFKA_SSL_SKIP_VERIFY:-${KAFKA_SSL_INSECURE_SKIP_VERIFY:-}} KAFKA_SSL_INSECURE_SKIP_VERIFY: ${KAFKA_SSL_INSECURE_SKIP_VERIFY:-${KAFKA_SSL_SKIP_VERIFY:-}} services: # ─── INFRASTRUCTURE ────────────────────────────────────────────────────────── postgres: image: postgres:16-alpine container_name: rsync-ai-postgres logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped # Metadata DB only — pipeline rows never land here. Default shared_buffers # is 128 MiB; 1 GiB leaves room for work_mem across the connection pool. mem_limit: 1g environment: POSTGRES_USER: ${POSTGRES_USER:-rsync} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} POSTGRES_DB: ${POSTGRES_DB:-pipeline_db} command: - postgres - -c - wal_level=logical - -c - max_wal_senders=1 - -c - max_replication_slots=1 volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rsync} -d ${POSTGRES_DB:-pipeline_db}"] interval: 5s timeout: 5s retries: 10 # ─── DEMO WAREHOUSE ────────────────────────────────────────────────────────── # # The destination half of the zero-credential try-it path. `sample-data` gives a # fresh install a source it can connect to with no credentials, but a pipeline # needs TWO steps: intent.go:417 returns "plan has insufficient steps" for # len(steps) < 2. Every other destination in the catalog requires a credential # the new user does not have yet, so without this container the demo ends at a # successful connection test and never produces a row. # # This is deliberately NOT the `postgres` service above. That one is sized and # tuned as a metadata DB ("pipeline rows never land here") and runs with # max_wal_senders=1 / max_replication_slots=1 -- exactly one CDC slot. Landing # demo rows there would put pipeline data in the control-plane DB and contend # for that single slot. # # It gets its OWN credential rather than reusing POSTGRES_PASSWORD. Sharing the # control-plane password with a user-facing destination is a real, if # second-order, escalation path: any workspace member can run SQL against this # database through the pipeline/explorer, a superuser there can read # pg_authid.rolpassword, and a SCRAM verifier is offline-crackable. The two # credentials have no reason to be the same string, so they are not. # # The default below is a literal, on purpose, and is not a secret. A new # REQUIRED var -- one written with the `:?` interpolation guard -- would break a # fresh host and ONLY a fresh host, because a single unsatisfied `:?` aborts # interpolation for the whole merged config while prod, staging and CI stay # green. So the choice was a default or nothing. This database is never # published off the internal network and holds nothing but the sample rows the # demo writes into it. Override RSYNC_DEMO_WAREHOUSE_PASSWORD if you disagree. demo-warehouse: image: postgres:16-alpine container_name: rsync-ai-demo-warehouse logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 256m environment: POSTGRES_USER: rsync_demo POSTGRES_PASSWORD: ${RSYNC_DEMO_WAREHOUSE_PASSWORD:-rsync-demo-not-a-secret} POSTGRES_DB: demo_warehouse volumes: - demo_warehouse_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U rsync_demo -d demo_warehouse"] interval: 5s timeout: 5s retries: 10 # The demo seed hands this host to the JIT-deployed postgresql connector, which # joins ONLY rsync-ai-mcp (SEC-M-06), so on `default` alone the connector cannot # resolve "demo-warehouse" and the seed 502s. Bridging it is safe where bridging # postgres would not be: it has its own role and holds only the sample rows. # Guarded by test_a_host_handed_to_a_connector_is_on_the_connector_network. networks: - default - rsync-ai-mcp redis: image: redis:7-alpine container_name: rsync-ai-redis logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 768m environment: # Consumed by the command + healthcheck below; keeps the secret off ARGV. REDIS_PASSWORD: ${REDIS_PASSWORD:?REDIS_PASSWORD is required} command: - sh - -c # The password is written to a 0600 file and never appears on the # redis-server ARGV, so it stays out of `ps` on the host and out of # the rendered compose spec (`docker inspect` / `compose config`). - | umask 077 : > /tmp/redis.conf printf 'requirepass %s\n' "$$REDIS_PASSWORD" > /tmp/redis.conf exec redis-server /tmp/redis.conf --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data healthcheck: test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli --no-auth-warning ping"] interval: 5s timeout: 3s retries: 10 kafka: image: apache/kafka:3.7.0 hostname: kafka container_name: rsync-ai-kafka logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped # KAFKA_HEAP_OPTS below pins Xmx at 512m. Only PART of the rest is reclaimable: # log-segment page cache does shrink under pressure, but direct/network buffers # and metaspace are anonymous memory and count against the cap permanently. # # 1g gave that anonymous remainder ~512 MiB. The same broker config on the GCP # host, capped at 1536M, measures 644.7 MiB resident at REST (2026-09-23) — i.e. # already over what 1g would leave once heap is fully used, before any load. The # cap here now matches docker-compose.prod.yml rather than sitting 512 MiB under # it for an identical JVM; self-host is the deployment most likely to meet a # large first sync with no operator watching. mem_limit: 1536m environment: KAFKA_NODE_ID: 1 KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 3000 KAFKA_GROUP_MAX_SESSION_TIMEOUT_MS: 180000 KAFKA_GROUP_MIN_SESSION_TIMEOUT_MS: 6000 KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 KAFKA_PROCESS_ROLES: broker,controller KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093 KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092 KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER KAFKA_LOG_DIRS: /var/lib/kafka/data # Broker cap on a single record. The 1 MiB default rejects CDC change events for # rows with large JSON/text/blob columns (RecordTooLargeException -> Debezium task # FAILED -> restart loop). LOCKSTEP with CONNECT_PRODUCER_MAX_REQUEST_SIZE on # kafka-connect below and with the sink worker's ReaderConfig.MaxBytes, which must # exceed it. docker-compose.yml has carried this since the bug was first hit; the # quickstart shipped the producer half alone. KAFKA_MESSAGE_MAX_BYTES: 15728640 # Retention for topics rsync.ai does not create itself — Debezium auto-creates # rsync.cdc-* and those inherit the BROKER default, not kafka/topology.go. # 168h/delete matches Apache's own defaults, so nothing changes today; it # pins the 7-day policy so a future image default cannot silently restore # unbounded growth. See docker-compose.yml for the full note. Per-topic # retention.ms=-1 on the Debezium schema-history topic wins over this, # which is what that one exception relies on. KAFKA_LOG_RETENTION_HOURS: 168 KAFKA_LOG_CLEANUP_POLICY: delete CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk KAFKA_HEAP_OPTS: "-Xms256m -Xmx512m -XX:+ExitOnOutOfMemoryError" # Kafka's two internal topics default to 50 partitions EACH. On a single # broker that is 100 partition directories, 100 index/timeindex file pairs # and 100 sets of in-memory metadata, bought for a stack whose real traffic # is a handful of 3-partition topics. 8 keeps consumer-group parallelism far # above anything this deployment uses. # # Both are read ONLY when the topic is first created. On an existing cluster # this is a harmless no-op — the topic keeps whatever partition count it was # born with — so it cannot be applied retroactively by editing this file. KAFKA_OFFSETS_TOPIC_NUM_PARTITIONS: 8 KAFKA_TRANSACTION_STATE_LOG_NUM_PARTITIONS: 8 volumes: - kafka_data:/var/lib/kafka/data healthcheck: test: /opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1 interval: 10s timeout: 5s retries: 10 start_period: 30s kafka-init: image: apache/kafka:3.7.0 container_name: rsync-ai-kafka-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Run-once job, but it starts a JVM AdminClient. Capped so the topic # creation pass cannot compete with the broker it just waited for. mem_limit: 512m depends_on: kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false entrypoint: ["/bin/bash", "-c"] environment: # Bare `-`, matching kafkaclient.TopicPrefix(): an explicitly empty prefix # is the migration lever for a deployment with live unprefixed topics, and # `:-` would silently overwrite that choice with the default. # The bundled broker by default; a BYO cluster when KAFKA_BROKERS is set. KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} # Same two names the Go side reads (backend-orchestrator/internal/kafka/ # replication.go EnvReplicationFactor / EnvMinInsyncReplicas), so ONE operator # setting covers the bootstrapper below and the runtime topic-creation path. # The bundled single-broker Kafka can only serve RF=1, but this file is also # the starting point for a self-host pointed at a real cluster via # KAFKA_BROKERS, and there RF=1 topics go unavailable during routine rolling # maintenance. Empty means "operator stated nothing" -> the default below. KAFKA_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR:-} KAFKA_MIN_INSYNC_REPLICAS: ${KAFKA_MIN_INSYNC_REPLICAS:-} # The topic bootstrapper is a Kafka CLIENT too. Without these it speaks # PLAINTEXT to a SASL listener, which does not error -- it BLOCKS in the # handshake until the CLI's 5-minute default gives up, so the install # appears to hang rather than to be misconfigured. <<: *kafka-security # Single-element list, not a bare block scalar: Compose splits a string # command into argv, which left `bash -c` running only "export" and exiting 0 # without creating a single topic. `docker compose config` must show ONE item. command: - | export PATH="/opt/kafka/bin:$$PATH"; # Was hardcoded to kafka:29092 in all four calls below, so the RF / # min-insync knobs above could never reach the real cluster they were # written for. Honour KAFKA_BROKERS like every other service does. B="$${KAFKA_BROKERS:-kafka:29092}"; P="$${KAFKA_TOPIC_PREFIX-rsync.}"; P="$$(printf '%s' "$$P" | tr -cd 'a-zA-Z0-9._-')"; case "$$P" in ""|*[._-]) ;; *) P="$$P." ;; esac; echo "Topic prefix: $${P:-}"; RF="$${KAFKA_REPLICATION_FACTOR:-1}"; case "$$RF" in ''|*[!0-9]*) RF=1 ;; esac; if [ "$$RF" -lt 1 ]; then RF=1; fi; MISR="$${KAFKA_MIN_INSYNC_REPLICAS:-}"; case "$$MISR" in *[!0-9]*) MISR='' ;; esac; if [ -n "$$MISR" ] && [ "$$MISR" -lt 1 ]; then MISR='' ; fi; MISR_ARG=""; if [ -n "$$MISR" ]; then if [ "$$MISR" -gt "$$RF" ]; then echo "WARN: KAFKA_MIN_INSYNC_REPLICAS=$$MISR exceeds replication factor $$RF - clamping to $$RF (min.insync.replicas > RF creates a topic that every acks=all produce rejects with NOT_ENOUGH_REPLICAS on brokers older than 3.7)"; MISR="$$RF"; fi; MISR_ARG="--config min.insync.replicas=$$MISR"; fi; echo "Replication factor: $$RF (min ISR: $${MISR:-})"; # ── AdminClient config ──────────────────────────────────────────────── # Written even for PLAINTEXT, because the timeouts are not optional. A # client whose security config does not match the listener does NOT get # an error -- it BLOCKS. Mirrors deploy/helm/rsync-ai/templates/jobs/ # kafka-init.yaml; the two are hand-kept in lockstep because compose can # ship no file but this one (install.sh downloads only this compose file), # so the script cannot be factored out. CFG=/tmp/kafka-client.properties; umask 077; { echo "request.timeout.ms=10000"; echo "default.api.timeout.ms=15000"; echo "security.protocol=$${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}"; } > "$$CFG"; # type=PEM is the whole point (KIP-651, Kafka 2.7+): without it the JVM # defaults to JKS, fails to parse the PEM, and names neither the file nor # the setting. The Go and Python clients read the same file unconverted. if [ -n "$${KAFKA_SSL_CA_LOCATION:-}" ]; then { echo "ssl.truststore.type=PEM"; echo "ssl.truststore.location=$$KAFKA_SSL_CA_LOCATION"; } >> "$$CFG"; fi; # mTLS. A JVM PEM keystore is ONE file holding chain + key; it cannot # take the two paths the Go and Python clients read, and pointing it at # the cert half alone yields "Failed to load PEM keystore" -- a message # naming the file, so it reads like a bad certificate, not a missing key. # An explicit KAFKA_SSL_KEYSTORE_LOCATION wins; otherwise the file is # built here from the pair, with the same checks the kafka-connect # image's entrypoint (connect-entrypoint.sh) makes on the same input. KS="$${KAFKA_SSL_KEYSTORE_LOCATION:-}"; CRT="$${KAFKA_SSL_CERT_LOCATION:-}"; KEY="$${KAFKA_SSL_KEY_LOCATION:-}"; if [ -z "$$KS" ] && [ -n "$$CRT$$KEY" ]; then # Half a pair is a config error, not "no mTLS": the broker would # reject the handshake anyway, with an alert naming neither variable. if [ -z "$$CRT" ] || [ -z "$$KEY" ]; then echo "FATAL: KAFKA_SSL_CERT_LOCATION and KAFKA_SSL_KEY_LOCATION must be set together (mTLS needs both halves of the keypair)." >&2; exit 1; fi; for f in "$$CRT" "$$KEY"; do [ -r "$$f" ] || { echo "FATAL: $$f (from KAFKA_SSL_CERT_LOCATION / KAFKA_SSL_KEY_LOCATION) is not a readable file in this container." >&2; exit 1; }; done; grep -q -- '-----BEGIN CERTIFICATE-----' "$$CRT" || { echo "FATAL: KAFKA_SSL_CERT_LOCATION=$$CRT holds no PEM certificate (-----BEGIN CERTIFICATE-----)." >&2; exit 1; }; grep -q -- '-----BEGIN PRIVATE KEY' "$$KEY" || { echo "FATAL: KAFKA_SSL_KEY_LOCATION=$$KEY must be an unencrypted PKCS#8 key (BEGIN PRIVATE KEY)." >&2; echo " The Kafka CLI is a JVM and cannot load PKCS#1, SEC1 or encrypted keys. Convert once:" >&2; echo " openssl pkcs8 -topk8 -nocrypt -in client.key -out client.pk8.key" >&2; echo " The Go and Python services read the PKCS#8 file unchanged." >&2; exit 1; }; # umask 077 above: owner-only, like the properties file beside it. KS=/tmp/kafka-client.pem; { cat "$$CRT"; echo; cat "$$KEY"; } > "$$KS"; fi; if [ -n "$$KS" ]; then { echo "ssl.keystore.type=PEM"; echo "ssl.keystore.location=$$KS"; } >> "$$CFG"; fi; # KAFKA_SSL_SKIP_VERIFY is the spelling every service reads; the anchor # at the top of this file fills it in from either name. if [ "$${KAFKA_SSL_SKIP_VERIFY:-}" = "true" ]; then # As close as a JVM gets: drops the HOSTNAME check only, the chain is # still validated. The Go client skips both, so this job can fail on a # certificate the data plane accepted. Deliberately not papered over. echo "ssl.endpoint.identification.algorithm=" >> "$$CFG"; fi; if [ -n "$${KAFKA_SASL_MECHANISM:-}" ]; then # The login module is a FUNCTION of the mechanism; hardcoding one turns # a mechanism swap into a handshake failure naming the module. case "$$KAFKA_SASL_MECHANISM" in SCRAM-SHA-256|SCRAM-SHA-512) MOD=org.apache.kafka.common.security.scram.ScramLoginModule ;; PLAIN) MOD=org.apache.kafka.common.security.plain.PlainLoginModule ;; OAUTHBEARER) MOD=org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule ;; *) echo "FATAL: kafka-init cannot build a JAAS config for sasl.mechanism=$$KAFKA_SASL_MECHANISM" >&2; echo " Supported here: PLAIN, SCRAM-SHA-256, SCRAM-SHA-512, OAUTHBEARER." >&2; echo " AWS_MSK_IAM is NOT one of them: the Go data plane implements it" >&2; echo " (shared/go/kafkaclient/tokenauth/msk.go) but this JVM CLI image does" >&2; echo " not. Create the topics out of band and drop kafka-init -- see" >&2; echo " docs/deployment/kafka-acls.md." >&2; exit 1 ;; esac; # The password crosses TWO grammars (a Java .properties file, then the # StreamTokenizer-backed sasl.jaas.config value) and each eats a level # of backslash. Escaping once corrupts identically to not escaping: # C:\Users\svc arrives as C:Userssvc and the broker reports an ordinary # auth failure, so the password looks wrong rather than mangled. esc() { printf '%s' "$$1" | sed 's/\\/\\\\/g; s/"/\\"/g' | sed 's/\\/\\\\/g'; }; # True when fetching a token from $$1 would put the client secret on # a wire. Loopback is exempt because the request never leaves the # host. Mirrors _insecure_token_endpoint in # scripts/kafka-init-new-topics.sh -- change both. _ins_tok() { case "$$1" in https://*|HTTPS://*) return 1 ;; esac; H=$${1#*://}; H=$${H%%/*}; H=$${H%%\?*}; H=$${H##*@}; case "$$H" in \[*) H=$${H#\[}; H=$${H%%]*} ;; *) H=$${H%%:*} ;; esac; H=$$(printf '%s' "$$H" | tr '[:upper:]' '[:lower:]'); H=$${H%.}; [ -n "$$H" ] || return 1; case "$$H" in localhost|*.localhost|::1|0:0:0:0:0:0:0:1) return 1 ;; 127.*) case "$$H" in *[!0-9.]*) return 0 ;; *) return 1 ;; esac ;; esac; return 0; }; if [ "$$KAFKA_SASL_MECHANISM" = "OAUTHBEARER" ]; then for v in KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT KAFKA_SASL_OAUTHBEARER_CLIENT_ID KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET; do eval "set_to=\$${$$v:-}"; if [ -z "$$set_to" ]; then echo "FATAL: sasl.mechanism=OAUTHBEARER but $$v is empty." >&2; exit 1; fi; done; # The client-credentials grant POSTs the client secret to the token # endpoint on every fetch, so http off loopback hands a credential # that never expires to anyone on the path. Same rule and opt-out # as the Go, Python and Kafka Connect halves. if [ "$${KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT:-}" != "true" ] && _ins_tok "$$KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT"; then echo "FATAL: KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT=$$KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT is http and its host is not loopback." >&2; echo " KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET would be sent in the clear on every token fetch." >&2; echo " Use https, a loopback address, or set KAFKA_SASL_OAUTHBEARER_ALLOW_INSECURE_TOKEN_ENDPOINT=true for a disposable test rig." >&2; exit 1; fi; # kafka-clients 3.9.2 gates the token endpoint on a JVM system # property, and UNSET MEANS ALLOW ANY URL. case "$${KAFKA_OPTS:-}" in *org.apache.kafka.sasl.oauthbearer.allowed.urls*) ;; *) export KAFKA_OPTS="$${KAFKA_OPTS:+$$KAFKA_OPTS }-Dorg.apache.kafka.sasl.oauthbearer.allowed.urls=$$KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT" ;; esac; OPTS=$$(printf ' clientId="%s" clientSecret="%s"' "$$(esc "$$KAFKA_SASL_OAUTHBEARER_CLIENT_ID")" "$$(esc "$$KAFKA_SASL_OAUTHBEARER_CLIENT_SECRET")"); if [ -n "$${KAFKA_SASL_OAUTHBEARER_EXTENSIONS:-}" ]; then OLDIFS=$$IFS; IFS=,; for kv in $$KAFKA_SASL_OAUTHBEARER_EXTENSIONS; do IFS=$$OLDIFS; case "$$kv" in *=*) ;; *) echo "FATAL: extensions entry '$$kv' has no '=' separator." >&2; exit 1 ;; esac; EK=$${kv%%=*}; EV=$${kv#*=}; # 'auth' is the OAUTHBEARER frame's own key; sending it as an # extension makes a malformed frame the broker rejects without # naming the extension. if [ -z "$$EK" ] || [ "$$EK" = "auth" ]; then echo "FATAL: extensions has an empty or reserved name in '$$kv'." >&2; exit 1; fi; OPTS=$$OPTS$$(printf ' extension_%s="%s"' "$$EK" "$$(esc "$$EV")"); IFS=,; done; IFS=$$OLDIFS; fi; if [ -n "$${KAFKA_SASL_OAUTHBEARER_SCOPE:-}" ]; then OPTS=$$OPTS$$(printf ' scope="%s"' "$$(esc "$$KAFKA_SASL_OAUTHBEARER_SCOPE")"); fi; { echo "sasl.mechanism=$$KAFKA_SASL_MECHANISM"; printf 'sasl.jaas.config=%s required%s;\n' "$$MOD" "$$OPTS"; # Without the handler class OAuthBearerLoginModule falls back to # its UNSECURED default and mints a self-signed JWS; the broker # then rejects a token it cannot validate, and the error describes # the token rather than the missing handler. echo "sasl.login.callback.handler.class=org.apache.kafka.common.security.oauthbearer.secured.OAuthBearerLoginCallbackHandler"; echo "sasl.oauthbearer.token.endpoint.url=$$KAFKA_SASL_OAUTHBEARER_TOKEN_ENDPOINT"; } >> "$$CFG"; else if [ -z "$${KAFKA_SASL_USERNAME:-}" ] || [ -z "$${KAFKA_SASL_PASSWORD:-}" ]; then echo "FATAL: sasl.mechanism=$$KAFKA_SASL_MECHANISM but KAFKA_SASL_USERNAME or KAFKA_SASL_PASSWORD is empty." >&2; exit 1; fi; { echo "sasl.mechanism=$$KAFKA_SASL_MECHANISM"; printf 'sasl.jaas.config=%s required username="%s" password="%s";\n' "$$MOD" "$$(esc "$$KAFKA_SASL_USERNAME")" "$$(esc "$$KAFKA_SASL_PASSWORD")"; } >> "$$CFG"; fi; fi; CC="--command-config $$CFG"; # Wait for the broker. On a BYO cluster this is the DNS/auth check too -- # and it must run BEFORE --if-not-exists, whose "already exists" success # would otherwise mask an auth failure as a no-op. READY=""; i=1; while [ "$$i" -le 30 ]; do if kafka-topics.sh --bootstrap-server "$$B" $$CC --list >/dev/null 2>&1; then READY=1; break; fi; echo "waiting for kafka at $$B ($$i/30)..."; sleep 5; i=$$((i + 1)); done; if [ -z "$$READY" ]; then echo "FATAL: could not list topics on $$B" >&2; echo " security.protocol=$${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT} sasl.mechanism=$${KAFKA_SASL_MECHANISM:-} sasl username=$${KAFKA_SASL_USERNAME:-}" >&2; echo " If the credentials are set but security.protocol is not, the client" >&2; echo " connected ANONYMOUSLY and the broker rejected it: set KAFKA_SECURITY_PROTOCOL." >&2; exit 1; fi; # One config for all three, the one the orchestrator's topology # provisioner (backend-orchestrator/internal/kafka/topology.go) gives the # same names. No creator alters a topic that already exists, so whichever # runs first fixes the config for good; they must agree. Retention is # bounded at 7 days, pipeline.domain.events included. Same list as # deploy/helm/rsync-ai/templates/jobs/kafka-init.yaml. TC="--config cleanup.policy=delete --config retention.ms=604800000 --config compression.type=snappy"; for t in pipeline.domain.events pii.scan.request pii.scan.response; do kafka-topics.sh --create --if-not-exists --bootstrap-server "$$B" $$CC --topic "$${P}$$t" --partitions 3 --replication-factor "$$RF" $$MISR_ARG $$TC || exit 1; done && echo "✅ Kafka topics ready" restart: "no" minio: # Pinned: this is the one-command install. An unpinned tag means two operators running # the same install command on different days get different object stores. Pinned by # digest because cgr.dev/chainguard serves only `latest` (and `latest-dev`) # anonymously, so a digest is the only pin there is. # Chainguard's build, not MinIO's. MinIO revoked anonymous pulls on # docker.io/minio/* on 2026-09-12 and then on quay.io/minio/* -- every tag, server # and `mc` client alike, 401 to an unauthenticated client. This file is the # one-command install: `docker compose pull` died here before a single container # started, and the error Docker reports for a walled repository is "repository # does not exist or may require 'docker login'", which reads like a typo rather # than a vendor policy change. This image carries both `minio` (its entrypoint) # and `mc`, which the healthcheck below uses; it has no curl. image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 # Root, like the image this replaces. A volume written by v0.1.5 is root-owned # 0755, and this image's default user (65532) cannot write it: the server exits # "Unable to write to the backend". Nothing here waits on MinIO's health, so # without this the upgrade would report success with a dead object store. user: "0:0" container_name: rsync-ai-minio logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 768m environment: MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} command: server /data --console-address ":9001" volumes: - minio_data:/data healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 # One-shot: create the `pipeline-data` bucket every batch stage and claim-check # writes into, and put the staging-prefix expiry backstop on it. Nothing else on # this install creates it: the minio connector only head_bucket()s and # put_object()s, and the orchestrator has no MakeBucket. docker-compose.yml and # the Helm chart each carry this step (minio-lifecycle-init / the `-minio-mb` Job); # this file, being standalone, has to repeat it or a fresh install stages into a # bucket that does not exist. Same image and digest as `minio` above, which ships # `mc` and a shell. The 7-day expiry must stay >= the batch-topic retention, for # the reason spelled out on minio-lifecycle-init in docker-compose.yml. minio-init: image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: rsync-ai-minio-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: minio: condition: service_healthy environment: MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} entrypoint: ["/bin/sh", "-c"] command: - | set -e until mc alias set local http://minio:9000 "$$MINIO_ACCESS_KEY_ID" "$$MINIO_SECRET_ACCESS_KEY"; do echo "waiting for minio..."; sleep 2; done mc mb --ignore-existing local/pipeline-data mc ilm rule add local/pipeline-data --expire-days 7 --prefix staging/ \ || echo "WARN: could not set the staging expiry rule; the sink's own delete is the primary cleanup" echo "minio-init: pipeline-data bucket ready" restart: "no" # The blob lane's staging store: a second MinIO, on the connector network ONLY. # Blob passthrough (copy raw files between object stores) has the SOURCE # connector stage each object here and the DESTINATION connector fetch it, and # connectors join only rsync-ai-mcp (SEC-M-06). `minio` above is on `default` # only, so a connector could not resolve it; bridging it would hand every # connector the store that holds every pipeline's staged claim-check rows. This # one holds only staged blobs, under its own login, and nothing on `default` # can reach it (the orchestrator only passes its URL along). Same image and # digest as `minio`. The name must keep ":9000": the connectors' staging client # uses path-style addressing only for an endpoint with "minio", "localhost" or # ":9000" in it (base_connector.py _get_staging_client). blob-staging: image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: rsync-ai-blob-staging logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 512m environment: MINIO_ROOT_USER: ${BLOB_STAGING_ACCESS_KEY:?BLOB_STAGING_ACCESS_KEY is required} MINIO_ROOT_PASSWORD: ${BLOB_STAGING_SECRET_KEY:?BLOB_STAGING_SECRET_KEY is required} command: server /data volumes: - blob_staging_data:/data networks: - rsync-ai-mcp healthcheck: test: ["CMD", "mc", "ready", "local"] interval: 10s timeout: 5s retries: 5 # Bucket + expiry for blob-staging. Nothing deletes a staged blob per message: # blobs are content-addressed and shared between messages, so this rule is the # only cleanup. 7 days, like minio-init's, and for the same reason. blob-staging-init: image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 container_name: rsync-ai-blob-staging-init logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" depends_on: blob-staging: condition: service_healthy environment: BLOB_STAGING_ACCESS_KEY: ${BLOB_STAGING_ACCESS_KEY:?BLOB_STAGING_ACCESS_KEY is required} BLOB_STAGING_SECRET_KEY: ${BLOB_STAGING_SECRET_KEY:?BLOB_STAGING_SECRET_KEY is required} networks: - rsync-ai-mcp entrypoint: ["/bin/sh", "-c"] command: - | set -e until mc alias set local http://blob-staging:9000 "$$BLOB_STAGING_ACCESS_KEY" "$$BLOB_STAGING_SECRET_KEY"; do echo "waiting for blob-staging..."; sleep 2; done mc mb --ignore-existing local/pipeline-data mc ilm rule add local/pipeline-data --expire-days 7 --prefix staging/ \ || echo "WARN: could not set the blob staging expiry rule" echo "blob-staging-init: pipeline-data bucket ready" restart: "no" # ─── TEMPORAL ──────────────────────────────────────────────────────────────── temporal: image: temporalio/auto-setup:1.22.7 container_name: rsync-ai-temporal logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 1g depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false environment: DB: postgresql DB_PORT: ${POSTGRES_PORT:-5432} POSTGRES_USER: ${POSTGRES_USER:-rsync} POSTGRES_PWD: ${POSTGRES_PASSWORD} POSTGRES_SEEDS: ${POSTGRES_HOST:-postgres} # TLS to the database. Temporal does NOT read POSTGRES_SSLMODE -- it has # its own names, verified against the pinned image's # /etc/temporal/config/config_template.yaml (1.22.4; byte-identical in # 1.22.7). SQL_TLS_ENABLED gates all four sql blocks (default store + # visibility) at once. # Managed Postgres that mandates TLS (Azure Database, RDS with # rds.force_ssl=1) needs this true or auto-setup cannot create its # schemas -- and then no workflow engine starts and every pipeline fails. SQL_TLS_ENABLED: ${POSTGRES_TLS_ENABLED:-false} SQL_HOST_VERIFICATION: ${POSTGRES_TLS_VERIFY_HOST:-false} SQL_HOST_NAME: ${POSTGRES_TLS_SERVER_NAME:-} SQL_CA: ${POSTGRES_TLS_CA_FILE:-} # The SECOND TLS family. auto-setup runs temporal-sql-tool to create the # schemas BEFORE starting the server, and that is a different binary with # its own flags -- verified from `temporal-sql-tool --help` on this pinned # image, not from the server's config template. Set only the four names # above and the schema step still connects in plaintext, so a database # that refuses plaintext kills auto-setup before the server ever binds: # no workflow engine, every pipeline hangs, and no TLS error in any log. SQL_TLS: ${POSTGRES_TLS_ENABLED:-false} SQL_TLS_CA_FILE: ${POSTGRES_TLS_CA_FILE:-} SQL_TLS_SERVER_NAME: ${POSTGRES_TLS_SERVER_NAME:-} # Stated as "disable" -- the INVERSE of SQL_HOST_VERIFICATION above. # Compose interpolation cannot negate, so this is its own key rather than # a derivation. Turning on host verification means setting BOTH: # POSTGRES_TLS_VERIFY_HOST=true and POSTGRES_TLS_SKIP_HOST_VERIFY=false. SQL_TLS_DISABLE_HOST_VERIFICATION: ${POSTGRES_TLS_SKIP_HOST_VERIFY:-true} # config/dynamicconfig/docker.yaml -- NOT development-sql.yaml. Both # docker-compose.yml and the Helm chart name development-sql.yaml, but both # also mount it (deploy/temporal, and a ConfigMap). This file cannot: # install.sh downloads this compose file ALONE, with no repo checkout, so # there is nothing on the host to bind-mount. Naming a file the image does # not ship makes the server exit at boot -- "unable to validate dynamic # config: no such file or directory" -- and `restart: unless-stopped` turns # that into a permanent crash loop with no workflow engine, i.e. every # pipeline hangs. docker.yaml ships in the image (empty) and is the image's # own default. The only setting the mounted file carried is # forceSearchAttributesCacheRefreshOnRead, which is inert here: this # product registers no custom search attributes. Add one and this needs a # real file again -- via `configs:` content, not a bind mount. DYNAMIC_CONFIG_FILE_PATH: config/dynamicconfig/docker.yaml healthcheck: test: ["CMD", "tctl", "--address", "temporal:7233", "workflow", "list"] interval: 10s timeout: 5s retries: 10 # ─── APPLICATION SERVICES ──────────────────────────────────────────────────── # The COMMUNITY image, not the private ./llm-service/Dockerfile one. Built from # Dockerfile.community: an allowlist COPY cut against llm-service/oss-strip-list.txt, # so the connector-generation moat (tool_generator/config + the agents that read it) # is absent from every layer. It carries the /chat gateway — the only # pipeline-creation path in the product — plus the Data Explorer, suggestions and # the PII scanner. See .github/workflows/docker-publish.yml. llm-service: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/llm-service-oss:${RSYNC_VERSION:-latest} container_name: rsync-ai-llm-service logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 1536m # The rate limiter degrades to per-process in-memory counters the first time # it cannot reach Redis, and stays degraded for the life of the process. Wait # for the server so that decision is never made against a Redis that was # merely still booting. Same edge the orchestrator, adapter and gateway # already declare. depends_on: redis: condition: service_healthy # OLLAMA_URL below defaults to host.docker.internal, which Docker Desktop # (macOS/Windows) resolves for free and Linux Docker does NOT -- so without # this line the offline provider fails to resolve its own default on exactly # the platform every server install runs, and an operator who sets # LLM_PROVIDER=ollama for an Ollama on the host is on exactly that path. # # Note this only makes the NAME resolve; Ollama must also be listening off # loopback (OLLAMA_HOST=0.0.0.0), since host-gateway is the bridge address, # not 127.0.0.1. extra_hosts: - "host.docker.internal:host-gateway" environment: # Kafka client security (BYO/managed broker). See x-kafka-security at the # top of this file; every key defaults to empty and empty means unset. <<: *kafka-security PORT: 5000 # LLM cost logging (src/utils/llm_cost.py) writes llm_usage_events into the # database api-gateway migrates. Unset, every LLM call goes unmetered. DATABASE_URL: postgres://${POSTGRES_USER:-rsync}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-pipeline_db}?sslmode=${POSTGRES_SSLMODE:-disable} # Sent as X-Internal-Secret on service-to-service calls to the orchestrator # topology API (requirePrincipal). Empty = topology calls 401 in production. INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} # Soft (not :?) so a fully-offline Ollama install (no OpenAI key) can boot. # Provider selection is by LLM_PROVIDER; the client falls back to Ollama when # no cloud key is present (see llm-service openai_client.py). OPENAI_API_KEY: ${OPENAI_API_KEY:-} # LLM_PROVIDER=openai means "speaks the OpenAI wire protocol", not "is # OpenAI": Vertex AI, Azure, Groq, OpenRouter, Together and vLLM all serve # that protocol, and OPENAI_BASE_URL is the only var that points the client # at one of them. There is no env_file on this service, so without this line # an operator who sets OPENAI_BASE_URL in .env watches it be silently # ignored and the stack keeps talking to api.openai.com — the one endpoint # their key is not for. docker-compose.prod.yml:585 already passes it, which # is what makes the omission here an oversight rather than a policy. OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} # gcp-metadata = take the bearer from this VM's service account and refresh it # before its hour is up, which is what Vertex AI needs. Empty = OPENAI_API_KEY # as written. OPENAI_API_KEY_SOURCE: ${OPENAI_API_KEY_SOURCE:-} # LLM_PROVIDER=groq and LLM_PROVIDER=azure read these. Until they were listed # here neither provider could be set up from .env: the key never reached the # container and the service quietly fell back to Ollama. GROQ_API_KEY: ${GROQ_API_KEY:-} AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-} AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-} AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-} LLM_PROVIDER: ${LLM_PROVIDER:-openai} # Empty, not gpt-4o. Empty lets each provider use its own default model # (openai_client.py get_default_model); gpt-4o is a name only OpenAI's catalog # has, so a Groq, Azure or Ollama stack with no LLM_MODEL got 404 on every call. LLM_MODEL: ${LLM_MODEL:-} # Empty = 4096 (openai_client.py with_reasoning_headroom): room a thinking model # reasons in before it answers. 0 for a model with a small output limit. LLM_REASONING_TOKEN_HEADROOM: ${LLM_REASONING_TOKEN_HEADROOM:-} OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} # OLLAMA_BASE_URL wins over OLLAMA_URL (openai_client.py _ollama_base_url). # docs/deployment/env-vars.md tells operators to set this one, so it needs a # delivery path here or the value they set is silently ignored. OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} # Air-gapped / on-prem: pins ONLY the Data Explorer to Ollama so schema # metadata never leaves the deployment. There is no env_file on this service, # so every var the operator is allowed to set must be listed explicitly — # without this line EXPLORER_OFFLINE_ONLY=true in .env never reaches the # container and the privacy guarantee silently does not hold. EXPLORER_OFFLINE_ONLY: ${EXPLORER_OFFLINE_ONLY:-false} EXPLORER_LLM_PROVIDER: ${EXPLORER_LLM_PROVIDER:-} EXPLORER_TABLE_LINK_MODEL: ${EXPLORER_TABLE_LINK_MODEL:-} EXPLORER_COLUMN_LINK_MODEL: ${EXPLORER_COLUMN_LINK_MODEL:-} EXPLORER_NEXT_STEPS_MODEL: ${EXPLORER_NEXT_STEPS_MODEL:-} EXPLORER_QUERY_SPEC_MODEL: ${EXPLORER_QUERY_SPEC_MODEL:-} EXPLORER_SQL_MODEL: ${EXPLORER_SQL_MODEL:-} EXPLORER_SQL_MODEL_MYSQL: ${EXPLORER_SQL_MODEL_MYSQL:-} # /agents/rank-tables (table recommendation during pipeline setup). Unset # inherits EXPLORER_LLM_PROVIDER then LLM_PROVIDER; EXPLORER_OFFLINE_ONLY # overrides both. RANK_TABLES_LLM_PROVIDER: ${RANK_TABLES_LLM_PROVIDER:-} RANK_TABLES_MODEL: ${RANK_TABLES_MODEL:-} EXPLORER_SQL_PROVIDER: ${EXPLORER_SQL_PROVIDER:-} EXPLORER_SQL_ALLOW_ONLINE: ${EXPLORER_SQL_ALLOW_ONLINE:-} EXPLORER_SQL_OPENAI_MODEL: ${EXPLORER_SQL_OPENAI_MODEL:-} EXPLORER_SQL_FALLBACK_MODELS: ${EXPLORER_SQL_FALLBACK_MODELS:-} OLLAMA_MODEL: ${OLLAMA_MODEL:-} # The async PII-scan consumer reads these; without them it falls back to # localhost:9092 and its startup failure is swallowed into a log warning. KAFKA_BROKERS: "${KAFKA_BROKERS:-kafka:29092}" KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} USE_MOCK_LLM: "false" # The gateway's rate limiter (src/utils/ratelimit.py get_limiter) builds its # Redis URL from these four. With none of them delivered it still resolved # the host "redis" by luck of the default, then failed AUTH against a server # this file always starts with requirepass — so every limited route ran on a # per-replica in-memory counter while the logs said Redis. Names and values # match the orchestrator block below; REDIS_PASSWORD is the same one the # redis service is started with. REDIS_HOST: redis REDIS_PORT: 6379 REDIS_DB: 0 REDIS_PASSWORD: ${REDIS_PASSWORD} # This bundle ships no OTLP collector. The gRPC exporter connects lazily, so an absent collector is not a # startup error: the BatchSpanProcessor just queues and retries every batch # at localhost:4317 for the life of the process, blocking a worker for the # full export timeout each time. The code default is true (cloud runs # a collector); only this file turns it off, per the OSS/cloud split rule in # CLAUDE.md. Spans are still created, they are simply not shipped — # observability here is `docker logs`. OTEL_ENABLED: "false" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: production command: ["python", "-m", "src.gateway.main"] healthcheck: test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:5000/health', timeout=3)"] interval: 15s timeout: 5s retries: 5 start_period: 20s # One-shot init: seed the mcp_connectors volume so a COLD self-host boots WITH # connectors. Without it the volume is created empty and every pipeline run # resolves zero connectors — a stack that comes up green and cannot move a row. # Runs the image's seed-connectors.sh on every boot: a no-op on the same # release, a refresh of the shipped connectors after an upgrade (generated # connectors kept), and the tree handed to tool-generator's uid. No `command:` # here -- an inline copy of the logic is how an upgrade once kept the old # connector code. Same definition as docker-compose.oss.yml so layering the # two merges cleanly. connector-seed: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/connector-seed:${RSYNC_VERSION:-latest} container_name: rsync-ai-connector-seed restart: "no" logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" volumes: - mcp_connectors:/target # Connector Deployer (SEC-H-02): THE trusted socket-holder. tool-generator now # drops root + the docker socket and delegates all JIT connector build/run to # this small no-LLM service over an authenticated internal RPC. The security # property is allow-by-construction (the deployer builds the Docker create # payload itself with a hardcoded-safe HostConfig); because the HOST daemon # creates the container it still joins rsync-ai-mcp by name/alias so the sink # resolves JIT destinations. Same topology on Docker Desktop as in cloud — the # deployer holds the socket in every stack. See # docs/internal/jit-connector-sandboxing.md + connector-deployer/CONTRACT.md. connector-deployer: # An IMAGE, not a `build:`. This file is downloaded on its own by install.sh # into an otherwise empty directory, so a build context is a path that does # not exist on the customer's box -- `docker compose up` aborts the whole # stack with `unable to prepare context: path "./connector-deployer" not # found`. Published by docker-publish.yml alongside the other core services. image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/connector-deployer:${RSYNC_VERSION:-latest} container_name: rsync-ai-connector-deployer logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 512m environment: # Same app-level fail-closed posture the tool-generator carried: ENVIRONMENT # production => the deployer's S2S gate 503s when the secret is unset. The # compose-level :? guard lives in prod.yml ONLY (OSS/cloud split rule), so # here the secret defaults to empty for local Docker Desktop runs. ENVIRONMENT: production LOG_FORMAT: json LOG_LEVEL: info # Rotation for the connector containers THIS service creates -- compose's # `logging:` blocks only reach the services compose itself starts. RSYNC_LOG_MAX_SIZE: ${RSYNC_LOG_MAX_SIZE:-10m} RSYNC_LOG_MAX_FILE: ${RSYNC_LOG_MAX_FILE:-3} INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} DEPLOYER_DOCKER_NETWORK: rsync-ai-mcp MCP_SHARED_NETWORK: rsync-ai-mcp OAUTH_TOKENS_VOLUME_NAME: rsync-ai-oauth-tokens # Root + RW docker.sock live HERE now, not on tool-generator. user: "0:0" networks: - default - rsync-ai-mcp volumes: # Connector artifacts, READ-ONLY (build contexts only). - mcp_connectors:/app/shared/mcp-connectors:ro # RW docker socket for build/run of JIT connector containers. - /var/run/docker.sock:/var/run/docker.sock # Connector LIFECYCLE, not connector generation. This image (Dockerfile.oss) # serves GET /health + POST /v1/deploy and nothing else — it carries none of the # LLM/NLP dependency tree and no generation code, which is why it is publishable. # /v1/deploy is the one endpoint the Go data plane calls # (backend-orchestrator server_manager.go -> TOOL_GENERATOR_URL) for connector # self-heal and pinned-version JIT builds, so the service name stays # `tool-generator` — every peer resolves it by that hostname. tool-generator: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/connector-lifecycle:${RSYNC_VERSION:-latest} container_name: rsync-ai-tool-generator logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 1g # No llm-service edge: the lifecycle image never calls the gateway. It waits for # the connector volume to be seeded so /v1/deploy can resolve connectors on disk, # and for the deployer it delegates every build/run to. depends_on: connector-seed: condition: service_completed_successfully connector-deployer: condition: service_started environment: PORT: 5010 TOOLS_DIR: /app/shared/mcp-connectors # Universal default: managed mode on. Tool-generator dynamically # builds images and starts user MCP containers when the # orchestrator's Preflight calls /v1/deploy. Override to "false" # if you'd rather manage user MCPs via a separate compose stack. RSYNC_MANAGED_CONNECTORS: ${RSYNC_MANAGED_CONNECTORS:-true} DOCKER_NETWORK: rsync-ai-mcp RSYNC_EDITION: community LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: production # SEC-H-03: S2S secret for /v1/deploy (X-Internal-Secret). Unset => no-op. INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} # SEC-H-02: managed-mode JIT build/run is delegated to the connector-deployer # over this internal RPC; tool-generator no longer touches the docker socket. DEPLOYER_URL: http://connector-deployer:5011 command: ["python", "-m", "src.lifecycle.main"] # SEC-H-02: runs the image's default unprivileged appuser — no root, no # docker.sock. The connector-deployer is the sole trusted socket holder. networks: - default - rsync-ai-mcp volumes: - mcp_connectors:/app/shared/mcp-connectors planner: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/llm-service-oss:${RSYNC_VERSION:-latest} container_name: rsync-ai-planner logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 768m depends_on: llm-service: condition: service_healthy # Same sticky-degrade reason as llm-service above. redis: condition: service_healthy # Same reason as llm-service: OLLAMA_URL below defaults to a name Linux # Docker does not resolve on its own. extra_hosts: - "host.docker.internal:host-gateway" environment: # Kafka client security (BYO/managed broker). See x-kafka-security at the # top of this file; every key defaults to empty and empty means unset. <<: *kafka-security PORT: 5011 OPENAI_API_KEY: ${OPENAI_API_KEY:-} # Same OpenAI-compatible-endpoint story as llm-service above: without this # the planner talks to api.openai.com no matter what the operator set, so a # stack pointed at Vertex/Azure/vLLM would answer chat and fail planning. OPENAI_BASE_URL: ${OPENAI_BASE_URL:-} # The rest of the provider settings, for the same reason as on llm-service: # the planner builds its client with the same helper, and a variable not # listed here never reaches it. OPENAI_API_KEY_SOURCE: ${OPENAI_API_KEY_SOURCE:-} GROQ_API_KEY: ${GROQ_API_KEY:-} AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-} AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-} AZURE_OPENAI_API_VERSION: ${AZURE_OPENAI_API_VERSION:-} AZURE_OPENAI_DEPLOYMENT: ${AZURE_OPENAI_DEPLOYMENT:-} LLM_PROVIDER: ${LLM_PROVIDER:-openai} LLM_MODEL: ${LLM_MODEL:-} LLM_REASONING_TOKEN_HEADROOM: ${LLM_REASONING_TOKEN_HEADROOM:-} OLLAMA_URL: ${OLLAMA_URL:-http://host.docker.internal:11434} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-} OLLAMA_MODEL: ${OLLAMA_MODEL:-} TOOL_GENERATOR_URL: http://tool-generator:5010 API_GATEWAY_URL: http://api-gateway:8080 LLM_GATEWAY_URL: http://llm-service:5000 KAFKA_BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} CONNECTORS_DIR: /app/shared/mcp-connectors # src/agents/planner/service.py imports the same get_limiter as the gateway, # so it needs the same four vars or its limits are per-replica too. REDIS_HOST: redis REDIS_PORT: 6379 REDIS_DB: 0 REDIS_PASSWORD: ${REDIS_PASSWORD} # No collector in this bundle — see the llm-service block above. OTEL_ENABLED: "false" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: production # Plan-time topic sizing posts to the orchestrator's /topology group, which # sits behind requirePrincipal (strategies.py _create_topic_via_topology_agent). INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} command: ["python", "-m", "src.agents.planner.service"] volumes: - mcp_connectors:/app/shared/mcp-connectors:ro orchestrator: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/orchestrator:${RSYNC_VERSION:-latest} container_name: rsync-ai-orchestrator logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped # Repeated from docker-compose.yml, which says why: this file is standalone. stop_grace_period: 45s mem_limit: 2g depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false redis: condition: service_healthy environment: # Kafka client security (BYO/managed broker). See x-kafka-security at the # top of this file; every key defaults to empty and empty means unset. <<: *kafka-security DB_HOST: ${POSTGRES_HOST:-postgres} DB_PORT: ${POSTGRES_PORT:-5432} DB_USER: ${POSTGRES_USER:-rsync} DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: ${POSTGRES_DB:-pipeline_db} # internal/config/config.go:168 reads DB_SSLMODE (default "disable"). # Naming it explicitly matters: a DSN that omits sslmode negotiates # plaintext without complaining, so a BYO database would look connected # while shipping credentials in the clear. DB_SSLMODE: ${POSTGRES_SSLMODE:-disable} KAFKA_BROKERS: "${KAFKA_BROKERS:-kafka:29092}" # Durability for every topic the orchestrator creates at runtime. Read by # internal/kafka/replication.go (EnvReplicationFactor / EnvMinInsyncReplicas). # Empty means "operator stated nothing" -- not zero -- so the built-in # derivation applies (1 on a single broker, min(3, brokers) beyond). # # The pair travels together: min.insync.replicas > replication factor yields a # topic that is created, listed and subscribable, and then rejects every # acks=all produce with NOT_ENOUGH_REPLICAS -- a pipeline that reports running # and moves zero rows. The Go side clamps misr down to the topic's final RF, # so setting only one of these cannot wedge a cluster. Brokers from 3.7 on cap # the effective value at the replica count and are immune; the older managed # clusters a self-host points at are not. See docker-compose.yml for the # measurement. KAFKA_REPLICATION_FACTOR: ${KAFKA_REPLICATION_FACTOR:-} KAFKA_MIN_INSYNC_REPLICAS: ${KAFKA_MIN_INSYNC_REPLICAS:-} # Partition count for every CDC data topic (backend-orchestrator/internal/kafka # partitions.go EnvCDCTopicPartitions). Empty means "operator stated nothing", so # the derivation applies: 1 on a single broker, min(3, brokers) beyond that. Set # it only to go WIDER than 3 on a large cluster. # # It is a birth setting, not a live one. Kafka cannot reduce a partition count at # all, and RAISING one on a topic that is already streaming rehashes every key # onto a different partition and breaks per-key ordering, so rsync.ai never resizes # an existing topic -- a change here reaches only topics created after it. KAFKA_CDC_TOPIC_PARTITIONS: ${KAFKA_CDC_TOPIC_PARTITIONS:-} # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} # Avro requires a Schema Registry; this compose ships none. The code default is # true, so leaving it unset points the producer at a host that does not exist here. KAFKA_USE_AVRO: "false" JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required} ENCRYPTION_KEY: ${ENCRYPTION_KEY:?ENCRYPTION_KEY is required} INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} REDIS_HOST: redis REDIS_PORT: 6379 REDIS_DB: 0 REDIS_PASSWORD: ${REDIS_PASSWORD} TOOL_GENERATOR_URL: http://tool-generator:5010 # Sentinel core-service probes (sentinel/service_probes.go). Each is probed only # when its address is set; after 3 failed 30s checks the service is recorded # unhealthy and every admin gets an INFRASTRUCTURE_DOWN alert. Redis is always # probed, at the address the correlation client uses. TEMPORAL_ADDRESS is also the # (parked) native Temporal worker's dial address, whose default is this same value. TEMPORAL_ADDRESS: temporal:7233 SENTINEL_PROBE_TEMPORAL_ADAPTER_URL: http://temporal-adapter:8082 SENTINEL_PROBE_CONNECTOR_DEPLOYER_URL: http://connector-deployer:5011 SENTINEL_PROBE_LLM_SERVICE_URL: http://llm-service:5000 # Object-storage-to-object-storage runs (blob passthrough) stage bytes in # blob-staging, and the orchestrator only hands these to the two connectors # (executor/blob_lane.go buildStagingConfig). NOT `minio`: connectors join # only rsync-ai-mcp and cannot resolve it. Unset, the lane falls back to # MINIO_ENDPOINT_URL and then to rsync-ai-minio, neither of which a # connector can reach here. The orchestrator reads no other MINIO_* value, # so it carries no claim-check credentials at all. BLOB_STAGING_ENDPOINT_URL: http://blob-staging:9000 BLOB_STAGING_ACCESS_KEY_ID: ${BLOB_STAGING_ACCESS_KEY:?BLOB_STAGING_ACCESS_KEY is required} BLOB_STAGING_SECRET_ACCESS_KEY: ${BLOB_STAGING_SECRET_KEY:?BLOB_STAGING_SECRET_KEY is required} CORRELATION_CLAIM_TTL: 1h ENABLE_SCHEDULER: "false" ENABLE_CDC_TABLE_STATS: "true" # Batch data-plane sizing. Both empty by default = the code's own values, # which are now correct rather than merely default: with this container's # mem_limit set above, resolveTableConcurrencyWithReason() can finally read a real # cgroup limit and cap the pool at what the box can buffer (~384 MiB per # concurrent table load). Pin EXECUTOR_TABLE_CONCURRENCY only to override # that derivation; lower EXECUTOR_TABLE_BATCH_ROWS (default 10000) if a # source has unusually wide rows, since one page is held in memory. EXECUTOR_TABLE_CONCURRENCY: ${EXECUTOR_TABLE_CONCURRENCY:-} EXECUTOR_TABLE_BATCH_ROWS: ${EXECUTOR_TABLE_BATCH_ROWS:-} DOCKER_NETWORK: rsync-ai-mcp # No collector in this bundle — same reason as OTEL_ENABLED below. # internal/config/config.go:231 defaults this to true for cloud; the OSS # compose is the only place it is turned off. OTEL_ENABLED: "false" LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: production networks: - default - rsync-ai-mcp volumes: - mcp_connectors:/app/shared/mcp-connectors temporal-adapter: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/temporal-adapter:${RSYNC_VERSION:-latest} container_name: rsync-ai-temporal-adapter logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 768m depends_on: temporal: condition: service_healthy kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false redis: condition: service_healthy environment: # Kafka client security (BYO/managed broker). See x-kafka-security at the # top of this file; every key defaults to empty and empty means unset. <<: *kafka-security TEMPORAL_ADDRESS: temporal:7233 KAFKA_BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} # Avro requires a Schema Registry; this compose ships none. The code default is # true, so leaving it unset points the producer at a host that does not exist here. KAFKA_USE_AVRO: "false" ENCRYPTION_KEY: ${ENCRYPTION_KEY} POSTGRES_HOST: ${POSTGRES_HOST:-postgres} POSTGRES_PORT: ${POSTGRES_PORT:-5432} POSTGRES_USER: ${POSTGRES_USER:-rsync} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB:-pipeline_db} # internal/db/db.go:23 reads POSTGRES_SSLMODE (default "disable"). POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-disable} REDIS_ADDRESS: redis:6379 # The `redis` service above always starts with `requirepass` (REDIS_PASSWORD # is `:?`-required at line 209), so this client must authenticate or it does # not start at all: cmd/adapter/main.go:222 reads exactly this name and # main.go:223-226 `log.Fatalf`s when the correlation store cannot init. Its # own comment at main.go:217-220 names this failure "Redis auth split-brain". REDIS_PASSWORD: ${REDIS_PASSWORD} LOG_LEVEL: info ENVIRONMENT: production # Scheduled saved queries and the model-freshness sweep call the gateway's # internal endpoints and return "INTERNAL_SERVICE_SECRET is not configured" # without this (scheduled_model_workflow.go:123-128, # model_freshness_workflow.go:209-214). The Schedules page still shows # them active; they just never run. docker-compose.yml has always passed # both; this standalone file never did. API_GATEWAY_URL: http://api-gateway:8080 INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} volumes: # REQUIRED, and this file is the only one that ever missed it: this is the # container that runs ConnectorAvailabilityActivityV2, and that activity # resolves connectors off the LOCAL FILESYSTEM — connector_check_activity.go:93-97 # stats /app/shared/mcp-connectors/{public,internal,} and /app/tools. Without # the mount every root Stat fails, so checkConnectorExists returns false for # EVERY connector, the workflow escalates to GenerateConnectorActivityV2, and # the user sees "Connector generation failed for " for a connector that is # sitting right there in the volume. Nothing about it is connector-specific; # no pipeline of any kind can be created. # docker-compose.yml (base, which prod/staging/e2e/ci-isolate all overlay) # has carried this mount from the start — `./shared:/app/shared`, commented # "Required for connector filesystem checks". This quickstart is the one # standalone compose, so it is the only file that had to repeat it, and the # only deployment path that broke: every self-host install. # Read-only on purpose: the adapter only ever reads this tree (ReadDir/Stat/ # ReadFile/WalkDir). Generation output is written by tool-generator, which # mounts the same volume read-write. - mcp_connectors:/app/shared/mcp-connectors:ro api-gateway: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/api-gateway:${RSYNC_VERSION:-latest} container_name: rsync-ai-api-gateway logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 1g ports: # Loopback by default -- a laptop install must not publish an unauthenticated # port to the LAN. A server install has the opposite problem: bound here, the # only two published ports in this file are unreachable from anywhere but the # server itself, so install.sh could ask for a domain, print a URL for it, and # be believed, because its health check polls localhost and always passes. # install.sh sets RSYNC_BIND_ADDR=0.0.0.0 when the operator names a real host # and is not putting a TLS proxy in front. Leave it at the default when a # reverse proxy on the host terminates TLS -- it reaches loopback fine, and # 0.0.0.0 would additionally expose the plaintext port beside it. - "${RSYNC_BIND_ADDR:-127.0.0.1}:5001:8080" depends_on: postgres: condition: service_healthy # Other half of docker-compose.byo-postgres.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false redis: condition: service_healthy # Wait for Temporal so the startup client.Dial wins the cold-boot race. temporal: condition: service_healthy orchestrator: condition: service_started environment: # Kafka client security (BYO/managed broker). See x-kafka-security at the # top of this file; every key defaults to empty and empty means unset. <<: *kafka-security DATABASE_URL: postgres://${POSTGRES_USER:-rsync}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-pipeline_db}?sslmode=${POSTGRES_SSLMODE:-disable} KAFKA_BROKERS: "${KAFKA_BROKERS:-kafka:29092}" # Namespace for every Kafka topic this product creates, so an operator can # tell them apart from other applications' topics on a shared (BYO-Kafka) # cluster. Every service that touches Kafka must agree on this value: a # producer and a consumer resolving different names do not error, the # consumer just blocks on a topic nobody writes. `-` not `:-` on purpose -- # an explicit empty value means "disable" (keep pre-prefix topic names) and # `:-` would silently turn that back into the default. KAFKA_TOPIC_PREFIX: ${KAFKA_TOPIC_PREFIX-rsync.} # Avro requires a Schema Registry; this compose ships none. The code default is # true, so leaving it unset points the producer at a host that does not exist here. KAFKA_USE_AVRO: "false" TEMPORAL_ADDRESS: temporal:7233 ORCHESTRATOR_URL: http://orchestrator:8080 AGENT_ORCHESTRATOR_URL: http://orchestrator:8080 TOOL_GENERATOR_URL: http://tool-generator:5010 # Deadline for the llm-service calls in one chat turn (llmServiceTimeout() # in internal/handlers/chat_nl_pipeline.go). The code default is 60s, sized # for a hosted API; this bundle runs inference on CPU Ollama, where a first # token takes 60-100s. When it was 10s, every plain-English message # returned the canned fallback while the regex fast path kept answering # canonical phrasings instantly, so the stack looked healthy. # # Under 300 on purpose: cmd/server/main.go caps the whole response at a # 300s WriteTimeout, so a longer upstream deadline would just move the cut # to a worse place. Raise both together if you ever need more. LLM_SERVICE_TIMEOUT_SECONDS: ${LLM_SERVICE_TIMEOUT_SECONDS:-240} JWT_SECRET: ${JWT_SECRET} REDIS_ADDRESS: redis:6379 # Same requirepass as the adapter above. api-gateway fails SOFTER -- it # retries 5x then logs "proceeding without caching" (cmd/server/main.go:412-427) # -- so the omission does not crash the stack, it silently disables the schema # cache and the Data Explorer cache built on the same client. Read at # cmd/server/main.go:408 and internal/handlers/admin_health.go:80, which is why # /admin/health would otherwise report redis "down" on a healthy Redis. REDIS_PASSWORD: ${REDIS_PASSWORD} ENCRYPTION_KEY: ${ENCRYPTION_KEY} INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} RSYNC_CSRF_ENFORCE: "true" RSYNC_ALLOW_INTERNAL_CONNECTORS: "false" # Self-host has no Stripe / cloud plans: disable plan-quota enforcement so a # fresh install is never trial-gated (migration 060 would otherwise seed a # 14-day, 1-pipeline trial with no upgrade path). Cloud leaves this unset ⇒ # enforced. See resolvePlanQuota / billingEnforced() in plan_quota.go. RSYNC_BILLING_ENFORCED: "false" # Zero-credential try-it path. Unset ⇒ the demo endpoints report unavailable # and refuse to seed, which is the CLOUD behavior (app.rsync.ai must never # offer a bundled warehouse). Only this compose file sets it, so the split # stays "which compose runs", not a runtime edition gate. # Parsed by demoDestinationConfig() in api-gateway/internal/handlers/demo.go; # host/port/database/user/password are handed to the `postgresql` connector, # whose required_config is exactly those five keys. The value never reaches # the browser — the seed endpoint builds the connection server-side. RSYNC_DEMO_DESTINATION_DSN: postgres://rsync_demo:${RSYNC_DEMO_WAREHOUSE_PASSWORD:-rsync-demo-not-a-secret}@demo-warehouse:5432/demo_warehouse # SEC-M-07: self-host is NOT behind Cloudflare, so CF-Connecting-IP is # attacker-controllable. Disable Cloudflare client-IP trust here; cloud # leaves this unset => enabled. See trustCloudflareClientIP() in api-gateway. RSYNC_TRUST_CLOUDFLARE: "false" # This stack ships no OTLP collector to export spans to. # LoadTelemetryConfig defaults this to true (cloud runs # one), and until now InitTracerWithConfig ignored the flag entirely, so # every self-host retried gRPC exports at localhost:4317 forever. Only this # file turns it off, per the OSS/cloud split rule in CLAUDE.md; the tracer # falls back to a no-op provider, so instrumented paths keep working. OTEL_ENABLED: "false" RSYNC_ADMIN_EMAILS: ${RSYNC_ADMIN_EMAILS:-} OAUTH_CALLBACK_URL: ${OAUTH_CALLBACK_URL:-} # Left empty, OAUTH_CALLBACK_URL is derived from this (oauth_byo.go # oauthCallbackBase); without it a server install redirected every OAuth # consent back to localhost:5001. PUBLIC_URL: ${PUBLIC_URL:-} GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} SLACK_CLIENT_ID: ${SLACK_CLIENT_ID:-} SLACK_CLIENT_SECRET: ${SLACK_CLIENT_SECRET:-} HUBSPOT_CLIENT_ID: ${HUBSPOT_CLIENT_ID:-} HUBSPOT_CLIENT_SECRET: ${HUBSPOT_CLIENT_SECRET:-} SALESFORCE_CLIENT_ID: ${SALESFORCE_CLIENT_ID:-} SALESFORCE_CLIENT_SECRET: ${SALESFORCE_CLIENT_SECRET:-} # Notifier delivery. api-gateway ALWAYS runs the notifier consumer # (cmd/server/main.go) and always persists to pipeline_notifications, but # Slack/email fan-out is enabled purely by the presence of these values: # slackEnabled = NOTIFIER_SLACK_WEBHOOK_URL != "", and emailEnabled needs # BOTH SMTP_HOST and SMTP_FROM (notifier.go:199-200). Until this block # existed the vars were simply not passed through here, so a self-host got # persist-only alerting -- rows in the DB, nothing in Slack or email, and # no error at any layer to say so. Every one defaults empty: unset stays # persist-only, which is the safe default for a fresh install. NOTIFIER_SLACK_WEBHOOK_URL: ${NOTIFIER_SLACK_WEBHOOK_URL:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} # Required for email delivery, not optional: SMTP_HOST alone leaves # emailEnabled false and the omission looks identical to "email off". SMTP_FROM: ${SMTP_FROM:-} # The absolute base every alert's "Open pipeline" link is built on # (notifier.go:173). It falls back to http://localhost:3000 in code, which # is a dead link in every alert on a server install -- so it follows # NEXTAUTH_URL, the value install.sh actually persists and the same one # RSYNC_CORS_ORIGINS below already keys on. APP_BASE_URL: ${APP_BASE_URL:-${NEXTAUTH_URL:-http://localhost:3000}} FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} # CORS allowlist: must match the frontend origin so browser requests succeed RSYNC_CORS_ORIGINS: ${NEXTAUTH_URL:-http://localhost:3000} # Secure flag on the session and CSRF cookies. install.sh derives it from # the scheme it published; left empty the gateway falls back to # ENVIRONMENT, i.e. Secure. Fail-closed on purpose -- the failure this # guards against (a Secure cookie dropped by the browser on plain http) is # invisible, while the opposite mistake is at least visible as a warning. RSYNC_COOKIE_SECURE: ${RSYNC_COOKIE_SECURE:-} LOG_FORMAT: json LOG_LEVEL: info ENVIRONMENT: production networks: - default - rsync-ai-mcp volumes: - mcp_connectors:/app/shared/mcp-connectors:ro frontend: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/frontend:${RSYNC_VERSION:-latest} container_name: rsync-ai-frontend logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 768m ports: # Same knob as the api-gateway port above; see the comment there. - "${RSYNC_BIND_ADDR:-127.0.0.1}:3000:3000" depends_on: - api-gateway environment: API_GATEWAY_INTERNAL_URL: http://api-gateway:8080 # The dashboard's server-side CDC fetch (app/(dashboard)/page.tsx) falls back # to localhost:8081 without this, which inside this container is nothing. ORCHESTRATOR_INTERNAL_URL: http://orchestrator:8080 # The NEXT_PUBLIC_* pair below is inert on a published image -- the Next.js # compiler substituted those names at `docker build` time, so nothing set # here can reach the browser through them. They stay for a locally built # image, where build and run share an environment. # # These two are what actually reach the browser, served per request by # /runtime-config.js. Without them the UI published on :3000 resolves the # api-gateway to its own origin and every request 404s against Next.js, # while the installer's health probe on :5001 still reports success. PUBLIC_URL: ${PUBLIC_URL:-} PUBLIC_WS_URL: ${PUBLIC_WS_URL:-} NEXT_PUBLIC_API_URL: ${PUBLIC_URL:-http://localhost:5001} NEXT_PUBLIC_WS_URL: ${PUBLIC_WS_URL:-ws://localhost:5001/ws} NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} INTERNAL_SERVICE_SECRET: ${INTERNAL_SERVICE_SECRET:-} # ─── INTERNAL MCP CONNECTORS ───────────────────────────────────────────────── # ── Container names are load-bearing ──────────────────────────────────────── # The orchestrator does not look these services up by compose service name. It # builds the hostname itself, as `--v1-0-0-mcp` # (backend-orchestrator/internal/workers/infra_preflight.go requiredServices, # via mcp.StackPrefix() which defaults to "rsync-ai"). The short names used here # before — rsync-minio-mcp, rsync-debezium-mcp, rsync-kafka-mcp-sink — matched # neither that pattern nor anything else the orchestrator asks DNS for. # # Nothing errored. The preflight health poll simply NXDOMAIN'd for its full # 24 x 5s budget (preflightKafkaConnectRetries) and then fell through to its # "start it via tool-generator" last resort, which deployed a SECOND, correctly # named container beside the compose one. So every batch pipeline on a # quickstart box paid 120 seconds at preflight and then ran with a duplicate # minio-mcp resident, while the compose-started container sat unused forever. # Renaming is the whole fix; do not shorten these again. # # Pre-started, unlike sample-data-mcp below, because on-demand is not an # option for it yet: the JIT deploy path (connector-deployer, tool-generator # deployment, infra_preflight's last resort) passes no MINIO_* credentials, so # a container it started would come up unable to reach the bucket. The env # block below is the only place those credentials reach this connector. minio-mcp: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/mcp-minio:${RSYNC_VERSION:-latest} container_name: rsync-ai-minio-v1-0-0-mcp logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped mem_limit: 512m environment: PORT: 8000 MCP_HTTP_MODE: "true" MINIO_ENDPOINT_URL: http://minio:9000 MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} MINIO_BUCKET: pipeline-data ENVIRONMENT: production # The zero-credential demo source, and the reason it is worth a service block # of its own: it is the ONLY connector in the tree whose auth_type is "none" # (every other one of the 21 public connectors needs a credential before it # will do anything). It carries its customers/orders/products rows inside its # own image and imports nothing outside the Python standard library, so it is # the one source a fresh install can connect to before the operator has # produced a single secret. # # It was absent from THIS file while being present in the catalog the # connector-seed image bakes -- Dockerfile.seed allowlist-COPYs public/ # wholesale, so sample-data has always been listed as installable. That is the # worst of the two possible states: the connector was offered in the UI and # selecting it failed at connection test with a DNS miss for a container no # compose file here ever started. Absent-and-unlisted would at least have been # honest. # # container_name is load-bearing and must keep EXACTLY this shape -- # --vX-Y-Z-mcp, built by MCPContainerName() # (backend-orchestrator/internal/mcp/server_manager.go:310). Docker's embedded # DNS resolves it, so it doubles as the hostname the orchestrator health-checks # at http://:8000/health. The minio-mcp note above is the record of what # shortening one of these costs: 120s of preflight per pipeline plus a # duplicate container. Do not shorten it. # # Behind the opt-in "connectors" profile: an install starts no catalog # connector. Creating the sample-data connection deploys this same container # name on demand (connector-deployer builds it from the seeded source), and # POST /demo/seed retries its 503 connector_deploying while that runs # (api-gateway demo.go demoDeployRetryWindow). minio-mcp above and the cdc # pair stay pre-started for reasons given at each. sample-data-mcp: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/mcp-sample-data:${RSYNC_VERSION:-latest} container_name: rsync-ai-sample-data-v1-0-0-mcp profiles: ["connectors"] logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" restart: unless-stopped # Stdlib-only, and it serves three in-memory lists. It does not need the # 512m the credentialed connectors take. mem_limit: 128m environment: PORT: 8000 MCP_PORT: 8000 MCP_HTTP_MODE: "true" ENVIRONMENT: production # ── CDC data plane (profile: cdc) ─────────────────────────────────────────── # kafka-connect and debezium-mcp are a set. The orchestrator's preflight # requires both (plus kafka-mcp-sink, which is unprofiled because batch needs # it too) for any CDC pipeline, so shipping a # subset is strictly worse than shipping none: the two MCPs used to start # unconditionally here while kafka-connect — the engine debezium-mcp is a bridge # to — had no service at all, so a CDC pipeline failed preflight on a box that # was already paying to run half of it. # # The two MCPs are not deployed on demand the way catalog connectors are: # preflight's JIT last resort starts them without this file's Kafka client and # security env (x-kafka-client-env), and only after polling 24 x 5s first. So # the profile, not the first CDC pipeline, is what starts them. kafka-connect: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/kafka-connect:${RSYNC_VERSION:-latest} container_name: kafka-connect logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" profiles: ["cdc"] restart: unless-stopped # Parity with docker-compose.prod.yml, which was raised to 2048M after this # container was OOM-killed repeatedly on the GCP host. The overrun is NATIVE, # not heap: Debezium's BufferingChangeStreamCursor allocates outside the JVM # heap, so KAFKA_HEAP_OPTS (-Xmx768M, below) does not bound it and raising # Xmx would make the kill arrive SOONER, since the cgroup counts total RSS. # Kernel evidence, 2026-09-23 — three separate kills at the old 1024M cap: # Memory cgroup out of memory: Killed process (java) anon-rss:1042068kB # 1536m left only 768 MiB of non-heap headroom; 2048m leaves 1280 MiB, which # is what prod now runs. This file is the one-command self-host path, so it is # where a first CDC snapshot of a large collection actually lands. mem_limit: 2048m depends_on: kafka: condition: service_healthy # Other half of docker-compose.byo-kafka.yml — see that file's header: # profiles: without required:false = "depends on undefined service". required: false environment: # Unprefixed names on purpose: the debezium/connect entrypoint hand-exports # this fixed list. See docker-compose.yml for the full annotation — anything # else unprefixed is discarded in silence. BOOTSTRAP_SERVERS: "${KAFKA_BROKERS:-kafka:29092}" GROUP_ID: "rsync-connect-cluster" CONFIG_STORAGE_TOPIC: "_rsync-connect-configs" OFFSET_STORAGE_TOPIC: "_rsync-connect-offsets" STATUS_STORAGE_TOPIC: "_rsync-connect-status" CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: "${KAFKA_REPLICATION_FACTOR:-1}" KEY_CONVERTER: "org.apache.kafka.connect.json.JsonConverter" VALUE_CONVERTER: "org.apache.kafka.connect.json.JsonConverter" OFFSET_FLUSH_INTERVAL_MS: 10000 ADVERTISED_HOST_NAME: "kafka-connect" CONNECT_KEY_CONVERTER_SCHEMAS_ENABLE: "false" CONNECT_VALUE_CONVERTER_SCHEMAS_ENABLE: "false" CONNECT_PRODUCER_ACKS: "all" CONNECT_PRODUCER_RETRIES: 10 # Lockstep with the broker's message.max.bytes and the sink worker's # ReaderConfig.MaxBytes — a row with a large JSON/text/blob column fails the # task with RecordTooLargeException if these three disagree. CONNECT_PRODUCER_MAX_REQUEST_SIZE: 15728640 CONNECT_CONSUMER_AUTO_OFFSET_RESET: "earliest" # FileConfigProvider: source DB passwords resolve at task start from the # shared connect_secrets volume, so they never enter the Kafka config topic # nor the GET /connectors//config response. CONNECT_CONFIG_PROVIDERS: "file" CONNECT_CONFIG_PROVIDERS_FILE_CLASS: "org.apache.kafka.common.config.provider.FileConfigProvider" CONNECT_CONFIG_PROVIDERS_FILE_PARAM_ALLOWED_PATHS: "/connect-secrets" KAFKA_HEAP_OPTS: "-Xms256M -Xmx768M -XX:+ExitOnOutOfMemoryError" # ── BYO-Kafka security ─────────────────────────────────────────────── # The base image's entrypoint turns every ^CONNECT_ variable into one # connect-distributed.properties line (strip CONNECT_, lowercase, '_' -> # '.') and DISCARDS everything else in silence. The KAFKA_* names the Go # and Python services read still work here, because this image runs its # own entrypoint first (connect-entrypoint.sh, the same code Helm runs) # and it derives the CONNECT_* lines from them: # * KAFKA_SASL_USERNAME/PASSWORD, or the KAFKA_SASL_OAUTHBEARER_* set, # become the JAAS line for the mechanism; # * KAFKA_SSL_CERT_LOCATION + KAFKA_SSL_KEY_LOCATION become the one-file # PEM keystore a JVM needs (KAFKA_SSL_KEYSTORE_LOCATION, if set, wins). # Mount the certificate files into this container too, at the same paths. <<: *kafka-security # # Connect needs the profile in THREE places. The worker's own admin client # is a different client from the producer each source task writes with and # the consumer each sink task reads with, and the task-level ones do not # inherit reliably. Configure only the worker and the failure is # silent-partial: the worker authenticates, joins the group, answers the # REST API and reports connectors RUNNING while every task fails its own # handshake and not one row moves. # # Unset, these render exactly the bundled-broker behaviour: PLAINTEXT, # with sasl.mechanism inert because no handshake is attempted. CONNECT_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" CONNECT_PRODUCER_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_PRODUCER_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" CONNECT_CONSUMER_SECURITY_PROTOCOL: "${KAFKA_SECURITY_PROTOCOL:-PLAINTEXT}" CONNECT_CONSUMER_SASL_MECHANISM: "${KAFKA_SASL_MECHANISM:-PLAIN}" # # An OVERRIDE, normally left unset: the entrypoint builds the JAAS line # from KAFKA_SASL_USERNAME/PASSWORD (or the OAuth set), choosing the login # module the mechanism needs and escaping the values for the two grammars # they cross. Set KAFKA_SASL_JAAS_CONFIG only for a login module it has no # arm for; a non-empty value is used as-is and nothing is built. It then # needs BOTH escapes -- a properties grammar, then a JAAS StreamTokenizer # -- so `p"w` is written `p\\"w`; escaping once corrupts it identically # to not escaping at all. CONNECT_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" CONNECT_PRODUCER_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" CONNECT_CONSUMER_SASL_JAAS_CONFIG: "${KAFKA_SASL_JAAS_CONFIG:-}" # # Truststore PATH and PASSWORD are BARE pass-through keys -- no `${...:-}` # default, deliberately. A key with no value is OMITTED from the container # environment when the host variable is unset; `${VAR:-}` renders it # present-but-EMPTY, and this entrypoint writes every CONNECT_ var it sees, # empty included. Kafka then reads an empty ssl.truststore.location as a # real path and the worker exits 2 with "Failed to load PEM SSL keystore". # Worse for the password: Kafka REJECTS a truststore password outright for # PEM stores, and an empty string counts as specified. Both would break the # commonest BYO case -- MSK and Confluent Cloud chain to a public root and # need no truststore setting at all. # # Set all THREE of each; the worker-only half is the classic partial. # export CONNECT_SSL_TRUSTSTORE_LOCATION=/connect-certs/ca.pem # export CONNECT_PRODUCER_SSL_TRUSTSTORE_LOCATION="$CONNECT_SSL_TRUSTSTORE_LOCATION" # export CONNECT_CONSUMER_SSL_TRUSTSTORE_LOCATION="$CONNECT_SSL_TRUSTSTORE_LOCATION" CONNECT_SSL_TRUSTSTORE_LOCATION: CONNECT_PRODUCER_SSL_TRUSTSTORE_LOCATION: CONNECT_CONSUMER_SSL_TRUSTSTORE_LOCATION: CONNECT_SSL_TRUSTSTORE_PASSWORD: CONNECT_PRODUCER_SSL_TRUSTSTORE_PASSWORD: CONNECT_CONSUMER_SSL_TRUSTSTORE_PASSWORD: # TYPE is safe to default: it is consulted only when a location is set, and # JKS is Kafka's own default. Set KAFKA_SSL_TRUSTSTORE_TYPE=PEM for a PEM # CA bundle -- one variable, all three clients. CONNECT_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" CONNECT_PRODUCER_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" CONNECT_CONSUMER_SSL_TRUSTSTORE_TYPE: "${KAFKA_SSL_TRUSTSTORE_TYPE:-JKS}" # Bare `-`, not `:-`: unset means Kafka's default ("https", verify), while # an explicitly EMPTY value is Kafka's documented off switch and has to # survive as empty -- `:-` would substitute the default back over it. CONNECT_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" CONNECT_PRODUCER_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" CONNECT_CONSUMER_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM: "${KAFKA_SSL_ENDPOINT_IDENTIFICATION_ALGORITHM-https}" volumes: # Read-only: this worker only RESOLVES the secrets debezium-mcp writes. - connect_secrets:/connect-secrets:ro healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8083/"] interval: 30s timeout: 10s retries: 5 start_period: 60s debezium-mcp: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/mcp-debezium:${RSYNC_VERSION:-latest} container_name: rsync-ai-debezium-v1-0-0-mcp logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" profiles: ["cdc"] restart: unless-stopped mem_limit: 512m environment: PORT: 8000 MCP_HTTP_MODE: "true" # Where debezium-mcp WRITES the per-connector secret file that # kafka-connect's FileConfigProvider reads back. Unset, it had nowhere to # put them and the ${file:...} references in a CDC connector config would # resolve to nothing — so the volume and this variable travel together. DEBEZIUM_SECRETS_DIR: /connect-secrets ENVIRONMENT: production # BYO-Kafka. This service authors the Debezium connector config, and the # connector's own producer plus its schema-history client are configured # FROM here -- so an unsecured debezium-mcp writes a connector that dials # a secured broker anonymously, which surfaces as a task that never leaves # RUNNING and a snapshot that never starts. # # The ADDRESS travels with the credentials, and must: no caller passes # kafka_bootstrap_servers, so _bootstrap_from_args() falls through to # connector.py:533's `_env("KAFKA_BROKERS") or ... "kafka:29092"`. Without # this line a BYO operator gets a connector whose schema history is # SASL-credentialed for their cluster and ADDRESSED to the bundled broker # -- so it authenticates against the wrong Kafka, or (under SASL_SSL) hangs # handshaking with a PLAINTEXT one. The schema-history client is separate # from the task producer, so the connector starts fine and only breaks on # the restart that replays it. KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} <<: *kafka-security volumes: - connect_secrets:/connect-secrets kafka-mcp-sink-mcp: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/mcp-kafka-sink:${RSYNC_VERSION:-latest} container_name: rsync-ai-kafka-mcp-sink-v1-0-0-mcp logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" # Deliberately NOT behind the cdc profile. The sink is the Kafka → # destination writer for BATCH runs too (the executor starts a sink worker # for pipeline..data before producing), and the orchestrator's preflight # requires it for every pipeline with a destination # (internal/workers/infra_preflight.go requiredServices). Profiled out, a # batch-only install fails every batch run at preflight. restart: unless-stopped mem_limit: 768m environment: PORT: 8000 MCP_HTTP_MODE: "true" MINIO_ENDPOINT_URL: http://minio:9000 MINIO_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} MINIO_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} MINIO_BUCKET: pipeline-data ENVIRONMENT: production # The sink owns the durable ACK ledger (pipeline_batch_acks) and fails # CLOSED without it: main.go:3153-3156 reads POSTGRES_URL then DATABASE_URL, # and main.go:3186-3196 logs "FATAL: ACK ledger (POSTGRES_URL / DATABASE_URL) # is unavailable" and os.Exit(1)s unless RSYNC_SINK_ALLOW_NO_LEDGER=true. # With neither name set the worker exits on every spawn and the Python # supervisor respawns it forever, so nothing drains the data topic and the # pipeline reports `completed` into an empty destination. # # sslmode is ${POSTGRES_SSLMODE:-disable}, NOT a hardcoded `disable`: the # literal is what broke staging and prod against a TLS-requiring managed # Postgres (see the two archived write-ups), and it is the one URL part no # other env var could override. Same form as api-gateway's DATABASE_URL above. POSTGRES_URL: postgres://${POSTGRES_USER:-rsync}:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-postgres}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-pipeline_db}?sslmode=${POSTGRES_SSLMODE:-disable} # BYO-Kafka. The Go sink worker reads exactly these names # (kafka-mcp-sink/worker-src/.../kafka_security.go initKafkaSecurity) and # is fatal on an INVALID profile -- but silent on an ABSENT one, where it # defaults to PLAINTEXT and consumes anonymously. That is the failure this # block exists to prevent: it is the last hop of the CDC path, so the # symptom is a pipeline that reports `completed` with an empty destination. # # The address travels with the credentials. Today every caller passes it # explicitly (kafka.SinkBootstrapServers()), so this changes no behaviour -- # it is here so the rule "anything merging x-kafka-security also declares a # broker" holds without an exception, because the exception is what makes # the next service's omission look deliberate. # How many destination flushes this sink runs at once. Unset/empty = 4. # # A CDC data topic is born with min(3, brokers) partitions and Debezium gives # every source table a topic of its own, so one worker is assigned tens of # partitions. It consumed all of them already, but drained them one at a time # because the destination write ran inline on the consume goroutine — one slow # table stalled every other partition. Lanes are sharded on (topic, partition), # so per-row order is untouched. # # Set to 0 or 1 to restore the old inline behaviour exactly; this is the kill # switch. Raising it opens more concurrent connections to the DESTINATION, which # is somebody else's database — 4 is deliberately modest. RSYNC_SINK_FLUSH_LANES: ${RSYNC_SINK_FLUSH_LANES:-} KAFKA_BROKERS: ${KAFKA_BROKERS:-kafka:29092} <<: *kafka-security # The sink worker writes to destination MCPs (e.g. rsync-ai-postgresql-v1-0-0-mcp) # by container name. JIT-deployed connector MCPs live on the rsync-ai-mcp network # (tool-generator sets DOCKER_NETWORK=rsync-ai-mcp), so without bridging the sink # into it the sink resolves NOTHING and every destination write fails with # "no such host" — the silent DLQ mode that lets a pipeline report `completed` # while the destination stays empty. Mirrors docker-compose.yml (the regular stack). networks: - default - rsync-ai-mcp # Generation-time only (profile: generate). Every reference to context7 in the # product is under llm-service/src/agents/tool_generator/, and the one call site # is wrapped in try/except with a 3s timeout, so its absence is tolerated by # design — a stack that only RUNS existing connectors never needs it. context7-mcp: image: ${RSYNC_IMAGE_REGISTRY:-ghcr.io/rsync-ai}/mcp-context7:${RSYNC_VERSION:-latest} # Not `rsync-ai--mcp`: api-gateway reads that shape as a connector (tools.go # getMCPDockerContainers), and this is the doc-lookup helper generation uses. container_name: rsync-ai-context7 logging: driver: json-file options: max-size: ${RSYNC_LOG_MAX_SIZE:-10m} max-file: "${RSYNC_LOG_MAX_FILE:-3}" profiles: ["generate"] restart: unless-stopped mem_limit: 256m environment: PORT: 8080 CONTEXT7_API_URL: https://context7.com/api CONTEXT7_API_KEY: ${CONTEXT7_API_KEY:-} ENVIRONMENT: production volumes: postgres_data: demo_warehouse_data: redis_data: kafka_data: minio_data: blob_staging_data: # Shared between debezium-mcp (writes) and kafka-connect (reads, ro) so source # DB credentials never transit the Kafka config topic. connect_secrets: mcp_connectors: name: rsync-ai-mcp-connectors networks: rsync-ai-mcp: name: rsync-ai-mcp