* feat(authz): add plugin resource targets, provenance, action and management enforcement
PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.
- `authz/plugin_targets.py` is the single encoder/validator for composite
targets (`namespace/part`), with one validating composer behind three
kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
`plugin_management`, plus the batch filters the page slice will consume):
disabled is a no-op, an explicit deny raises, and provider errors follow
`fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
`arequire_plugin_management` with two `app.state` resolver keys; the
Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
display (assembly descriptor, Layer-2 context later); no decision reads a
label.
- Plugin providers use a `(config signature, loop key)` cache so a
loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
`config_version` 47 -> 48 across the three mirrors.
Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.
* fix(authz): decide plugin resources from the request's config snapshot
Review round 1 on PR #5842 (willem-bd).
P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.
P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.
Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.
* fix(authz): treat a request-time config absence as unavailable
Review round 2 on PR #5842 (willem-bd).
P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.
P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.
Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.
* fix(authz): apply the running policy when the plugin config is unreadable
Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).
The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.
The rule is now "has this process loaded a configuration?", not "was the last
read successful?":
- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
`deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
*disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
failure flag of the configuration this process runs on (authorization
disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
default), so an enabled Gateway answers 403/False in the window instead of
letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
closed when nothing is loaded (round 1's rule), otherwise the loaded policy
decides.
`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.
Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.
* fix(authz): keep a denial whose reasons raise while being read
* fix(authz): intersect plugin batch filters with the candidates
The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.
* docs(plugins): map a management denial to 403 in the route example
The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.
* docs(extensions): point the 0.2.4 contract row at the PR that ships it
Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
21 KiB
DeerFlow Helm Chart
Deploys the full DeerFlow stack to Kubernetes: gateway (backend + embedded LangGraph runtime), frontend (Next.js), nginx (internal reverse proxy preserving the compose routing), and the provisioner (K8s-native sandbox that spawns code-execution Pods on demand).
This chart translates the production docker/docker-compose.yaml into native
Kubernetes resources. No existing repo files are modified.
Prerequisites
- A Kubernetes cluster (Docker Desktop K8s, OrbStack, kind, k3d, or a real cluster).
kubectl+helm3.8+ installed (OCI registry support stabilized in 3.8; earlier 3.x needsHELM_EXPERIMENTAL_OCI=1).- The three DeerFlow images — either the published ones (see "Install the published chart" below) or built locally (see step 1).
- An Ingress controller (e.g. ingress-nginx) if you enable
ingress.
Install the published chart (GHCR)
The chart and all three images are published to GHCR on every v* release tag
(see .github/workflows/container.yaml and chart.yaml). Skip the build step
and install directly:
helm install deer-flow oci://ghcr.io/<owner>/charts/deer-flow \
--version <version> \
-n deer-flow --create-namespace \
-f my-values.yaml
where <owner> is the GitHub owner the chart is published from and <version>
matches the release tag without the leading v (tag v0.1.0 → --version 0.1.0).
Note: the helm chart is new in 2.1.0 - no chart was published before it. It publishes to
oci://ghcr.io/<owner>/charts/deer-flow(thecharts/prefix keeps it distinct from thedeer-flow-{backend,frontend,provisioner}image packages).
Point the chart at the published images:
image:
registry: ghcr.io/<owner> # owner prefix; images are <owner>/deer-flow-<name>
tag: "<version>" # match the release tag (sans leading `v`)
pullSecrets:
- { name: regcred } # only if the GHCR package is private
The chart's gatewayImage / frontendImage / provisionerImage defaults
already match the published image names (deer-flow-backend,
deer-flow-frontend, deer-flow-provisioner), so only registry and tag
are required. New GHCR packages default to private — flip the package to
public in its GHCR settings page for unauthenticated pulls, otherwise create a
pull secret (step 1) and reference it via image.pullSecrets.
The OCI chart and the images are versioned independently of the chart's
appVersion; always setimage.tagto the release that matches your chart--versionunless you have a reason to pin differently.
1. Build & push images (custom builds only)
Skip this section if you're using the published chart above. To build the images yourself from the existing Dockerfiles:
REGISTRY=ghcr.io/yourorg
TAG=latest
# backend - build with the `postgres` extra so multi-replica deploys can use
# shared Postgres (matches the published image)
docker build -t $REGISTRY/deer-flow-backend:$TAG --build-arg UV_EXTRAS=postgres -f backend/Dockerfile .
# frontend
docker build -t $REGISTRY/deer-flow-frontend:$TAG -f frontend/Dockerfile .
# provisioner
docker build -t $REGISTRY/deer-flow-provisioner:$TAG -f docker/provisioner/Dockerfile docker/provisioner
docker push $REGISTRY/deer-flow-backend:$TAG
docker push $REGISTRY/deer-flow-frontend:$TAG
docker push $REGISTRY/deer-flow-provisioner:$TAG
These names match the chart's gatewayImage / frontendImage /
provisionerImage defaults, so only image.registry and image.tag need to
point at them.
If your registry needs auth, create a pull secret:
kubectl create secret docker-registry regcred \
--docker-server=ghcr.io \
--docker-username=youruser \
--docker-password=yourtoken \
-n deer-flow
2. Configure values
Copy and edit values.yaml → my-values.yaml. At minimum set:
image:
registry: ghcr.io/yourorg
tag: latest
pullSecrets:
- { name: regcred }
ingress:
enabled: true
className: nginx
host: deer-flow.example.com
tls:
enabled: true
secretName: deer-flow-tls
secrets:
OPENAI_API_KEY: sk-...
# add channel tokens, search keys, etc. as needed
The default ingress annotations permit a 100 MiB local .skill archive plus
multipart framing, stream request bodies without ingress buffering, and allow
up to 600 seconds for a response, which the API requests that wait on a model
call or a whole run all need — skill install and custom-skill edits (each file
is scanned by an LLM), /api/threads/{id}/compact, /api/input-polish, and
/api/runs/wait. If you replace ingress.annotations, preserve equivalent
size, streaming, and response-timeout settings for your ingress controller, or
local skill uploads may fail before DeerFlow completes the installation and
those requests may time out while Gateway is still working — for
/api/runs/wait the disconnect also cancels the run.
Provide your model config under config (keep secrets as $VAR references —
they resolve from the secrets map):
config: |
config_version: 49
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI
model: gpt-4
api_key: $OPENAI_API_KEY
request_timeout: 600.0
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
database:
backend: postgres
postgres_url: $DATABASE_URL
pool_recycle: 300
command_timeout: 30
checkpointer:
type: postgres
connection_string: $DATABASE_URL
stream_bridge:
type: redis # cross-pod SSE; URL from DEER_FLOW_STREAM_BRIDGE_REDIS_URL
knowledge_base:
enabled: true
scope_selection_enabled: false
# Provider connection/retrieval settings belong on the knowledge_search tool.
# Tools MUST be listed explicitly - the agent gets none otherwise
# (BUILTIN_TOOLS only adds present_file + ask_clarification). The chart
# default in values.yaml enables the sandbox tools + web tools (web_search,
# web_fetch, image_search - no API key); when you override `config:`, copy
# them in. Full list in values.yaml / config.example.yaml. The web tools need
# outbound egress from the gateway pod.
tool_groups:
- name: web
- name: file:read
- name: file:write
- name: bash
- name: knowledge
tools:
- name: web_search
group: web
use: deerflow.community.ddg_search.tools:web_search_tool
max_results: 5
- name: web_fetch
group: web
use: deerflow.community.jina_ai.tools:web_fetch_tool
timeout: 10
- name: image_search
group: web
use: deerflow.community.image_search.tools:image_search_tool
max_results: 5
- name: knowledge_search
group: knowledge
use: deerflow.community.ragflow.tools:knowledge_search_tool
base_url: http://ragflow:9380
api_key: $RAGFLOW_API_KEY
- name: list_knowledge_bases
group: knowledge
use: deerflow.community.ragflow.tools:list_knowledge_bases_tool
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
# also: ls, read_file, glob, grep, write_file, str_replace (see values.yaml)
$DATABASE_URL is injected from the postgres Secret (see below). The
checkpointer: section is required for multi-replica operation — the LangGraph
Store (cross-thread memory + thread list) reads it and does not fall back to
database:. stream_bridge.type: redis is the default and routes live SSE
events through the bundled redis StatefulSet (or redis.external).
Because config: is a single override blob, a partial config: replaces the
chart default entirely - keep the tools:/tool_groups: block (or the agent
will have no tools) and the sandbox:/database:/checkpointer:/stream_bridge:
sections shown above.
extensionsConfig is an initial seed, not a live read-only mount. An init
container copies it into
/app/backend/.deer-flow/extensions-config/extensions_config.json, where the
Gateway can persist MCP and skill-state API updates. With
persistence.home.enabled: true, the runtime file is kept on the home PVC and
is not overwritten by later Helm upgrades; delete that runtime file before a
pod restart only when you intentionally want a changed extensionsConfig seed
to replace it. With persistence disabled, the writable copy uses emptyDir
and is reseeded whenever the Pod is replaced.
3. Install (from a local chart checkout)
For a custom build or local development, install from the chart directory:
helm install deer-flow deploy/helm/deer-flow \
-n deer-flow --create-namespace \
-f my-values.yaml
4. Verify
kubectl -n deer-flow get pods
kubectl -n deer-flow port-forward svc/nginx 2026:2026
curl http://localhost:2026/health # gateway health via nginx
Hit the Ingress host (map it in /etc/hosts for local clusters) to load the UI.
Provisioner sanity check:
kubectl -n deer-flow exec deploy/deer-flow-provisioner -- curl -s localhost:8002/health
Architecture notes
- PostgreSQL is the default database. A bundled single-instance postgres
StatefulSet (
postgresql.enabled: true) runs in the namespace and the gateway connects via the in-cluster Service. The DSN is auto-generated into a Secret (keydatabase-url) and injected asDATABASE_URL;config.yamlreferences it as$DATABASE_URLindatabase.postgres_url. Schema is bootstrapped automatically on gateway startup (alembiccreate_all+stamp head). For real HA, disable the bundled instance and point at a managed DB:postgresql: enabled: false external: host: mydb.example.com # or set databaseUrl / existingSecret port: 5432 database: deerflow username: deerflow password: changeme - Graceful shutdown & memory drain. The gateway pod sets
terminationGracePeriodSeconds(default 45s, overridable viagateway.terminationGracePeriodSeconds) plus an optionalpreStopsleep (gateway.preStopSleepSeconds, default 5s). The grace period MUST exceed the Gateway's graceful-shutdown work — channel stop (~5s) plus the memory-queue drain (memory.shutdown_flush_timeout_seconds, default 30s) plus a buffer — because the drain runs on a daemon thread and K8s SIGKILLs anything still running at the end of the grace window. K8s defaults to 30s, which SIGKILLs the drain mid-flight and silently re-introduces the memory loss the drain is fixing. When you raisememory.shutdown_flush_timeout_seconds, raisegateway.terminationGracePeriodSecondsto match (channel stop + drain + buffer). - Gateway replicas. Postgres + the Redis stream bridge together make the
gateway's persisted state (checkpointer + run/thread metadata) and live
stream path cross-pod-safe. The default is still 1 replica: do not raise
gateway.replicaspast 1 yet. Run control —create_or_rejectdedup,cancel, and orphan reconciliation — is still worker-local (in-processasyncio.Lock+ in-memoryrecord.task), tracked by issue #3948. With >1 replica a double-submit can create two runs on one thread (checkpoint corruption), a cancel can land on a non-owner pod (409), and a crashed pod's runs staypending/runningforever. Stay on 1 replica until that work lands. - Scheduled task recovery. If a deployment explicitly enables
scheduler.multi_instance: true, it must use shared Postgres,run_ownership.heartbeat_enabled: true, andrun_events.backend: db. Scheduler startup then preserves live scheduled runs owned by another Pod, atomically takes over only expired leases, and fences stale post-launch bookkeeping.max_concurrent_runsis a shared global cap across Pods, including pre-launch dispatch reservations. Restart all Gateway Pods after changing these startup-only settings. This does not remove the broader Gateway replica limitations described above. - Redis stream bridge. A bundled single-instance redis StatefulSet
(
redis.enabled: true,redis:7-alpine) runs in the namespace and the gateway connects via the in-cluster Service. Per-run SSE events are stored in Redis Streams (PR #3191) so a client connected to any gateway pod receives live events and reconnect resumes fromLast-Event-ID. The URL is auto-generated into a Secret (keyredis-url) and injected asDEER_FLOW_STREAM_BRIDGE_REDIS_URL;config.yamlsetsstream_bridge.type: redisby default. No-auth by default (ClusterIP isolation, matching compose); setredis.auth.passwordto enable AUTH. For a managed Redis, disable the bundled instance and point at it viaredis.external. - Persistence. A PVC (
<release>-home) backs/app/backend/.deer-flow(sqlite DB, memory, custom agents, per-thread user-data). The gateway mounts it withsubPath: deer-flowso the layout matches the provisioner's PVC user-data mode. DefaultReadWriteOnce; useReadWriteMany(NFS) on multi-node clusters so sandbox Pods on other nodes can mount it. - Provisioner RBAC. The provisioner gets a ServiceAccount with a namespaced
Role (get/list/watch/create/delete on pods + services) and a narrow ClusterRole
(namespace get/create). It uses in-cluster service-account creds — no
kubeconfig mount. The unused update/patch/pods-exec/events verbs were dropped
(audited against
docker/provisioner/app.py). - Skills. Disabled by default (emptyDir at
/app/skills). Populate viaskills.existingClaimorskills.configMap, or bake skills into a custom gateway image.
Security
Enforced posture
All workloads run as non-root with all Linux capabilities dropped. No container escalates privileges or runs as uid 0.
| workload | runAsUser | fsGroup | writable-path handling |
|---|---|---|---|
| gateway | 1000 | 1000 | .deer-flow PVC group-writable via fsGroup; PYTHONDONTWRITEBYTECODE=1 suppresses .pyc writes; UV_CACHE_DIR=/tmp |
| frontend | 1000 (node) |
1000 | emptyDir at /app/frontend/.next/cache (root-owned in the image) |
| nginx | 101 (nginx) |
101 | command writes the rendered config to /tmp/nginx.conf and loads nginx -c /tmp/nginx.conf (since /etc/nginx is root-owned); emptyDir at /var/cache/nginx |
| provisioner | 1000 | — | no PVC; PYTHONDONTWRITEBYTECODE=1 |
| postgres | 999 (postgres) |
999 | official postgres:16 entrypoint detects non-root and skips the chown/gosu dance; data PVC group-writable via fsGroup |
| redis | 999 (redis) |
999 | official redis:7-alpine entrypoint detects non-root and skips the gosu dance; data PVC group-writable via fsGroup |
Every container sets:
runAsNonRoot: trueallowPrivilegeEscalation: falsecapabilities.drop: ["ALL"]seccompProfile: { type: RuntimeDefault }
All listening ports are >1024 (8001 / 3000 / 2026 / 8002 / 5432), so no
NET_BIND_SERVICE capability is required.
ConfigMap rollout. ConfigMaps mount via subPath, which does not receive
in-place updates — a helm upgrade that changes only a ConfigMap would leave
pods on stale config. Each pod template carries a checksum/* annotation (SHA256
of the rendered ConfigMap): checksum/config + checksum/extensions on the
gateway, checksum/nginx on nginx. Any content change alters the pod spec and
triggers a rolling restart.
Resource defaults. Every workload ships with modest requests+limits in
values.yaml; override per workload (gateway.resources, frontend.resources,
nginx.resources, provisioner.resources, postgresql.primary.resources,
redis.primary.resources).
Not yet enforced (deferred hardening)
These are intentionally not set in this chart revision. Each can be added per-workload with testing:
readOnlyRootFilesystem: true— makes the container's root filesystem immutable so a compromised process can't persist changes to the image. Not enabled because it requires auditing every runtime write path and mounting anemptyDirover each. Known paths:- gateway / frontend / nginx / provisioner:
/tmp(uv cache, python tempfiles, the nginx config + pid, node temp) — oneemptyDirat/tmpeach. - postgres:
/tmpand/var/run/postgresql(the Unix-socket dir). The first four are mechanical. postgres is the hard case — the official image writes its socket to/var/run/postgresqland isn't designed for a read-only root, so it may need socket-path redirection (PGHOST/unix_socket_directories). Optionally, addUSERdirectives to thebackend/Dockerfile,frontend/Dockerfile, anddocker/provisioner/Dockerfileso the images are non-root by default (defense in depth — the chart already forces the uid viasecurityContext, so this is not required). A cluster enforcing therestrictedPod Security Admission standard would require this setting.
- gateway / frontend / nginx / provisioner:
- Provisioner RBAC narrowing. The Role grants get/list/watch/create/delete on pods and services in the namespace (update/patch/pods-exec/events were dropped as unused). These verbs still apply to all Pods in the namespace, not just sandbox Pods — RBAC can't scope by label, so the remaining options are a dedicated sandbox namespace or admission control (OPA/Kyverno).
startupProbe. Workloads have readiness + liveness probes but no startup probe. The gateway'slivenessProbe.initialDelaySeconds: 30covers slow starts today; astartupProbewould let it take arbitrarily long to initialize without risking a liveness kill during a cold start (e.g. slow model config load).
None of these affect correctness of the current deployment.
Migrating an existing volume to non-root
fsGroup does not apply to subPath mounts, and it changes group ownership
but not file mode — so a PVC written by an earlier root run (e.g. a cluster
that ran the gateway as root before enabling this hardening, or a backup restore
of root-owned files) will keep files like .jwt_secret at 0600 root:root. The
non-root gateway (uid 1000) then can't read them and crashes on the first auth
request with RuntimeError: Failed to read JWT secret from .../.jwt_secret.
Fresh installs are unaffected — uid 1000 creates every file as 1000:1000.
To fix an existing root-written PVC, run a one-shot root pod that chowns the volume to the gateway uid (1000), then restart the gateway:
cat <<'EOF' | kubectl apply -n deer-flow -f -
apiVersion: v1
kind: Pod
metadata: { name: fix-home-perms, namespace: deer-flow }
spec:
restartPolicy: Never
containers:
- name: chown
image: busybox:1.36
command: ["sh", "-c"]
args: ["chown -R 1000:1000 /home-pvc/deer-flow && chmod -R g+rwX /home-pvc/deer-flow"]
volumeMounts:
- { name: home, mountPath: /home-pvc }
volumes:
- name: home
persistentVolumeClaim: { claimName: deer-flow-deer-flow-home }
EOF
kubectl -n deer-flow wait --for=condition=Ready pod/fix-home-perms --timeout=30s
kubectl -n deer-flow delete pod fix-home-perms
kubectl -n deer-flow rollout restart deploy/deer-flow-deer-flow-gateway
(On a single-node cluster the fix pod can mount the RWO PVC concurrently with the
gateway; on multi-node, scale the gateway to 0 first.) A durable alternative —
an opt-in root volumePermissions initContainer that chowns on every start (the
Bitnami pattern) — is not yet wired into this chart; it would introduce a root
container, so it's left as an operator decision for now.
Sandbox Service type
The provisioner exposes each sandbox Pod behind a per-sandbox Service whose type
is controlled by provisioner.sandboxServiceType (default ClusterIP).
ClusterIP (default, recommended). The provisioner returns a cluster-DNS
URL - http://sandbox-<id>-svc.<namespace>.svc.cluster.local:8080 - so the
gateway reaches its sandbox entirely inside the cluster network. No node IP, no
high port, and the code-execution surface is not bound on every node's
interfaces. This is correct for the chart, where the gateway always runs
in-cluster.
NodePort (Docker-Compose/hybrid escape hatch). Set
provisioner.sandboxServiceType: NodePort only when the gateway is not in K8s
(e.g. the compose dev path, where the gateway is a container reaching sandbox
Pods on the host's Docker Desktop K8s). The provisioner then returns
http://{NODE_HOST}:{NodePort}. NODE_HOST defaults to the provisioner pod's
node IP via the downward API
(status.hostIP); because a NodePort is exposed on every node, the gateway can
reach <node-IP>:<NodePort> on most clusters without configuration. Override
provisioner.nodeHost only if your CNI or network policy blocks pod->node-IP
traffic:
kubectl get nodes -o wide # use INTERNAL-IP or EXTERNAL-IP
provisioner:
sandboxServiceType: NodePort
nodeHost: 192.168.x.x
On multi-node clusters, also switch persistence.home.accessMode to
ReadWriteMany (this is orthogonal to the Service type - it governs whether a
sandbox Pod can be scheduled on a node other than the gateway's).
Lint / dry-run
helm lint deploy/helm/deer-flow
helm template deer-flow deploy/helm/deer-flow -n deer-flow -f my-values.yaml | \
kubectl apply --dry-run=client -f -
Uninstall
helm uninstall deer-flow -n deer-flow
# the PVC is NOT deleted by default — remove it manually if desired:
kubectl -n deer-flow delete pvc -l app.kubernetes.io/instance=deer-flow