Skip to content

Kubernetes Deployment

Synclet provides a Helm chart for Kubernetes deployments, supporting both a simple single-pod setup and a distributed architecture where each sync runs as a separate Kubernetes Job.

Install from the OCI registry:

Terminal window
helm install synclet oci://ghcr.io/synclet-io/charts/synclet \
-f values.yaml \
--namespace synclet \
--create-namespace

To install a specific version:

Terminal window
helm install synclet oci://ghcr.io/synclet-io/charts/synclet \
--version 0.7.0 \
-f values.yaml \
--namespace synclet \
--create-namespace

A minimal values.yaml:

auth:
jwtSecret: "your-jwt-secret" # min. 32 bytes
encryption:
key: "your-base64-encoded-32-byte-key" # base64(32 bytes)

These map to the AUTH_JWT_SECRET and SECRET_ENCRYPTION_KEY environment variables. Generate both with openssl rand -base64 32.

The chart includes a built-in PostgreSQL by default. To use an external database, disable the built-in one and configure externalDatabase:

postgresql:
enabled: false
externalDatabase:
host: "db.example.com"
port: 5432
user: "synclet"
password: "password"
database: "synclet"
sslmode: require

Upgrade after changing values:

Terminal window
helm upgrade synclet oci://ghcr.io/synclet-io/charts/synclet -f values.yaml -n synclet
ValueDescriptionDefault
modeDeployment mode: standalone or distributedstandalone
server.replicaCountNumber of server replicas; above 1 only in distributed mode1
server.resources.requests.cpuCPU request250m
server.resources.requests.memoryMemory request512Mi
server.resources.limits.cpuCPU limit2
server.resources.limits.memoryMemory limit1Gi
server.ingress.enabledEnable Ingress resourcetrue
server.ingress.hostnameIngress hostnamesynclet.local
server.ingress.tlsEnable TLS on Ingressfalse
server.gateway.enabledCreate a Gateway API HTTPRoutefalse
server.publicUrlURL users reach Synclet atderived
serviceAccount.annotationsAnnotations on the server and jobs account{}
executor.serviceAccount.createSeparate executor account (distributed mode)true
pipeline.serviceAccount.createSeparate connector pod accountfalse
logArchive.backendJob log archive: none, filesystem, s3, gcsnone
logArchive.garage.enabledRun the bundled Garage S3 storefalse
cors.*CORS of the public API, see CORSserver defaults
corsAllowAllOriginsDeprecated alias of cors.allowAllOriginsunset
extraEnvEnv vars for every Synclet container, see Extra environment variables[]
server.extraEnv, jobs.extraEnv, executor.extraEnvEnv vars for one component, applied after extraEnv[]
server.extraEnvFrom, jobs.extraEnvFrom, executor.extraEnvFromExtra envFrom sources (ConfigMap or Secret)[]

A single pod runs the API server, scheduler, and sync executor together. This is the simplest setup and works well for small-to-medium workloads. Standalone mode always runs one server pod: the chart fails to render with server.replicaCount above 1 or server.pdb.enabled: true. To run several replicas, use distributed mode.

mode: standalone
server:
replicaCount: 1

When Synclet detects it is running inside Kubernetes, it automatically selects the Kubernetes executor: every sync runs as its own batchv1.Job (orchestrator + source + destination containers) rather than via the local Docker daemon. No Docker-in-Docker is required.

The server and jobs worker run as Deployments and the executor as a StatefulSet. The executor launches connector pods as Kubernetes Jobs, giving you native resource isolation and scaling. See Executors for how the parts talk to each other.

mode: distributed
server:
replicaCount: 2 # API replicas (stateless)

Benefits of distributed mode:

  • Isolation — A failing sync cannot crash the API server.
  • Scaling — Kubernetes schedules sync Jobs across the cluster.
  • Resource control — Set CPU/memory limits per sync via Helm values.
  • Observability — Each sync is a distinct Job with its own logs.

See Production Checklist for sizing guidance.

ValueDescriptionDefault
server.replicaCountServer replicas. Above 1 only in distributed mode; standalone mode fails to render1
jobs.replicaCountJobs worker replicas (distributed mode)1
executor.enabledRun the executor StatefulSet in distributed mode. Set false to run the executor outside the charttrue
executor.replicaCountExecutor replicas (distributed mode)1
<component>.pdb.enabledPodDisruptionBudget of server, jobs or executor (distributed mode). Unset creates one when the component runs more than one replica; true always, false never. Standalone mode fails to render with server.pdb.enabled: trueunset
<component>.pdb.maxUnavailablePods of the component that may be down during a voluntary disruption, such as a node drain1
<component>.pdb.minAvailablePods that must stay up instead; replaces maxUnavailable when setunset
executor.updateStrategyStatefulSet update strategy: type: RollingUpdate or OnDeleteRollingUpdate
mode: distributed
server:
replicaCount: 2 # gets a PDB with maxUnavailable: 1
jobs:
replicaCount: 2
pdb:
enabled: false # no PDB for the jobs workers
executor:
replicaCount: 3
pdb:
maxUnavailable: 2

Executor pods start in parallel and update one at a time, highest ordinal first. Each keeps its pod name (<fullname>-executor-0, -1, …) as its owner ID; with k8s.ownerId set, its ID is <k8s.ownerId>-<ordinal>. After a scale-down, the Jobs a removed ordinal left behind are cleaned up only when a pod with that ordinal runs again. More replicas add availability, not capacity: one executor launches any number of sync Jobs. See Executors: scaling.

With executor.enabled: false, point your own synclet k8s-executor at http://<fullname>-internal.<namespace>.svc.cluster.local:8081 (EXECUTOR_API_URL) and give it the internal API token (EXECUTOR_API_TOKEN, the internal-api-secret key of the <fullname>-secrets Secret unless internalApi.existingSecret is set).

k8s.* configures the Kubernetes executor, both the one embedded in a standalone server and the distributed executor.

ValueDescriptionDefault
k8s.namespaceNamespace of the sync and connector task Jobs (K8S_EXECUTOR_NAMESPACE). It must exist before installrelease namespace
k8s.ownerIdOwner label stamped on the executor’s Jobs and Secrets; the executor cleans up only its own. Empty uses the pod name of each executor replica (distributed) or the release’s full name (standalone). Set, it is the standalone server’s K8S_EXECUTOR_OWNER_ID, and in distributed mode the prefix K8S_EXECUTOR_OWNER_ID_PREFIX: each replica’s owner ID is <k8s.ownerId>-<ordinal>. Releases sharing one namespace need distinct valuesempty
k8s.defaultMemoryMemory request and limit of a sync container that gets neither from pipeline.resources or the connector’s runtime config2Gi
k8s.defaultCPUSame for CPU1
k8s.imagePullSecretImage pull Secret for private connector registriesempty
k8s.localVolumeExisting PVC mounted at /local in connector containers, for file-writing destinations; empty mounts an emptyDirempty

See Executors: owner IDs for why the owner ID matters.

The chart keeps its secrets in the <fullname>-secrets Secret. Each one is set inline in the values or read from a Secret you create (existingSecret), in which case the chart writes nothing for it. The JWT secret, the encryption key, the internal API token and the bundled PostgreSQL password are generated on first install when left empty, and kept across upgrades.

ValueSecretDefault key of existingSecret
auth.jwtSecret, auth.existingSecret, auth.existingSecretKeyAUTH_JWT_SECRET, at least 32 bytesjwt-secret
encryption.key, encryption.existingSecret, encryption.existingSecretKeyKeySECRET_ENCRYPTION_KEY, base64 of 32 bytesencryption-key
encryption.keyPrevious, encryption.enableKeyPrevious, encryption.existingSecretKeyPreviousKeySECRET_ENCRYPTION_KEY_PREVIOUS during key rotation. With an existingSecret or VSO, set enableKeyPrevious: true once the Secret holds the keyencryption-key-previous
internalApi.secret, internalApi.existingSecret, internalApi.existingSecretKeyExecutor API token: PIPELINE_EXECUTOR_API_TOKEN of the server and jobs pods, EXECUTOR_API_TOKEN of the executor pods. See Executors: the internal APIinternal-api-secret
externalDatabase.existingSecret, externalDatabase.existingSecretKeyA full DB_DSN; the host, port, user, password, database and sslmode fields are then ignoreddb-dsn
smtp.password, smtp.existingSecret, smtp.existingSecretKeyNOTIFY_SMTP_PASSWORDsmtp-password
oidc.providers[].clientSecret, oidc.existingSecretAUTH_OIDC_<SLUG>_CLIENT_SECRET of each provideroidc-<slug>-client-secret

Back up the encryption key, generated or not: without it the connector credentials in the database cannot be decrypted. See Backup and Restore.

postgresql:
enabled: false
externalDatabase:
existingSecret: synclet-db # key db-dsn: postgres://synclet:...@db.example.com:5432/synclet?sslmode=require
encryption:
existingSecret: synclet-keys # key encryption-key

With vso.enabled: true the chart creates VaultStaticSecret resources and reads the secrets from the Kubernetes Secrets the Vault Secrets Operator syncs them into. The operator must be installed and the Vault paths must hold the keys below.

ValueDescriptionDefault
vso.enabledSync the application secrets from Vault into <fullname>-vault-app: keys jwt-secret, encryption-key, internal-api-secret, and encryption-key-previous when encryption.enableKeyPrevious is truefalse
vso.vaultAuthRefExisting VaultAuth to useempty
vso.auth.createCreate a VaultAuth named <fullname> instead; set vaultConnectionRef, method, mount, role, and serviceAccount (the chart’s account when empty). Set either this or vaultAuthReffalse
vso.appSecrets.mount, .path, .type, .refreshAfterVault mount, path (required), kv-v1 or kv-v2, refresh intervalsecret, empty, kv-v2, 60s
vso.database.enabledAlso sync the database DSN, key db-dsn, into <fullname>-vault-dbfalse
vso.database.mount, .path, .type, .refreshAfterSame for the database path (required when enabled)secret, empty, kv-v2, 60s

An existingSecret set on auth, encryption, internalApi or externalDatabase takes precedence over the Vault-synced Secret. SMTP and OIDC secrets are not read from Vault.

vso:
enabled: true
vaultAuthRef: synclet-vault-auth
appSecrets:
path: synclet/app-secrets
database:
enabled: true
path: synclet/database
ValueDescriptionDefault
auth.registrationEnabledAllow sign-up with email and password (AUTH_REGISTRATION_ENABLED). While false, the first account may still sign upfalse
auth.secureCookiesMark auth cookies Secure, sent only over HTTPS (AUTH_SECURE_COOKIES). Keep it on behind TLS; set false only when users reach Synclet over plain HTTPtrue
auth.accessTokenTTLAccess token lifetime15m
auth.refreshTokenTTLRefresh token lifetime168h
auth.oidcStateTTLLifetime of the OIDC login state10m
auth.minPasswordLengthMinimum password length of local accounts8
rateLimit.trustedProxiesProxies whose X-Forwarded-For identifies the client for the auth rate limits and the login lockout[10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7]

The server’s own default for AUTH_SECURE_COOKIES is false; the chart sets true. The chart’s default Ingress (synclet.local, no TLS) serves plain HTTP, so set either server.ingress.tls: true or auth.secureCookies: false there.

Each entry of oidc.providers becomes one provider. The chart lists the slugs in AUTH_OIDC_PROVIDERS and renders the entry’s keys as AUTH_OIDC_<SLUG>_* variables, with the slug upper-cased. See Authentication: single sign-on for what each one does.

KeyVariable
slugProvider ID, used in AUTH_OIDC_PROVIDERS, the variable names and the callback path /auth/oidc/<slug>/callback
issuer, clientID_ISSUER, _CLIENT_ID
clientSecret_CLIENT_SECRET, stored in <fullname>-secrets unless oidc.existingSecret is set
displayName, scopes_DISPLAY_NAME, _SCOPES
roleClaim, roleMapAdmin, roleMapEditor, roleMapViewer, defaultRole, roleSync_ROLE_CLAIM, _ROLE_MAP_ADMIN, _ROLE_MAP_EDITOR, _ROLE_MAP_VIEWER, _DEFAULT_ROLE, _ROLE_SYNC; single-workspace mode only
autoCreateUser, allowedDomains_AUTO_CREATE_USER, _ALLOWED_DOMAINS

Keys you leave out are not rendered and keep the server defaults. oidc.callbackBaseURL sets AUTH_OIDC_CALLBACK_BASE_URL; it defaults to frontend.url, then to server.publicUrl. oidc.existingSecret names a Secret holding oidc-<slug>-client-secret for every provider.

oidc:
providers:
- slug: google
issuer: https://accounts.google.com
clientID: 123456.apps.googleusercontent.com
clientSecret: "<client secret>"
displayName: Google
allowedDomains: example.com

SMTP sends invitations, password resets and email notifications. With smtp.host empty the chart renders no NOTIFY_SMTP_* variables and email is off.

ValueDescriptionDefault
smtp.hostSMTP serverempty
smtp.portPort; 465 with implicit, 587 with starttls465
smtp.tlsimplicit (TLS from the first byte), starttls (upgrade a plain connection) or none (plain text, for local catchers such as Mailpit)implicit
smtp.userUsernameempty
smtp.password, smtp.existingSecret, smtp.existingSecretKeyPassword, inline or from a Secretempty, empty, smtp-password
smtp.fromSender addressnoreply@synclet.io
frontend.urlBase URL of invitation links; defaults to server.publicUrlempty
invites.ttlHow long an invitation stays valid168h
notify.webhookHTTPTimeout, notify.webhookMaxRetriesTimeout and retries of webhook, Slack and Telegram deliveries10s, 3
smtp:
host: smtp.example.com
port: 587
tls: starttls
user: synclet
existingSecret: synclet-smtp
from: synclet@example.com

workspaces.mode sets WORKSPACE_MODE:

  • single (default): one shared workspace that every user joins. OIDC role mapping applies only here.
  • multi: users create workspaces and invite members.

See Workspaces & Roles.

pipeline.* values become environment variables of the Synclet pods. See Environment Variables for what each one does.

ValueVariableDefault
pipeline.workerIntervalK8S_EXECUTOR_JOB_WORKER_INTERVAL: how often the executor polls for jobs and connector tasks5s
pipeline.resourceCleanupIntervalK8S_EXECUTOR_JOB_RESOURCE_CLEANUP_INTERVAL: how often the executor reconciles its Jobs and Secrets5m
pipeline.schedulerIntervalPIPELINE_JOB_SCHEDULER_INTERVAL30s
pipeline.watchdogIntervalPIPELINE_JOB_WATCHDOG_INTERVAL10s
pipeline.idleTimeoutPIPELINE_IDLE_TIMEOUT10m
pipeline.defaultMaxAttemptsPIPELINE_DEFAULT_MAX_ATTEMPTS3
pipeline.connectorTaskRetentionPIPELINE_CONNECTOR_TASK_RETENTION24h
pipeline.connectorTaskRunningTimeoutPIPELINE_CONNECTOR_TASK_RUNNING_TIMEOUT5m
pipeline.connectorTaskPendingTimeoutPIPELINE_CONNECTOR_TASK_PENDING_TIMEOUT1m
pipeline.connectionCheckTimeoutPIPELINE_CONNECTION_CHECK_TIMEOUT2m
pipeline.connectorTaskPollIntervalPIPELINE_CONNECTOR_TASK_POLL_INTERVAL500ms
pipeline.eventEmitTimeoutPIPELINE_EVENT_EMIT_TIMEOUT30s
pipeline.statsRollupHourlyLookback, pipeline.statsRollupDailyLookbackPIPELINE_STATS_ROLLUP_HOURLY_LOOKBACK, PIPELINE_STATS_ROLLUP_DAILY_LOOKBACK2h, 48h
pipeline.registryFetchTimeoutPIPELINE_REGISTRY_FETCH_TIMEOUT60s
pipeline.logMaxLinesPerAttemptPIPELINE_LOG_MAX_LINES_PER_ATTEMPT100000
pipeline.resources.cpuRequest, cpuLimit, memoryRequest, memoryLimitPIPELINE_RUNTIME_*: default connector resources; a source or destination overrides them in its runtime configempty
pipeline.resources.serviceAccountNamePIPELINE_RUNTIME_SERVICE_ACCOUNT_NAME, unless pipeline.serviceAccount creates or names oneempty
pipeline.serviceAccount.*Connector pod account, see Service Accountscreate: false
pipeline.maxSyncDurationDOCKER_EXECUTOR_MAX_SYNC_DURATION24h
pipeline.maxConcurrentJobsDOCKER_EXECUTOR_MAX_CONCURRENT_JOBS10
pipeline.heartbeatIntervalDOCKER_EXECUTOR_HEARTBEAT_INTERVAL5s
pipeline.dockerOwnerIdDOCKER_EXECUTOR_OWNER_ID; needed only when the server runs the Docker executor, which does not start without itempty

By default the chart creates an Ingress (server.ingress.*, class nginx). On clusters that use the Kubernetes Gateway API (Envoy Gateway, GKE Gateway, Istio, Cilium and others) it can create an HTTPRoute (gateway.networking.k8s.io/v1) attached to a Gateway you already run. Both route only to the public server Service; the internal executor API (<fullname>-internal) is never exposed.

Enable TLS on the Ingress with cert-manager:

server:
ingress:
enabled: true
hostname: synclet.example.com
tls: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod

Make sure cert-manager is installed in your cluster. The Ingress references the <fullname>-tls Secret (server.ingress.tlsSecretName overrides the name).

ValueDescriptionDefault
server.gateway.enabledRender the HTTPRoutefalse
server.gateway.parentRefsGateways the route attaches to: name (required), namespace, sectionName (listener), port[]
server.gateway.hostnamesHostnames the route matches; empty matches every hostname of the listener[]
server.gateway.pathsPath matches (type, value)PathPrefix /
server.gateway.tlsThe Gateway listener terminates TLS; makes the derived public URL httpsfalse
server.gateway.timeouts.request, server.gateway.timeouts.backendRequestTimeouts of the route’s rule (e.g. 300s). Gateways default to short ones (Envoy: 15s) that a connector check, spec or discover pulling its image can exceed; backendRequest must not exceed requestGateway default
server.gateway.labels, server.gateway.annotationsExtra metadata on the route{}
server.publicUrlURL users reach Synclet at; default for frontend.url (invite links) and oidc.callbackBaseURLempty

When server.publicUrl is empty it is derived from server.ingress.hostname if only the Ingress is enabled, or from the first server.gateway.hostnames entry if only the route is. With both enabled the render fails until server.publicUrl is set. frontend.url and oidc.callbackBaseURL still override it for their own setting.

A minimal Gateway, terminating TLS with a certificate Secret in its own namespace:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: gateway-system
spec:
gatewayClassName: eg # your implementation's GatewayClass
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: synclet.example.com
tls:
mode: Terminate
certificateRefs:
- name: synclet-example-com-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
kubernetes.io/metadata.name: synclet # the release namespace
server:
ingress:
enabled: false
gateway:
enabled: true
parentRefs:
- name: shared-gateway
namespace: gateway-system
sectionName: https
hostnames:
- synclet.example.com
tls: true
  • Cross-namespace attachment. A listener accepts routes only from its own namespace unless allowedRoutes.namespaces says otherwise (Same by default, or All, or Selector as above). If the route stays Accepted=False with NotAllowedByListeners, that is the cause. Check with kubectl get httproute <fullname> -o yaml.
  • ReferenceGrant. The route and its backend, the public Service, are in the release namespace, so attaching the route needs none. You need a ReferenceGrant in the Secret’s namespace only when a listener’s certificateRefs points at a Secret in a namespace other than the Gateway’s.
  • TLS. TLS ends at the Gateway (mode: Terminate). The chart puts no certificate on the route, and cert-manager annotations go on the Gateway, not on the chart. Set server.gateway.tls: true (or server.publicUrl with https://) so invite links and OIDC callbacks use https. Keep auth.secureCookies: true.
  • Client IPs. The auth rate limits and the login lockout trust X-Forwarded-For only from rateLimit.trustedProxies, which by default are the private ranges. To narrow it behind a Gateway, set it to the pod CIDR of the data-plane proxies (for Envoy Gateway, the envoy-* pods in the controller namespace), not to the ingress controller’s.
  1. Enable the route alongside the Ingress and set server.publicUrl to the URL users will end up on. Both objects route to the same Service, either with the old hostname on the Ingress and a new one on the route, or with the same hostname on both:
    server:
    publicUrl: https://synclet.example.com
    ingress:
    enabled: true
    hostname: synclet.example.com
    gateway:
    enabled: true
    parentRefs: [{name: shared-gateway, namespace: gateway-system, sectionName: https}]
    hostnames: [synclet.example.com]
    tls: true
  2. Check the route through the Gateway’s address before any DNS change, e.g. curl --resolve synclet.example.com:443:<gateway-ip> https://synclet.example.com/. Make sure the Gateway serves a valid certificate for the hostname.
  3. Switch DNS to the Gateway’s address and wait for the old TTL to run out.
  4. Disable the Ingress (server.ingress.enabled: false). server.publicUrl can stay or be removed, since the route’s first hostname now gives the same URL.

helm uninstall of a Gateway implementation does not remove everything it put in the cluster. Envoy Gateway, for example, leaves the eg-gateway-helm-certgen:<namespace> ClusterRole and ClusterRoleBinding, the envoy-gateway-topology-injector.<namespace> MutatingWebhookConfiguration and its CRDs, and a GatewayClass whose controller is gone keeps its finalizer and never finishes deleting. Remove them in this order:

Terminal window
kubectl delete gateway --all -n <gateway-namespace> # before the controller, so it releases the GatewayClass
kubectl delete gatewayclass <class>
helm uninstall eg -n <gateway-namespace>
kubectl delete clusterrole,clusterrolebinding eg-gateway-helm-certgen:<gateway-namespace>
kubectl delete mutatingwebhookconfiguration envoy-gateway-topology-injector.<gateway-namespace>
kubectl delete namespace <gateway-namespace>
# A GatewayClass stuck in Terminating after the controller is gone:
kubectl patch gatewayclass <class> --type merge -p '{"metadata":{"finalizers":null}}'
# CRDs, only when nothing else in the cluster uses them (Gateway API CRDs are shared):
kubectl get crd -o name | grep -E '\.gateway\.envoyproxy\.io$' | xargs kubectl delete

The cors block configures CORS on the public API (PUBLIC_HTTP_SERVER_CORS_*). The chart renders only the keys you set; the others keep the server defaults.

ValueDescriptionServer default
cors.allowAllOriginsAccept every originfalse
cors.allowedOriginsOrigin hostnames to accept, without scheme or port[]
cors.allowedOriginGlobsHostname globs; * matches one dot-separated label[]
cors.allowedMethodsMethods allowed in preflight requestsGET, POST, HEAD
cors.allowedHeadersRequest headers allowed in preflight requests["*"]
cors.exposedHeadersResponse headers exposed to the browser[]
cors.allowCredentialsAllow cookies and other credentialsfalse
cors.allowPrivateNetworkAnswer Private Network Access preflightsfalse
cors.maxAgeSeconds a browser caches a preflight response0

Lists are passed to the server comma-separated, so an entry cannot contain a comma. corsAllowAllOrigins still works as an alias of cors.allowAllOrigins; when both are set, cors.allowAllOrigins wins.

cors:
allowedOrigins: [app.example.com]
allowedOriginGlobs: ["*.example.com"]
allowCredentials: true
maxAge: 600

extraEnv adds environment variables to every Synclet container: the server, jobs and executor, and the migrate init containers of the server and jobs pods. The connector pods the executor launches do not get them. Entries are Kubernetes EnvVar objects, so both value and valueFrom work.

server.extraEnv, jobs.extraEnv and executor.extraEnv apply to one component and come after extraEnv, so a component entry wins over a global entry of the same name. Both are rendered as the container’s env, which takes precedence over envFrom: an entry overrides a key of the chart’s ConfigMap or of an extraEnvFrom source.

extraEnv:
- name: HTTPS_PROXY
valueFrom:
secretKeyRef:
name: synclet-proxy # a proxy URL with credentials
key: url
- name: NO_PROXY
value: .svc,.cluster.local
server:
extraEnv:
- name: LOG_LEVEL
value: debug # overrides the chart's ConfigMap for the server only

The server’s startup, liveness and readiness probes call /live, /live and /ready on the internal container port (8081). Each probe takes path and port (server.startupProbe.*, server.livenessProbe.*, server.readinessProbe.*); port is a container port name (public, internal) or a number. See Monitoring: health endpoints for what each one checks.

Images up to 0.4.0 serve only /health on the public port. To run such an image with this chart, point the probes there instead of disabling them:

server:
startupProbe:
path: /health
port: public
livenessProbe:
path: /health
port: public
readinessProbe:
path: /health
port: public

<fullname> on this page is the release name when it contains “synclet” (synclet for helm install synclet …), otherwise <release>-synclet; fullnameOverride replaces it.

The chart’s serviceAccount is meant for the server and jobs pods, which archive job logs and so may hold bucket permissions through Workload Identity. Two other workloads run under accounts of their own:

  • Executor (executor.serviceAccount, distributed mode). The executor launches connector pods, so it needs the connector-runner Role, but it never touches the log archive: it sends log lines to the server through the executor API and only the server archives them. With create: true (the default) the chart renders <fullname>-executor and binds it to the connector-runner Role and nothing else. Set create: false with a name to use an existing account (the chart still binds it), or leave both unset to keep the executor on the chart’s account.
  • Connector pods (pipeline.serviceAccount). Every pod the executor creates (sync, check, spec, discover) runs in k8s.namespace under the account in PIPELINE_RUNTIME_SERVICE_ACCOUNT_NAME, or that namespace’s default account when it is empty. With create: true the chart renders <fullname>-connector in k8s.namespace with the given annotations and sets that variable to its name. Every pod uses this account; a source or destination cannot choose another. The account gets no RBAC.

Both accounts take annotations, which is how Workload Identity binds a cloud identity to a Kubernetes account. Some connectors reach Google Cloud or AWS that way:

executor:
serviceAccount:
annotations: {} # usually none: the executor needs no cloud access
pipeline:
serviceAccount:
create: true
annotations:
# GKE Workload Identity
iam.gke.io/gcp-service-account: <gsa>@<project>.iam.gserviceaccount.com
# EKS IRSA
# eks.amazonaws.com/role-arn: arn:aws:iam::<account>:role/<role>

On GKE, grant roles/iam.workloadIdentityUser to serviceAccount:<project>.svc.id.goog[<k8s.namespace>/<fullname>-connector] as in the log archive example. On EKS, the role’s trust policy must allow system:serviceaccount:<k8s.namespace>:<fullname>-connector.

logArchive.* sets where the logs of finished jobs are stored; while a job runs, its logs are always in PostgreSQL. See Monitoring: log storage for how archiving works.

ValueDescriptionDefault
logArchive.backendnone, filesystem, s3 or gcsnone
logArchive.dir, logArchive.persistence.existingClaimfilesystem: directory and a pre-created ReadWriteMany PVC mounted there in the server and jobs pods/var/lib/synclet/job-logs, empty
logArchive.s3.*endpoint, region, bucket, prefix, usePathStyle, accessKeyID, secretAccessKey, or existingSecret with both keysempty
logArchive.gcs.*bucket, prefix, credentialsSecret (key key.json by default)empty
logArchive.garage.*The bundled Garage store, see belowenabled: false

No key file is needed. The server and jobs pods run as the chart’s Kubernetes service account; bind it to a Google service account that can write the bucket:

Terminal window
gcloud storage buckets add-iam-policy-binding gs://<bucket> \
--member "serviceAccount:<gsa>@<project>.iam.gserviceaccount.com" \
--role roles/storage.objectAdmin
gcloud iam service-accounts add-iam-policy-binding <gsa>@<project>.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "serviceAccount:<project>.svc.id.goog[<namespace>/<ksa>]"

<ksa> is the chart’s service account (<fullname> unless serviceAccount.name is set). Then:

serviceAccount:
annotations:
iam.gke.io/gcp-service-account: <gsa>@<project>.iam.gserviceaccount.com
logArchive:
backend: gcs
gcs:
bucket: <bucket>

Outside Workload Identity, store a service account key in a Secret and set logArchive.gcs.credentialsSecret; it is mounted only into the server and jobs pods. The executor and connector pods run under accounts of their own, so they get no bucket access.

The chart can run Garage, a small S3-compatible store, next to Synclet so archiving works without an external bucket:

logArchive:
garage:
enabled: true
bucket: synclet-logs # default
persistence:
size: 10Gi # default; size it for your log volume and retention
storageClass: ""

This selects the s3 backend and points Synclet at the <fullname>-garage Service on port 3900 (region garage, path-style). Leave logArchive.backend at none: combining Garage with another backend fails at render time.

Garage runs as a single-node StatefulSet (replication factor 1) with its metadata and data on one ReadWriteOnce volume. On startup it assigns its own layout, creates the access key and the bucket if they are missing and grants the key read/write on the bucket, so no bootstrap job is involved. Its RPC secret, admin token and S3 key are generated once into the <fullname>-garage Secret, which is kept on helm uninstall together with the volume: Garage refuses to start if the stored key and the Secret disagree. Delete both to start over, or bring your own Secret with logArchive.garage.existingSecret (keys rpc-secret as 64 hex chars, admin-token, access-key-id, secret-access-key). The S3 (3900) and admin (3903) ports are exposed only through a ClusterIP Service.

Garage is AGPL-3.0. The chart runs the unmodified upstream image as a separate service that Synclet talks to over the S3 API, which doesn’t affect Synclet’s own license.

If you run the Prometheus Operator, enable the ServiceMonitor:

server:
serviceMonitor:
enabled: true
interval: 30s
labels:
release: prometheus # match your Prometheus selector

Prometheus then scrapes /metrics on the internal Service port. See Production Checklist for recommended Grafana dashboards and alerting rules.

Airbyte is a trademark of Airbyte, Inc. Synclet is not affiliated with, endorsed by or sponsored by Airbyte, Inc. Connector images keep their own licenses: see Connector licenses.