Skip to content

Commit 258de6e

Browse files
deploy: run a real production server, not langgraph dev
The Railway service has been running LangGraph's development server in production. `langgraph dev` holds threads and runs in memory via langgraph-runtime-inmem, so every deploy and every restart drops whatever was in flight — the AlphaFRS poller then watches the thread disappear instead of completing. It also stamps api_variant=local_dev on every log line and runs a filesystem watcher (207 "changes detected" in a 4-minute window, recorded in alphaFRS docs/notes/2026-08-02-competitor-intel-iteration.md, open item 2). The fix is not LangSmith Deployment. Of the four options on docs.langchain.com/langsmith/deployment, only Standalone Server can run on Railway and it needs an Enterprise licence; Cloud needs Plus at $39/seat and is not Railway at all. None of it is required, because the gateway already implements the LangGraph Platform runs API in-process (app/gateway/routers/thread_runs.py) — which is exactly what upstream's own production compose relies on: docker/docker-compose.yaml runs nginx, frontend and gateway, no LangGraph server anywhere, with DEER_FLOW_CHANNELS_LANGGRAPH_URL pointed back at the gateway. The langgraph dev process was never load-bearing here. Every endpoint AlphaFRS calls — POST /api/threads, POST /api/threads/{id}/runs, GET /api/threads/{id}/runs/{run_id}, GET /api/threads/{id}/state — is a gateway route. Only the IM channels service talks to the configured LangGraph URL, and AlphaFRS does not use channels. So: drop it, and get durability from Postgres instead. - config.yaml: database.backend sqlite -> postgres reading $DATABASE_URL. SQLite sat on a container filesystem Railway discards on every deploy, which made "persistent" checkpoints a fiction. - config.yaml: run_events.backend memory -> db. With memory, a run's history lived in one worker's process and a status lookup landing on the other worker 404'd — the condition patch 0005 worked around at the API level. - entrypoint: gateway only, via exec so Railway's signals reach uvicorn rather than a supervising shell. Fails fast with a clear message if DATABASE_URL is unset, instead of starting against an empty postgres_url. DEER_FLOW_CONFIG_PATH is now pinned rather than relying on the legacy monorepo fallback in app_config.py to find /app/config.yaml. - entrypoint: --workers 2. Shared Postgres state is the precondition that makes more than one worker safe; the comment says so, next to the flag. - Dockerfile: uv sync --extra postgres, without which the checkpointer factory raises ImportError at startup. Verified before commit: bash -n on the entrypoint; config.yaml loads through DeerFlow's own AppConfig and reports backend=postgres, run_events=db, checkpointer=None (so the unified `database` section drives both); and `uv sync --extra postgres --dry-run` installs asyncpg, langgraph-checkpoint-postgres, psycopg, psycopg-binary and psycopg-pool from the existing lockfile with no re-resolution. Not yet verified, and only observable on Railway: that a run survives a service restart. That is the whole point of the change and is the first thing to test after deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent af421d2 commit 258de6e

3 files changed

Lines changed: 75 additions & 46 deletions

File tree

Dockerfile.railway

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# DeerFlow — Railway production Dockerfile
2-
# Runs gateway (port 8001) + langgraph (port 2024) behind a supervisor entrypoint.
3-
# Railway routes external traffic to $PORT (gateway).
2+
# Runs the gateway (port 8001) as the container's only process. The gateway
3+
# serves the LangGraph-compatible API itself; there is no separate LangGraph
4+
# server. See entrypoint.railway.sh for why.
5+
#
6+
# Requires DATABASE_URL at runtime (config.yaml sets database.backend=postgres).
47
#
58
# Build: docker build -f Dockerfile.railway -t deerflow-railway .
69
# Run: docker run -p 2026:8001 --env-file .env deerflow-railway
@@ -41,16 +44,20 @@ WORKDIR /app
4144
COPY backend ./backend
4245
RUN mkdir -p /app/skills/custom /app/skills/public
4346

44-
# Install Python deps
45-
RUN cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync
47+
# Install Python deps.
48+
# --extra postgres pulls deerflow-harness[postgres] (asyncpg,
49+
# langgraph-checkpoint-postgres, psycopg[binary], psycopg-pool). Required
50+
# because config.yaml sets database.backend=postgres; without it the gateway
51+
# raises ImportError from the checkpointer factory at startup.
52+
RUN cd backend && UV_INDEX_URL=${UV_INDEX_URL:-https://pypi.org/simple} uv sync --extra postgres
4653

4754
# Copy entrypoint + production config
4855
# config.yaml is committed past .gitignore (-f) and is the live Railway config.
4956
COPY entrypoint.railway.sh /app/entrypoint.sh
5057
COPY config.yaml /app/config.yaml
5158
RUN chmod +x /app/entrypoint.sh
5259

53-
# Railway injects $PORT — gateway listens on it, langgraph on 2024 internally
60+
# Railway injects $PORT — the gateway is the only process and listens on it
5461
ENV PORT=8001
5562
EXPOSE 8001
5663

config.yaml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -979,9 +979,17 @@ skill_evolution:
979979
# database:
980980
# backend: postgres
981981
# postgres_url: $DATABASE_URL
982+
# AlphaFRS (Railway): postgres, not sqlite. The container filesystem is
983+
# discarded on every deploy and restart, so a sqlite file loses runs in flight
984+
# — the AlphaFRS poller then sees the thread disappear instead of completing.
985+
# It is also what makes >1 uvicorn worker safe: workers share run state through
986+
# Postgres rather than each holding their own. DATABASE_URL is supplied
987+
# per-environment by the Railway Postgres service, so staging and production
988+
# share this one baked config.
982989
database:
983-
backend: sqlite
984-
sqlite_dir: .deer-flow/data
990+
backend: postgres
991+
postgres_url: $DATABASE_URL
992+
pool_size: 5
985993

986994
# ============================================================================
987995
# Run Events Configuration
@@ -996,8 +1004,12 @@ database:
9961004
# backend: memory
9971005
# max_trace_content: 10240 # Truncation threshold for trace content (db backend, bytes)
9981006
# track_token_usage: true # Accumulate token counts to RunRow
1007+
# AlphaFRS (Railway): db, not memory. With `memory` a run's history lives in
1008+
# one worker's process, so a status lookup landing on the other worker 404s —
1009+
# the problem patch 0005 worked around at the API level. The db backend shares
1010+
# it through the `database` section above.
9991011
run_events:
1000-
backend: memory
1012+
backend: db
10011013
max_trace_content: 10240
10021014
track_token_usage: true
10031015

entrypoint.railway.sh

Lines changed: 48 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,66 @@
11
#!/usr/bin/env bash
22
# DeerFlow — Railway entrypoint
3-
# Starts langgraph dev server (port 2024) and the gateway (port $PORT / 8001).
4-
# Both processes are supervised; if either exits, the container exits.
3+
# Starts the gateway (port $PORT / 8001) as the container's only process.
4+
#
5+
# There is deliberately NO LangGraph server here. The gateway implements the
6+
# LangGraph Platform runs API in-process (app/gateway/routers/thread_runs.py),
7+
# which is what upstream's own production compose does — docker-compose.yaml
8+
# runs nginx + frontend + gateway and no LangGraph server at all, with
9+
# DEER_FLOW_CHANNELS_LANGGRAPH_URL pointed back at the gateway.
10+
#
11+
# This used to run `langgraph dev` alongside the gateway. That is the
12+
# DEVELOPMENT server: it stamps api_variant=local_dev, watches the filesystem
13+
# for hot reload, and holds threads and runs in memory via
14+
# langgraph-runtime-inmem. Nothing AlphaFRS calls was ever served by it — every
15+
# endpoint in alphaFRS backend/deerflow.py hits the gateway's /api/threads
16+
# routes — so it was pure overhead and a misleading log signature.
17+
#
18+
# Durability now comes from Postgres (config.yaml: database.backend=postgres,
19+
# run_events.backend=db), not from a LangGraph server. If you find yourself
20+
# wanting to add one back, note that the supported production LangGraph servers
21+
# are LangSmith Cloud (Plus, $39/seat) and Standalone Server (Enterprise
22+
# licence) — neither is needed for this architecture.
523

624
set -euo pipefail
725

826
# ── Config ───────────────────────────────────────────────────────────────────
927
GATEWAY_PORT="${PORT:-8001}"
10-
LANGGRAPH_PORT=2024
1128
BACKEND_DIR="/app/backend"
1229

1330
# config.yaml is baked into the image at build time (see Dockerfile.railway).
31+
# DATABASE_URL is injected by Railway from the Postgres service and expanded by
32+
# config.yaml's native $ENV_VAR support. Fail fast rather than let the gateway
33+
# start against an empty postgres_url and surface as a confusing runtime error.
34+
if [ -z "${DATABASE_URL:-}" ]; then
35+
echo "[entrypoint] FATAL: DATABASE_URL is unset."
36+
echo "[entrypoint] config.yaml sets database.backend=postgres and reads \$DATABASE_URL."
37+
echo "[entrypoint] Attach the Railway Postgres service to this environment."
38+
exit 1
39+
fi
1440

15-
# ── Start langgraph ───────────────────────────────────────────────────────────
16-
echo "[entrypoint] Starting langgraph on port ${LANGGRAPH_PORT}"
1741
cd "${BACKEND_DIR}"
18-
.venv/bin/langgraph dev \
19-
--host 0.0.0.0 \
20-
--port "${LANGGRAPH_PORT}" \
21-
--no-browser \
22-
&
23-
LANGGRAPH_PID=$!
2442

25-
# ── Wait for langgraph to be ready ───────────────────────────────────────────
26-
echo "[entrypoint] Waiting for langgraph to be ready …"
27-
for i in $(seq 1 30); do
28-
if curl -sf "http://localhost:${LANGGRAPH_PORT}/ok" > /dev/null 2>&1; then
29-
echo "[entrypoint] langgraph is ready."
30-
break
31-
fi
32-
sleep 2
33-
done
43+
# Pin the config path. Without this it still resolves, but only by falling
44+
# through to the "legacy monorepo" candidate list in app_config.py, which finds
45+
# /app/config.yaml two levels up from cwd. Being explicit means a missing or
46+
# misplaced config fails at startup with a clear error instead of silently
47+
# resolving somewhere else after a layout change.
48+
export DEER_FLOW_CONFIG_PATH="/app/config.yaml"
49+
50+
# The IM channels service talks to the LangGraph-compatible API. That is us.
51+
export DEER_FLOW_CHANNELS_LANGGRAPH_URL="http://localhost:${GATEWAY_PORT}/api"
52+
export DEER_FLOW_CHANNELS_GATEWAY_URL="http://localhost:${GATEWAY_PORT}"
3453

3554
# ── Start gateway ─────────────────────────────────────────────────────────────
55+
# exec: the gateway becomes PID 1 so Railway's stop/restart signals reach uvicorn
56+
# directly instead of a shell that would have to forward them.
57+
#
58+
# --workers 2 is safe only because run state is shared through Postgres. Do not
59+
# raise workers (or railway.toml's numReplicas) while database.backend is
60+
# sqlite or memory — each worker would keep its own runs and status lookups
61+
# would 404 depending on which one answered.
3662
echo "[entrypoint] Starting gateway on port ${GATEWAY_PORT}"
37-
.venv/bin/python -m uvicorn app.gateway.app:app \
63+
exec .venv/bin/python -m uvicorn app.gateway.app:app \
3864
--host 0.0.0.0 \
3965
--port "${GATEWAY_PORT}" \
40-
&
41-
GATEWAY_PID=$!
42-
43-
# ── Supervisor loop ───────────────────────────────────────────────────────────
44-
echo "[entrypoint] Both services started (langgraph PID=${LANGGRAPH_PID}, gateway PID=${GATEWAY_PID})."
45-
46-
wait_any() {
47-
while kill -0 "${LANGGRAPH_PID}" 2>/dev/null && kill -0 "${GATEWAY_PID}" 2>/dev/null; do
48-
sleep 5
49-
done
50-
}
51-
52-
wait_any
53-
54-
echo "[entrypoint] A service exited — shutting down."
55-
kill "${LANGGRAPH_PID}" "${GATEWAY_PID}" 2>/dev/null || true
56-
exit 1
66+
--workers 2

0 commit comments

Comments
 (0)