Deployment
fold is a single static binary with no local state: it never writes to disk, terminates no TLS, and keeps everything cross-request either in memory or in an optional Redis. That makes every deployment shape the same three decisions — how the config document reaches the process, how the secretRef environment variables reach it, and what sits in front of it for TLS.
- Docker / compose — simplest; the image is ~22 MB distroless.
- Kubernetes — the in-repo Helm chart encodes the probe/allowlist details below.
- VM / bare metal — prebuilt binaries or
go install, plus systemd.
Whatever the shape, run fold --validate against the config in CI or a pre-start hook: it parses and validates the document and exits (it never resolves secrets, so it needs no credentials). See /configuration/ for the document itself.
Docker
Section titled “Docker”docker run --rm -p 8080:8080 \ -e FOLD_CONFIG="$(cat fold.config.json)" \ -e ML_SEARCH_API_KEY=... \ ghcr.io/fold-run/fold:latest --host 0.0.0.0FOLD_CONFIGtakes either a file path or the JSON document itself — inlining it avoids a volume mount entirely.--host 0.0.0.0is required in a container: the binary binds127.0.0.1by default, which is unreachable through published ports.- Secrets referenced by the config’s
secretReffields are ordinary environment variables (-e NAME=...or--env-file). - The image runs as nonroot on distroless static;
--read-onlyworks.
Images are multi-arch (linux/amd64, linux/arm64), tagged latest and per release (e.g. v1.10.1).
docker compose
Section titled “docker compose”compose.yaml at the repo root runs the gateway with ./fold.config.json mounted, plus an optional Redis under a profile:
cp fold.config.example.json fold.config.json # then editdocker compose up -dcurl -fsS http://localhost:8080/health
docker compose --profile redis up -d # with shared-state RedisThe fold service has no compose healthcheck — distroless images carry no shell or curl to run one with. Probe /health from the host or your monitoring instead (see /operations/ for the endpoint’s semantics).
Kubernetes (Helm)
Section titled “Kubernetes (Helm)”The Helm chart lives in-repo at deploy/helm/fold — it is not published to any OCI registry or chart repository. Install it from a checkout:
git clone https://github.com/fold-run/fold.githelm install fold fold/deploy/helm/fold -n fold --create-namespace -f my-values.yamlconfig: upstreams: - id: github-tools url: https://mcp.platform.acme.com/mcp namespace: gh auth: { strategy: static, secretRef: GH_TOOLS_API_KEY, header: x-api-key, scheme: "" } server: allowedHosts: ["gw.example.com"]
envFrom: - secretRef: name: fold-upstream-secrets # holds GH_TOOLS_API_KEYHow the pieces map:
- Config — either inline under
config:(the chart renders it into a ConfigMap, and a checksum annotation rolls the Deployment when it changes) orexistingConfigMap:naming a ConfigMap you manage with the document under the keyfold.config.json(thenprobes.hostHeaderbecomes required — see below). With an externally managed ConfigMap, addserver.extraArgs: ["--watch"]so fold hot-reloads the mounted document when Kubernetes syncs it (the mtime poll handles the atomic-rename update ConfigMap mounts perform) — no reloader controller or rollout needed for the reloadable sections (see Hot reload). - Secrets — the config document never contains secret material; its
secretReffields name environment variables. Put the values in a Kubernetes Secret and inject withenvFrom. The validate init container (on by default) needs none of them. - Redis — set
redis.existingSecret(orredis.url) to populateREDIS_URLwhen running more than one replica; see Redis for fleets.
allowedHosts and health probes
Section titled “allowedHosts and health probes”server.allowedHosts is the gateway’s DNS-rebinding protection: any request whose Host (or Origin) hostname is not on the allowlist is answered 403 — and that includes /health and /metrics, not just /mcp. When unset, the allowlist is the localhost set; when set, it replaces the default rather than extending it. The port is stripped before matching, and ["*"] disables the check (only acceptable behind a trusted proxy that sets/validates Host itself).
Kubelet probes send Host: <podIP>:<port>, which no sane allowlist contains, so the chart’s httpGet probes send an explicit Host header: probes.hostHeader if set, else the first non-"*" entry of the inline config’s allowedHosts, else localhost. If you manage config outside the chart, you must set probes.hostHeader to a hostname your allowlist admits — the chart refuses to render otherwise, because the failure mode is a silent 403 loop where pods never become ready.
The same rule applies to any external health checker (load balancer target checks, uptime monitors): whatever hostname they send must be on the allowlist.
Probes
Section titled “Probes”/health is not a trivial endpoint: every call pings all upstreams concurrently with a 5-second internal budget and returns 503 when none are reachable (full field reference in /operations/). The chart’s probe defaults follow from that:
- Readiness:
httpGet /health, period 15 s, timeout 8 s (above the 5 s internal budget) — pods only receive traffic while at least one upstream is reachable. - Liveness: plain TCP connect, deliberately not
/health— liveness should detect a wedged process, not restart pods because upstreams are down, and shouldn’t generate upstream traffic every few seconds. - Startup:
httpGet /healthwith a ~2-minute budget for first upstream connects and JWKS fetches.
Upgrading to v1.9 or later from v1.4 or earlier: the path was /healthz, kept as a deprecated alias through v1.8 and removed in v1.9 — a probe left on it now 404s, which for a readiness probe means pods that never become ready. Move probes, load-balancer target checks, and uptime monitors to /health before you take the upgrade. (fold-discovery keeps its /healthz alias for now: there the path doesn’t 404 when removed but falls through to the document handler, so a stale probe would quietly start scraping the upstreams document and reporting 200.)
Shutdown: on SIGTERM the gateway drains for up to 10 s, then exits; long-lived SSE streams are cut at that bound. The chart sets terminationGracePeriodSeconds: 30 to stay clear of it.
Hot reload
Section titled “Hot reload”A running gateway applies config changes without a restart, three ways: kill -HUP <pid> re-reads the config source; --watch polls the config file’s mtime (2 s) and reloads on change; embedders call gw.Reload(cfg) (see /embedding/). The upstream set and the policy engine swap atomically — unchanged upstreams keep their live sessions, removed ones drain, clients get list_changed — while the auth, server, routing, audit, tracing, and discovery sections are fixed at startup: a reload that touches them fails loudly and the running configuration keeps serving, so a bad push never takes the gateway down. A rejected or invalid document is logged and ignored the same way.
Restart-only changes (auth issuers, listen address, tracing endpoint) still need a rollout; in Kubernetes the inline-config checksum annotation does that automatically, and under systemd ExecReload covers the SIGHUP path (see the unit below).
For upstreams that come and go without any operator involvement, see /discovery/ — fold can poll a registry document and swap discovered upstreams in and out on its own.
TLS and ingress
Section titled “TLS and ingress”fold does not terminate TLS — put an ingress controller, load balancer, or reverse proxy in front of it. Two things matter at that layer:
- SSE: MCP responses ride long-lived SSE streams. Raise idle/read timeouts and disable response buffering:
- ingress-nginx:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600",nginx.ingress.kubernetes.io/proxy-buffering: "off" - AWS ALB: raise
idle_timeout.timeout_secondswell above 60 - Traefik: raise
respondingTimeouts.readTimeout/idleTimeout - nginx (plain):
proxy_read_timeout 3600s; proxy_buffering off;
- ingress-nginx:
- Host: the public hostname the proxy forwards must be in
server.allowedHosts.
Redis for fleets
Section titled “Redis for fleets”A single replica needs no Redis: rate limits, circuit breakers, list caches, EMA replay protection, and task ownership live in memory. With multiple replicas, set REDIS_URL (or server.redisUrl) so those behave fleet-wide — otherwise each replica enforces its own rate-limit window, trips its own breaker, an EMA ID-JAG could be redeemed once per replica, and a task’s binding to the principal who minted it holds only on the replica that served the mint — elsewhere it falls through to the probe path and is reachable by any caller. If you run more than one replica with task-using upstreams, Redis is the difference between a guarantee and a coincidence.
Operationally forgiving by design: every Redis operation is bounded at 500 ms and fails open, so a Redis outage degrades to per-instance state instead of taking the gateway down (replay protection and task ownership fall back to each instance’s local mirror rather than to nothing). A bad URL is a boot failure (validated with a PING at startup). Any managed Redis- or Valkey-compatible service works; the chart deliberately ships no Redis subchart — bring your own or a managed offering.
VM / systemd
Section titled “VM / systemd”Grab a binary from the releases page (tar.gz per OS/arch, with checksums, SBOMs, and build provenance), or go install github.com/fold-run/fold/cmd/fold@latest.
[Unit]Description=fold MCP gatewayAfter=network-online.targetWants=network-online.target
[Service]ExecStart=/usr/local/bin/fold --config /etc/fold/fold.config.json --host 0.0.0.0 --log-format json# secretRef env vars (and optionally REDIS_URL), e.g. ML_SEARCH_API_KEY=...EnvironmentFile=/etc/fold/envExecStartPre=/usr/local/bin/fold --config /etc/fold/fold.config.json --validate# `systemctl reload fold` hot-reloads the upstream set and policy.ExecReload=/bin/kill -HUP $MAINPIDDynamicUser=yesNoNewPrivileges=yesProtectSystem=strictProtectHome=yesPrivateTmp=yesRestart=on-failure# The gateway drains for up to 10s on SIGTERM.TimeoutStopSec=15
[Install]WantedBy=multi-user.targetchmod 600 /etc/fold/env. The binary never writes to disk, so ProtectSystem=strict needs no ReadWritePaths. Terminate TLS in front (nginx/caddy/LB) per the section above.
Audit and logs
Section titled “Audit and logs”The two output streams are separable by design:
- stdout — audit events (one JSON line per terminal response, including denials) when the
stdoutsink is configured. Point your log pipeline’s stdout collector at your SIEM. - stderr — operational logs (
log/slog;--log-format json).
For direct SIEM delivery use the webhook sink instead (asynchronous, batched; buffered events are dropped on overflow rather than adding request latency — keep stdout as the durable copy if that matters). If no audit section is configured, nothing is emitted. See /operations/ for the full audit event field reference.
GET /metrics serves Prometheus metrics; the chart has an optional ServiceMonitor (metrics.serviceMonitor.enabled).
Production checklist
Section titled “Production checklist”-
server.allowedHostspinned to your public hostname(s), not["*"] -
auth.mode: "required"with your IdP’s issuer (anonymous gateways have no per-principal policy or rate limits) -
policy.defaultDecision: "deny"with explicit allow rules - TLS terminated in front; SSE timeouts/buffering configured
- Redis configured when running more than one replica
- An
auditsink configured and shipped somewhere durable -
fold --validategating config changes in CI/CD - Kubernetes: PodDisruptionBudget on, resource limits sized, probe Host header matches the allowlist
- Alerts on
fold_upstream_breaker_state,fold_http_rejections_total, and/healthdegradation (plusfold_discovery_syncs_totalrejected/erroroutcomes when discovery is enabled)
For day-2 operation of a running gateway — endpoints, metrics, audit fields, error codes, and how reloads and discovery surface in logs — see /operations/.