deer-flow/docker/docker-compose-dev.yaml
Nan Gao db7340d2fa
fix(security): add DEER_FLOW_CREDENTIALS_KEY and wire the channel credentials cipher (#6611)
* fix(security): add DEER_FLOW_CREDENTIALS_KEY for at-rest credential encryption

There was no at-rest encryption key. ChannelCredentialCipher had no
production caller: the channel service, the scheduled-task notification
outbox and the channel-connections router all built
ChannelConnectionRepository without a cipher, so store_credentials refused
(RuntimeError) and get_credentials always returned None.

- New harness module deerflow.config.credentials_key: env-only
  DEER_FLOW_CREDENTIALS_KEY (comma-separated Fernet keys; first encrypts,
  all decrypt via MultiFernet), CredentialsCipher with encrypt_text /
  decrypt_text / rotate_text, a fernet:v2: prefix with legacy fernet:v1:
  values still readable, and get_credentials_cipher() as the process-wide
  entry point. Malformed keys are refused without echoing key material.
  Without the variable a single instance generates
  {base_dir}/.credentials_key. Never derived from AUTH_JWT_SECRET.
- New deerflow.config.secret_file.read_or_create_secret_file: O_EXCL
  create (0600) plus read-back with a bounded settle window, shared by
  .credentials_key, .jwt_secret (fixes two replicas cold-starting on a
  shared volume keeping different JWT secrets) and the managed-model key.
- ChannelCredentialCipher now builds on CredentialsCipher (same
  constructors); the Gateway loads the cipher off the event loop at
  startup when channel_connections is enabled and passes it to every
  ChannelConnectionRepository. Undecryptable values stay "missing + warn".
- Startup gate: a malformed key stops startup in any topology; a declared
  multi-instance deployment with channel_connections enabled and no key
  is refused. Plain multi-worker shares the generated file and is allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(deploy): generate and inject DEER_FLOW_CREDENTIALS_KEY in Helm, compose and deploy.sh

- Helm: the app Secret generates a Fernet key (randBytes 32 translated to
  the urlsafe alphabet) and preserves it across upgrades via lookup like
  AUTH_JWT_SECRET; the gateway reads it through an optional secretKeyRef
  so a user-managed existingAppSecret without it still boots (the Gateway
  gate refuses only multi-instance deployments that store credentials).
  README, NOTES and values document backup, rotation and existingAppSecret.
- scripts/deploy.sh resolves the key shell -> .env -> persisted file ->
  generated, persisting it as $DEER_FLOW_HOME/.credentials_key (0600,
  noclobber/O_EXCL with read-back): the same file the Gateway generates in
  its runtime home, so either side's key is the one both keep.
- Both compose files pass DEER_FLOW_CREDENTIALS_KEY=${...:-} to the gateway.
- .env.example entry with a generation hint; README and CHANGELOG notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): make abandoned secret-file replacement single-winner

A rename is last-writer-wins, so replicas that restart together and all
find the same abandoned empty secret file could each replace it and keep
a different value. The process that exclusively creates
<name>.replacing now owns the replacement, re-checks the file is still
empty, and publishes by renaming the claim over it; peers wait for that
value, bounded so a crashed replacer cannot block startup forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): link DEER_FLOW_CREDENTIALS_KEY entries to #6611

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): name the setting that turns off a credentials-key consumer

The multi-instance refusal ended with "or disable
channel_connections.enabled=true"; it now says "or set
channel_connections.enabled=false".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(security): never expose a secret file before its value is written

O_EXCL published the name before its content, so a creator that stalled
mid-write left an empty file a peer replaced after the settle window; the
resumed creator then returned a value nobody persisted, or deleted the
peer's valid file when its own write failed. Creators now write and sync
a private temporary file and hard-link it to the name, which fails if
the name exists; a failed creator only removes its own temporary file.
Filesystems without hard links publish through the same single-winner
<name>.replacing claim that replaces abandoned empty files, so creation
and replacement are serialized there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(models): recover an empty managed-model key when no catalog exists

An interrupted first save could leave an empty key file; every later save
then passed empty bytes to Fernet and the endpoint returned 503 until the
file was removed by hand. An empty key with no catalog encrypted nothing,
so it now goes through the secret-file recovery path; next to an existing
catalog it is still refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(docker): pass the dotenv DEER_FLOW_CREDENTIALS_KEY through make docker-start

scripts/docker.sh runs Compose from docker/ without --env-file, so the dev
gateway's DEER_FLOW_CREDENTIALS_KEY=${DEER_FLOW_CREDENTIALS_KEY:-} entry
interpolated to empty and overrode the key the checkout .env supplied via
env_file. Export it from the dotenv like AUTH_TRUSTED_PROXIES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:51:09 +08:00

287 lines
14 KiB
YAML

# DeerFlow Development Environment
#
# Requires Docker Compose >= 2.24. The env_file entries below use the long-form
# path/required syntax so a missing ../.env or ../frontend/.env does not abort
# Compose on Windows ("file not found" / "Le fichier spécifique est
# introuvable"). Clients older than 2.24 do not understand that field and
# reject this file with:
# validating docker-compose-dev.yaml: services.gateway.env_file.0 must be a string
# make docker-start checks the version and says so; direct `docker compose`
# callers get the message above instead.
#
# Supported entry: from the repository root, run `make docker-start`. That
# wrapper checks the Compose version, creates missing .env files, exports
# DEER_FLOW_ROOT, and invokes Compose from this directory with a relative
# filename.
#
# Running Compose yourself works too, but export DEER_FLOW_ROOT as the absolute
# checkout path first (see CONTRIBUTING.md for the Windows form). Unset, the
# DEER_FLOW_HOST_BASE_DIR and THREADS_HOST_PATH values below render as
# /backend/.deer-flow, so the provisioner bind-mounts the wrong host paths:
# DEER_FLOW_ROOT="$PWD" docker compose -f docker/docker-compose-dev.yaml up --build
# The -f path resolves against your shell's directory, not against this file:
# from the repository root use docker/docker-compose-dev.yaml; from inside
# docker/ use the bare docker-compose-dev.yaml, as make docker-start does.
#
# Services:
# - nginx: Reverse proxy (port 2026)
# - frontend: Frontend Next.js dev server (port 3000)
# - gateway: Backend Gateway API + agent runtime (port 8001)
# - redis: Redis Streams backend for cross-worker SSE stream bridge
# - provisioner (optional): Sandbox provisioner (creates Pods in host Kubernetes)
#
# Prerequisites:
# - Kubernetes cluster + kubeconfig are only required when using provisioner mode.
#
# Access: http://localhost:2026
services:
# ── Redis Stream Bridge ────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: deer-flow-redis
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
networks:
- deer-flow-dev
restart: unless-stopped
# ── Sandbox Provisioner ────────────────────────────────────────────────
# Manages per-sandbox Pod + Service lifecycle in the host Kubernetes
# cluster via the K8s API.
# Backend accesses sandboxes directly via host.docker.internal:{NodePort}.
provisioner:
build:
context: ./provisioner
dockerfile: Dockerfile
args:
APT_MIRROR: ${APT_MIRROR:-}
container_name: deer-flow-provisioner
volumes:
- ~/.kube/config:/root/.kube/config:ro
environment:
- K8S_NAMESPACE=deer-flow
- SANDBOX_IMAGE=enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest
# Optional lark-cli init image (Pattern A). Empty ⇒ legacy runtime mount.
# Set to a published tag (e.g. deer-flow/lark-cli-init:v1.0.65) to provision
# the sandbox lark-cli runtime via an init container + emptyDir.
- LARK_CLI_INIT_IMAGE=${LARK_CLI_INIT_IMAGE:-}
# Optional lark-cli broker image (Pattern B, issue #4338). Empty ⇒ broker
# off. Supersedes LARK_CLI_INIT_IMAGE when both are set.
- LARK_CLI_BROKER_IMAGE=${LARK_CLI_BROKER_IMAGE:-}
# Host paths for K8s HostPath volumes (must be absolute paths accessible by K8s node)
# On Docker Desktop/OrbStack, use your actual host paths like /Users/username/...
# Set these in your shell before running docker-compose:
# export DEER_FLOW_ROOT=/absolute/path/to/deer-flow
# Deliberately left without a ${DEER_FLOW_ROOT:-...} fallback. The obvious
# candidate, $PWD, is exported by POSIX shells but not by PowerShell or
# cmd, so the default would resolve to an empty string on Windows — the
# platform this variable exists to get right. A wrong-but-plausible host
# path mounts an empty directory instead of failing, so we require the
# caller to be explicit; make docker-start sets it for you.
- THREADS_HOST_PATH=${DEER_FLOW_ROOT}/backend/.deer-flow/threads
# Per-user data base directory for user-scoped skill mounts
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
# Production: use PVC instead of hostPath to avoid data loss on node failure.
# When set, hostPath vars above are ignored for the corresponding volume.
# USERDATA_PVC_NAME uses subPath (deer-flow/users/{user_id}/threads/{thread_id}/user-data) automatically.
# - SKILLS_PVC_NAME=deer-flow-skills-pvc
# - USERDATA_PVC_NAME=deer-flow-userdata-pvc
- KUBECONFIG_PATH=/root/.kube/config
- NODE_HOST=host.docker.internal
# Override K8S API server URL since kubeconfig uses 127.0.0.1
# which is unreachable from inside the container
- K8S_API_SERVER=https://host.docker.internal:26443
# Optional: set PROVISIONER_API_KEY in .env to enable provisioner auth.
# The same value must be set on the gateway side via config.yaml sandbox.provisioner_api_key.
- PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-}
env_file:
- path: ../.env
required: false
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- deer-flow-dev
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 10s
timeout: 5s
retries: 6
start_period: 15s
# ── Reverse Proxy ──────────────────────────────────────────────────────
# Routes API traffic to gateway and (optionally) provisioner.
nginx:
image: nginx:alpine
container_name: deer-flow-nginx
# Loopback-only by default; see the note in docker-compose.yaml. Override
# with BIND_HOST when you deliberately need the dev stack reachable from
# another machine.
ports:
- "${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro
command:
- sh
- -c
- |
set -e
cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
test -e /proc/net/if_inet6 || sed -i '/^[[:space:]]*listen[[:space:]]\+\[::\]:2026[[:space:]]/d' /etc/nginx/nginx.conf
exec nginx -g 'daemon off;'
depends_on:
- frontend
- gateway
networks:
- deer-flow-dev
restart: unless-stopped
# Frontend - Next.js Development Server
frontend:
build:
context: ../
dockerfile: frontend/Dockerfile
target: dev
args:
PNPM_STORE_PATH: ${PNPM_STORE_PATH:-/root/.local/share/pnpm/store}
NPM_REGISTRY: ${NPM_REGISTRY:-}
container_name: deer-flow-frontend
command: sh -c "cd frontend && pnpm run dev > /app/logs/frontend.log 2>&1"
volumes:
- ../frontend/src:/app/frontend/src
- ../frontend/public:/app/frontend/public
- ../frontend/next.config.js:/app/frontend/next.config.js:ro
- ../logs:/app/logs
# Mount pnpm store for caching
- ${PNPM_STORE_PATH:-~/.local/share/pnpm/store}:/root/.local/share/pnpm/store
working_dir: /app
environment:
- NODE_ENV=development
- WATCHPACK_POLLING=true
- CI=true
- DEER_FLOW_INTERNAL_GATEWAY_BASE_URL=http://gateway:8001
- DEER_FLOW_DEV_ALLOWED_ORIGINS=${DEER_FLOW_DEV_ALLOWED_ORIGINS:-127.0.0.1,::1}
env_file:
- path: ../frontend/.env
required: false
networks:
- deer-flow-dev
restart: unless-stopped
# Backend - Gateway API
gateway:
build:
context: ../
dockerfile: backend/Dockerfile
target: dev
# cache_from disabled - requires manual setup: mkdir -p /tmp/docker-cache-gateway
args:
APT_MIRROR: ${APT_MIRROR:-}
UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.11.1}
UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple}
NPM_REGISTRY: ${NPM_REGISTRY:-}
LARK_CLI_NPM_VERSION: ${LARK_CLI_NPM_VERSION:-1.0.65}
container_name: deer-flow-gateway
# Startup logic lives in docker/dev-entrypoint.sh — UV_EXTRAS validation,
# `uv sync --all-packages`, .venv self-heal, and uvicorn handoff. Keeps
# this file readable and lets the script be linted (shellcheck-clean).
# See PR #2767 / Issue #2754.
command: ["sh", "/usr/local/bin/dev-entrypoint.sh"]
# Docker's 10s default SIGKILLed the 30s memory queue flush
# (memory.shutdown_flush_timeout_seconds) on every `make docker-restart`;
# dev-entrypoint.sh bounds uvicorn's graceful shutdown to 10s inside this.
# Must cover that bound plus the lifespan's worst case (~61s, see the
# gateway comment in docker-compose.yaml) with slack.
stop_grace_period: 90s
volumes:
# Mount the dev entrypoint as a read-only file so edits to the script
# take effect on `make docker-restart` without requiring an image rebuild.
- ./dev-entrypoint.sh:/usr/local/bin/dev-entrypoint.sh:ro
- ../backend/:/app/backend/
# Preserve the .venv built during Docker image build — mounting the full backend/
# directory above would otherwise shadow it with the (empty) host directory.
- gateway-venv:/app/backend/.venv
# Mount the project directory instead of its mutable config files individually.
# Host editors commonly replace files on save; a directory bind keeps those
# replacements visible inside Docker Desktop/WSL containers.
- ../:/app/project
- ../skills:/app/skills
- ../logs:/app/logs
# Use a Docker-managed uv cache volume instead of a host bind mount.
# On macOS/Docker Desktop, uv may fail to create symlinks inside shared
# host directories, which causes startup-time `uv sync` to crash.
- gateway-uv-cache:/root/.cache/uv
# DooD: the host Docker socket is NOT mounted by default. It is added only
# for aio (pure-DooD) sandbox mode via the opt-in docker-compose.dood.yaml
# overlay (appended by scripts/docker.sh). See backend/docs/CONFIGURATION.md
# CLI auth dirs (Claude Code / Codex) are NOT mounted by default: they
# expose the entire ~/.claude and ~/.codex (history, projects, global
# config, credentials) into the container. Mount them only when you use
# the Claude/Codex CLI login as a model provider or ACP agent, via the
# opt-in docker-compose.cli-auth.yaml overlay. Prefer an env token
# (CLAUDE_CODE_OAUTH_TOKEN, see .env.example / backend/docs/CONFIGURATION.md).
working_dir: /app
environment:
- CI=true
- DEER_FLOW_PROJECT_ROOT=/app
- DEER_FLOW_HOME=/app/backend/.deer-flow
- DEER_FLOW_CONFIG_PATH=/app/project/config.yaml
- DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/project/extensions_config.json
- DEER_FLOW_STREAM_BRIDGE_REDIS_URL=${DEER_FLOW_STREAM_BRIDGE_REDIS_URL:-redis://redis:6379/0}
- DEER_FLOW_CHANNELS_LANGGRAPH_URL=${DEER_FLOW_CHANNELS_LANGGRAPH_URL:-http://gateway:8001/api}
- DEER_FLOW_CHANNELS_GATEWAY_URL=${DEER_FLOW_CHANNELS_GATEWAY_URL:-http://gateway:8001}
- DEER_FLOW_INTERNAL_AUTH_TOKEN=${DEER_FLOW_INTERNAL_AUTH_TOKEN:-}
# At-rest key for stored credentials; empty falls back to .credentials_key in DEER_FLOW_HOME.
- DEER_FLOW_CREDENTIALS_KEY=${DEER_FLOW_CREDENTIALS_KEY:-}
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
- DEER_FLOW_SANDBOX_HOST=host.docker.internal
# The login lockout counts failures per client IP, but every browser
# request reaches the Gateway from nginx. Trust nginx's X-Real-IP (it
# overwrites the header on every Gateway route) so one client's wrong
# passwords cannot lock everyone out. Hostname, re-resolved every 10s.
# `:-` on purpose: an empty value also means nginx, because every browser
# request arrives through it, so direct mode would only bring back the
# shared lockout. Set another list in .env to change whom to trust.
- AUTH_TRUSTED_PROXIES=${AUTH_TRUSTED_PROXIES:-nginx}
# Pass PROVISIONER_API_KEY into the gateway container so config.yaml can reference it
# as sandbox.provisioner_api_key: $PROVISIONER_API_KEY
- PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-}
# Proxy values (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) are inherited from ../.env via env_file.
# Only NO_PROXY is declared here so internal service hostnames are always exempt from the proxy.
- NO_PROXY=${NO_PROXY:-}${NO_PROXY:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,openviking,host.docker.internal
- no_proxy=${no_proxy:-}${no_proxy:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,openviking,host.docker.internal
env_file:
- path: ../.env
required: false
extra_hosts:
# For Linux: map host.docker.internal to host gateway
- "host.docker.internal:host-gateway"
depends_on:
redis:
condition: service_healthy
networks:
- deer-flow-dev
restart: unless-stopped
volumes:
# Persist .venv across container restarts so dependencies installed during
# image build are not shadowed by the host backend/ directory mount.
gateway-venv:
gateway-uv-cache:
redis-data:
networks:
deer-flow-dev:
driver: bridge
ipam:
config:
- subnet: 192.168.200.0/24