Ferry is a single-binary Kubernetes image rollout controller, build coordinator, and GitOps snapshotter with a calm web UI across multiple clusters.
Run Ferry in -demo mode to launch three in-memory fake Kubernetes clusters (de, sg, jp) populated with Deployments, a StatefulSet, a DaemonSet, a CronJob, an Argo CD-managed service, and a paused rollout:
make demo
# Open http://localhost:8080Or build and run with Docker:
docker build -t ferry:local .
docker run --rm -p 8080:8080 ferry:local -demoPress / on the Home screen to focus the deploy composer, paste docker.cnb.cool/acme/builder/web-portal:e4f19a02bc11, press Enter to inspect the dry-run preview across clusters, and click Deploy.
- Clusters & Namespaces — Ferry talks directly to the Kubernetes REST API (
get,list,patchonly, noclient-go) across an in-cluster ServiceAccount and any number of remote clusters. - Safe Image Rollouts — Updates
Deployment,StatefulSet,DaemonSet, andCronJob(containersandinitContainers) using atomic RFC 6902 JSON Patch (test+replace+kubernetes.io/change-causewithfieldManager=ferry). Workloads carryingargocd.argoproj.io/tracking-idare skipped automatically; workloads annotated withferry.star.moe/auto-deploy: "false"pause automatic CI rollouts while still allowing targeted single-service rollouts. - Sources & Builds — Map
owner/repo@branchto either CNB (shallow git mirror viahttp.extraHeader+/-/build/start) or External CI (GitHub Actions, GitLab CI, etc.). Build events automatically link to subsequent/deploycalls by commit tag. - OIDC & Scoped CI Auth — Browser users sign in via OIDC Authorization Code + PKCE (
ES256/RS256JWKS, Pocket ID / Dex / Keycloak / GitHub Enterprise OIDC) gated byallowedGroupsorallowedUsers. CI pipelines authenticate with SHA-256-hashedfy_...deploy tokens orgitReadrepository tokens. - Cluster Snapshots — Periodically exports cleaned workload/network manifests and SOPS (
age)-encrypted Secrets to a Git repository, re-encrypting Secrets only when their plaintext SHA-256 changes. - DNS Sync — Ingress hosts under
dns.zoneget an A record at their cluster's address, or withdns.cdnon, a CNAME to a pool of Gcore CDN resources that keeps origin addresses out of DNS. Ferry also issues and renews the pool's wildcard certificate.
-
Apply namespace-scoped RBAC (
get/list/patchonly; separate optional Role for snapshots):kubectl apply -f deploy/rbac.yaml # On each remote cluster (e.g. sg, jp): kubectl apply -f deploy/remote-cluster-rbac.yaml -
Create
ferry.yaml(seedeploy/ferry.example.yamlfor all options;${VAR}placeholders expand from environment variables at startup):listen: ":8080" auth: sessionKey: "${SESSION_KEY}" oidc: issuer: "https://id.example.com" clientID: "ferry" clientSecret: "${OIDC_CLIENT_SECRET}" publicURL: "https://ferry.example.com" allowedGroups: ["ops"] state: kubernetes: namespace: "app" name: "ferry-state" namespaces: ["app"] clusters: - name: de inCluster: true - name: sg server: "https://k8s-sg.example.com:6443" caFile: "/secrets/clusters/sg.ca" tokenFile: "/secrets/clusters/sg.token"
-
Deploy Ferry:
kubectl apply -f deploy/deployment.yaml
Ferry runs as UID
65532:65532withreadOnlyRootFilesystem: true,allowPrivilegeEscalation: false, and all Linux capabilities dropped. No external database or Redis is required: sources, hashed deploy tokens, and the 300-event activity ring buffer live in theferry-stateConfigMap (with optimisticresourceVersionconflict retry).
Create a deploy token in Settings → Deploy tokens, store it as FERRY_TOKEN in your CI secrets, and notify Ferry after pushing your container image:
curl -fsS -X POST "https://ferry.example.com/deploy" \
-H "Authorization: Bearer ${FERRY_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"image\":\"ghcr.io/org/service\",\"tag\":\"${GIT_SHA:0:12}\",\"source\":\"github:org/service@${GIT_SHA:0:12}\"}"- name: Roll out via Ferry
env:
FERRY_TOKEN: ${{ secrets.FERRY_TOKEN }}
SHORT_SHA: ${{ github.sha }}
run: |
TAG="${SHORT_SHA:0:12}"
curl -fsS -X POST "https://ferry.example.com/deploy" \
-H "Authorization: Bearer ${FERRY_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"image\":\"ghcr.io/org/service\",\"tag\":\"${TAG}\",\"source\":\"github:${{ github.repository }}@${TAG}\"}"POST /deploy returns HTTP 200 with the JSON array of per-workload results, or HTTP 500 (still returning the JSON results array) if any workload failed to patch.
Ferry is a drop-in replacement for ci-relay with zero changes required on external callers:
POST /deploy— Accepts{"image", "tag", "source"}withAuthorization: Bearer <token>(bothfy_...deploy tokens andauth.gitReadrepository tokens).POST /— Remains a full alias forPOST /webhook/github(X-Hub-Signature-256HMAC verification), whileGET /serves the web UI.POST /snapshot— Immediately returns HTTP202(snapshot queued) and runs a single-flight snapshot in the background (sops -e -iusing the repository's.sops.yaml, or an optionalagePublicKeyoverride). Legacy single-namespacesecrets.enc.yaml/secrets.sha256files are automatically migrated to<namespace>.secrets.enc.yaml/<namespace>.secrets.sha256.clustersFile&seedSources— PointclustersFileat an existingCLUSTERS_FILEJSON secret andseedSourcesatrepos.jsonfor automatic first-boot import.
See docs/DESIGN.md for the complete architecture, HTTP API table, state schema, RBAC model, and security checklist.
# Build frontend and run all offline race-detector tests
cd web && npm ci && npm run build && cd ..
go vet ./...
go test -race ./...Licensed under the Apache License, Version 2.0.

