This article describes a practical, locked-down workflow for installing a LangGraph development sandbox inside Docker on Ubuntu. It uses localhost only and with minimal echo graph.
By Kevin Wells
Docker 28.x
Compose v2.39.x
LangGraph 0.6.8
langgraph-cli 0.4.2
langgraph-checkpoint-sqlite 2.0.11
langchain-core pinned
Date: 2025-09-30
Pin these versions for reproducibility in your environment and check for newer patches periodically.
Summary
This guide explains how to build a beginner friendly LangGraph dev environment in a Docker container.
You will have:
- A minimal echo graph compiled and runnable.
- A Docker Compose service with strict defaults.
- The LangGraph Dev API reachable on
127.0.0.1:2024
, with Studio connected.
Who this is for
- Linux admins and power users who want a controlled, reproducible sandbox.
- Builders who do not want to risk mounting their home folder, NFS shares, or using Docker’s root socket.
- Anyone who wants to learn how to install and configure LangGraph-based agents without risking their system being comprimised.
Architecture
Your Browser -(local)-> LangGraph Studio (web)
|
v localhost:2024 (bound only to 127.0.0.1)
+----------------------------------------+
| LangGraph Dev API |
| inside Docker |
| |
| Loads graph from /app |
| Runtime scratch in /app/.langgraph_api|
+-------------------+--------------------+
|
v
Echo graph - test DB at /state/graph.db
Posture: non-root user in container (UID 1000), read-only root filesystem, only two writable pockets (/state
and /app/.langgraph_api
), port bound to 127.0.0.1
, and no mounts of home, NFS, or /var/run/docker.sock
.
Project layout
$HOME/langgraph-lab/
├── app.py
├── Dockerfile
├── docker-compose.yml
├── langgraph.json
├── requirements.txt
└── state/ # writable, owned by UID 1000
mkdir -p "$HOME/langgraph-lab/state"
sudo chown 1000:1000 "$HOME/langgraph-lab/state"
chmod 700 "$HOME/langgraph-lab/state"
Files & configuration
requirements.txt
langgraph==0.6.8
langgraph-cli[inmem]==0.4.2
langgraph-checkpoint-sqlite==2.0.11
langchain-core==<pin your tested version>
Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
LANG=C.UTF-8
# Non-root user
RUN useradd -m -u 1000 appuser
WORKDIR /app
# Minimal OS deps for build
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential sqlite3 libsqlite3-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Python deps
COPY requirements.txt /tmp/requirements.txt
RUN python -m pip install -U pip && pip install -r /tmp/requirements.txt
# Remove compilers from runtime
USER root
RUN apt-get purge -y build-essential && apt-get autoremove -y && rm -rf /var/lib/apt/lists/*
USER appuser
# App code
COPY app.py /app/app.py
# Writable state dir
USER root
RUN mkdir -p /state && chown -R appuser:appuser /state
USER appuser
# Idle by default - dev server started by compose command
CMD ["bash","-lc","sleep infinity"]
docker-compose.yml
services:
langgraph:
build: .
container_name: langgraph-lab
ports:
- "127.0.0.1:2024:2024" # localhost only
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,nodev
- /app/.langgraph_api:rw,noexec,nosuid,nodev
cap_drop: ["ALL"]
security_opt:
- no-new-privileges:true
- seccomp=runtime/default
- apparmor=docker-default
user: "1000:1000"
environment:
- PYTHONPATH=/app
- PYTHONDONTWRITEBYTECODE=1
- LANGSMITH_TRACING=false # tracing off by default
volumes:
- ./app.py:/app/app.py:ro
- ./langgraph.json:/app/langgraph.json:ro
- ./state:/state:rw
# Resource and log controls
pids_limit: 256
mem_limit: 512m
cpus: "1.0"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Healthcheck uses Python - no wget needed
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:2024/ok', timeout=2).read() else 1)"]
interval: 30s
timeout: 3s
retries: 5
restart: "no"
langgraph.json
{
"dependencies": ["."],
"graphs": {
"echo": "/app/app.py:app"
}
}
app.py
from typing import TypedDict, Annotated, List
from operator import add
from langgraph.graph import StateGraph, END
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
# State
class State(TypedDict):
messages: Annotated[List[BaseMessage], add]
# Node
def model_node(state: State):
last = next((m.content for m in reversed(state["messages"]) if isinstance(m, HumanMessage)), "")
return {"messages": [AIMessage(content=f"ECHO: {last}")]}
# Graph factory
def build_graph():
g = StateGraph(State)
g.add_node("model", model_node)
g.set_entry_point("model")
g.add_edge("model", END)
return g
# Exported graph for Dev API - no custom checkpointer
app = build_graph().compile()
if __name__ == "__main__":
# Local test runner with SQLite checkpointer
import sqlite3
from langgraph.checkpoint.sqlite import SqliteSaver
conn = sqlite3.connect("/state/graph.db", check_same_thread=False)
app_local = build_graph().compile(checkpointer=SqliteSaver(conn))
cfg = {"configurable": {"thread_id": "demo"}}
out = app_local.invoke({"messages": [HumanMessage(content="Hello, graph.")]}, cfg)
print(out["messages"][-1].content)
Build & boot
cd "$HOME/langgraph-lab"
docker compose up -d --build
# One-off script sanity test
docker compose exec -T langgraph bash -lc 'python /app/app.py'
# Expect: ECHO: Hello, graph.
Start the Dev API
Note: the server binds inside the container to 0.0.0.0, but Docker publishes it only on 127.0.0.1 due to the port mapping. Do not expose the server beyond localhost.
Interactive session
docker compose exec -T langgraph bash -lc \
'cd /app && langgraph dev --host 0.0.0.0 --port 2024 --no-browser'
Verification
curl -sS http://127.0.0.1:2024/ok
curl -sS http://127.0.0.1:2024/info
Use it
Studio UI
https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
Create a Thread, select assistant echo, send a message. The LangSmith banner is informational. Tracing is off by default here.
REST API
BASE=http://127.0.0.1:2024
THREAD_JSON=$(curl -sS -X POST "$BASE/threads" -H 'content-type: application/json' -d '{}')
THREAD_ID=$(echo "$THREAD_JSON" | sed -n 's/.*"thread_id":"\([^"]*\)".*/\1/p')
RUN_JSON=$(curl -sS -X POST "$BASE/threads/$THREAD_ID/runs" \
-H 'content-type: application/json' \
-d '{"assistant_id":"echo","input":{"messages":[{"role":"user","content":"Hello"}]}}')
RUN_ID=$(echo "$RUN_JSON" | sed -n 's/.*"run_id":"\([^"]*\)".*/\1/p')
curl -sS "$BASE/threads/$THREAD_ID/runs/$RUN_ID/join"
Troubleshooting
- ModuleNotFoundError: langgraph.checkpoint.sqlite – ensure
langgraph-checkpoint-sqlite
is installed and rebuild. - langgraph: command not found – ensure
langgraph-cli[inmem]
is installed and rebuild. - sqlite3.OperationalError: unable to open database file – fix ownership and permissions on
./state
, then recreate the container. - ValueError: custom checkpointer not allowed – export
app = build_graph().compile()
without a custom saver. Keep SQLite only in__main__
. - Address already in use after Ctrl-C –
docker compose down
thenup -d
.
Security rules
- Do not expose the Dev API beyond
127.0.0.1
. If you must, then put a reverse proxy with auth and TLS in front. - Keep
read_only: true
, and restrict writable paths to/state
and/app/.langgraph_api
. - Do not mount any additional host paths. Never mount
/var/run/docker.sock
. - Before adding action-based tools, add a human approval gate and default all AI-agent decisions to deny.
Maintenance
Start and stop
docker compose up -d
docker compose logs -f
docker compose down
Clean reset
docker compose down -v