Skip to content
Guides · Recipes

How to Self-Host Langfuse in a Cloud Sandbox

Updated October 27, 2026 .md

Short answer: Clone the Langfuse repo into a sandbox, write a .env with fresh secrets, NEXTAUTH_URL set to your hosted URL and headless-init API keys, publish port 3000 on 127.0.0.1 and run docker compose up -d. Host it with sbx host --localhost. The Python SDK sends traces through the private URL with the __sbx_auth cookie.

What we ran#

Everything below ran on production on October 27, 2026, with the Langfuse repo's own docker-compose.yml (commit 954891a). That file now pulls Langfuse v4 (langfuse:4, server 4.70.1) plus Postgres 17, ClickHouse 25.12, Redis 7 and MinIO for S3-compatible storage. The laptop client was the langfuse Python SDK 5.0.4. No LLM was called: the trace is hand-made spans. Timings are single runs.

Step Result
docker compose up -d to /api/public/health OK, on default 60.0 s, including about 4 GB of image pulls
Memory with all six containers up, on default (8 GB) 2.8 GB used; langfuse-web 1.19 GiB, worker 656 MiB, ClickHouse 256 MiB
Hosted URL without its token 401, also on /api/public/health
SDK auth_check() and one trace with 3 observations, with the cookie True; exported in 0.33 s, readable through the API after 1.3 s
Same SDK without the cookie Failed to export spans batch code: 401, reason: Unauthorized
200 traces (400 observations) in one burst Exported in 0.37 s, all readable after 1.2 s
sbx stop --wait, then sbx resume 2.15 s and 1.34 s, memoryRestored: true, same container PIDs
Resume that boots from disk (--type large) 502 for 12 s while Langfuse started, then 200; all traces still there

Which size#

Langfuse's Docker Compose guide asks for a VM with at least 4 cores and 16 GiB of memory and about 100 GiB of storage for production. In Sandboxer terms that is large (8 vCPU, 16 GB, 125 GB disk, $0.072/hour while running). For trying it out, default (4 vCPU, 8 GB, 50 GB) was enough in our test: 2.8 GB used with the stack idle. After a 200-trace burst (run on large), use was still 2.8 GB.

Moving to a smaller size isn't supported yet, but growing on resume is. So start on default and run sbx resume $id --type large when your trace volume needs it. We did exactly that in the last section. During the free trial only small and default are available.

1. Create the sandbox and get the compose file#

sbx new --name langfuse --type default
id=sb_5dp510prza    # the id that sbx new printed; note its "subdomain" too

sbx exec $id --timeout 120 "git clone -q --depth 1 https://github.com/langfuse/langfuse.git"

Our subdomain was alert-prompt-ocelot, so the hosted URL for port 3000 is https://alert-prompt-ocelot-3000.box.sandboxer.com (Hosting). Langfuse needs that URL before it starts.

2. Write a .env with fresh secrets and a first project#

Every secret in docker-compose.yml is marked # CHANGEME and read as ${VAR:-default}, so a .env file next to it overrides them without editing the compose file. The LANGFUSE_INIT_* variables use headless initialization to create an organization, a project, its API keys and an owner account on first start, so there is no sign-up step. This script generates every value inside the sandbox, so no secret crosses your terminal:

# env.sh: run inside the sandbox
cd ~/langfuse
URL=https://alert-prompt-ocelot-3000.box.sandboxer.com
r() { openssl rand -hex "${1:-24}"; }
PG=$(r); MINIO=$(r); umask 077
cat > .env <<ENV
NEXTAUTH_URL=$URL
NEXTAUTH_SECRET=$(openssl rand -base64 32)
SALT=$(openssl rand -base64 32)
ENCRYPTION_KEY=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$PG
DATABASE_URL=postgresql://postgres:$PG@postgres:5432/postgres
CLICKHOUSE_PASSWORD=$(r)
REDIS_AUTH=$(r)
MINIO_ROOT_PASSWORD=$MINIO
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=$MINIO
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=$MINIO
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=$MINIO
TELEMETRY_ENABLED=false
LANGFUSE_INIT_ORG_ID=my-org
LANGFUSE_INIT_ORG_NAME=My Org
LANGFUSE_INIT_PROJECT_ID=agent-traces
LANGFUSE_INIT_PROJECT_NAME=Agent traces
LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-$(r 16)
LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-$(r 16)
LANGFUSE_INIT_USER_EMAIL=admin@example.com
LANGFUSE_INIT_USER_NAME=Admin
LANGFUSE_INIT_USER_PASSWORD=$(r 12)
ENV
grep -o '^[A-Z_]*' .env | tr '\n' ' '; echo

Two details the compose file doesn't make obvious:

  • DATABASE_URL carries the Postgres password, so change both together.
  • The MinIO password is also the S3 secret key for three Langfuse buckets (EVENT_UPLOAD, MEDIA_UPLOAD, BATCH_EXPORT). Change all four together or the worker can't write events.

Leave values unquoted: Langfuse's headless-init docs say not to wrap them in double quotes in Docker Compose.

3. Keep every port on loopback#

The compose file binds the databases to 127.0.0.1 already, but publishes the web UI as 3000:3000 and MinIO as 9090:9000 on all interfaces. The sandbox has a public IPv6 address, so override those two. !override replaces the list instead of appending to it:

# docker-compose.override.yml
services:
  langfuse-web:
    ports: !override
      - "127.0.0.1:3000:3000"
  minio:
    ports: !override
      - "127.0.0.1:9090:9000"
      - "127.0.0.1:9091:9001"
sbx scp env.sh $id:/home/user/env.sh
sbx scp docker-compose.override.yml $id:/home/user/langfuse/docker-compose.override.yml
sbx exec $id "bash ~/env.sh && rm ~/env.sh"
NEXTAUTH_URL NEXTAUTH_SECRET SALT ENCRYPTION_KEY POSTGRES_PASSWORD DATABASE_URL CLICKHOUSE_PASSWORD REDIS_AUTH MINIO_ROOT_PASSWORD LANGFUSE_S… TELEMETRY_ENABLED LANGFUSE_INIT_ORG_ID … LANGFUSE_INIT_USER_PASSWORD

docker compose config confirmed every published port was on 127.0.0.1. MinIO stays unhosted. That only matters for multimodal media uploads from the browser, which this guide doesn't cover.

4. Start the stack#

sbx exec stops a command after 30 seconds by default, and the first up pulls about 4 GB of images. Start it with --detach, then wait for the health endpoint:

sbx exec $id --detach "cd ~/langfuse && docker compose up -d > /tmp/up.log 2>&1"
sbx exec $id --timeout 300 "until curl -sf localhost:3000/api/public/health; do sleep 1; done; echo; \
  cd ~/langfuse && docker compose ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}'"
{"status":"OK","version":"4.70.1"}
SERVICE           IMAGE                                            STATUS
clickhouse        docker.io/clickhouse/clickhouse-server:25.12     Up 18 seconds (healthy)
langfuse-web      docker.langfuse.com/langfuse/langfuse:4          Up 15 seconds
langfuse-worker   docker.langfuse.com/langfuse/langfuse-worker:4   Up 15 seconds
minio             cgr.dev/chainguard/minio                         Up 19 seconds (healthy)
postgres          docker.io/postgres:17                            Up 19 seconds (healthy)
redis             docker.io/redis:7                                Up 19 seconds (healthy)

60 seconds from up to a healthy API, most of it image pulls. Docker and Compose are preinstalled; see Docker Compose in a sandbox for how Compose behaves across stops in general.

5. Host it privately#

sbx host $id 3000 --localhost --title Langfuse
https://alert-prompt-ocelot-3000.box.sandboxer.com?_token=<token>

--localhost relays the hosted URL to 127.0.0.1:3000 inside the VM. The route is private: without the token every path answers 401 from the proxy, including Langfuse's health check. Opening the full URL once answers 302 and sets the __sbx_auth cookie:

no token  /                      401
no token  /api/public/health     401
?_token=  /                      302  set-cookie: __sbx_auth=…; Max-Age=2592000; HttpOnly; Secure; SameSite=Lax
cookie    /api/public/health     {"status":"OK","version":"4.70.1"}

So Langfuse sits behind two locks: the proxy token, then Langfuse's own login and API keys.

6. Sign in to the UI#

Open the full private URL, then sign in as admin@example.com with the password from .env. Read it inside the sandbox instead of printing it into shared logs: sbx exec $id "grep ^LANGFUSE_INIT_USER_PASSWORD= langfuse/.env". We signed in with a headless Chromium. The session showed organization My Org with project Agent traces, created by the init variables.

Why NEXTAUTH_URL matters. We restarted langfuse-web with the default NEXTAUTH_URL=http://localhost:3000 to see what breaks behind the proxy:

NEXTAUTH_URL = hosted URL NEXTAUTH_URL = default http://localhost:3000
/api/auth/providers callback URL https://alert-prompt-ocelot-3000.box.sandboxer.com/api/auth/callback/credentials http://localhost:3000/api/auth/callback/credentials
Redirect after the credentials POST https://alert-prompt-ocelot-3000.box.sandboxer.com/ http://localhost:3000/
Session cookie __Secure-next-auth.session-token, Secure next-auth.session-token, not Secure

The UI's own sign-in form still landed on the hosted URL in our browser test. But redirects and the links Langfuse builds for users point at your laptop's localhost, and the session cookie loses its Secure flag. Set NEXTAUTH_URL to the exact hosted URL, with https:// and no trailing path. Langfuse's configuration reference describes it as the public URL of the web container. To change it later, edit .env and run docker compose up -d langfuse-web.

7. Send a trace from your laptop#

The Langfuse SDK talks to the server over HTTP: OTLP for spans, REST for everything else. It can't carry ?_token= and it doesn't keep cookies. It does take additional_headers, which it adds to both OTLP exports and API calls. Fetch the cookie once and pass it in:

# LANGFUSE_URL="https://…-3000.box.sandboxer.com?_token=…" \
# LANGFUSE_PUBLIC_KEY=pk-lf-… LANGFUSE_SECRET_KEY=sk-lf-… python send_trace.py
import os, time
from urllib.parse import urlsplit
import requests
from langfuse import Langfuse

full = os.environ["LANGFUSE_URL"]
r = requests.get(full, allow_redirects=False)  # trade ?_token= for the __sbx_auth cookie
cookie = r.headers["set-cookie"].split(";")[0]
u = urlsplit(full)
base = f"{u.scheme}://{u.netloc}"

langfuse = Langfuse(
    base_url=base,
    additional_headers={"Cookie": cookie},  # sent on OTLP exports and API calls
)
print("auth_check:", langfuse.auth_check())

t0 = time.perf_counter()
with langfuse.start_as_current_observation(as_type="agent", name="support-agent",
                                           input={"question": "Where is my order 1042?"}) as agent:
    with langfuse.start_as_current_observation(as_type="tool", name="lookup_order",
                                               input={"order_id": 1042}) as tool:
        time.sleep(0.12)
        tool.update(output={"status": "shipped", "carrier": "UPS"})
    with langfuse.start_as_current_observation(as_type="generation", name="draft-reply",
                                               model="hand-written", input="order 1042: shipped, UPS") as gen:
        gen.update(output="Your order shipped with UPS.",
                   usage_details={"input": 12, "output": 7})
    agent.update(output="Your order shipped with UPS.")
    trace_id = langfuse.get_current_trace_id()
langfuse.flush()
print(f"trace {trace_id} sent in {time.perf_counter() - t0:.2f} s")
print("UI:", langfuse.get_trace_url(trace_id=trace_id))

for _ in range(60):  # ingestion is async: the worker writes to ClickHouse
    obs = langfuse.api.observations.get_many(trace_id=trace_id).data
    if len(obs) == 3:
        break
    time.sleep(0.5)
print(f"readable after {time.perf_counter() - t0:.1f} s:",
      sorted((o.type, o.name) for o in obs))
pip install langfuse==5.0.4 requests
python send_trace.py
auth_check: True
trace 2391bbf196be810835f39a7c8260c18c sent in 0.33 s
UI: https://alert-prompt-ocelot-3000.box.sandboxer.com/project/agent-traces/traces/2391bbf196be810835f39a7c8260c18c
readable after 1.3 s: [('AGENT', 'support-agent'), ('GENERATION', 'draft-reply'), ('TOOL', 'lookup_order')]

The generation span takes a model name and token counts by hand, so you can see how a real LLM call would render without calling one. Without the Cookie header, the SDK logs Failed to export spans batch code: 401, reason: Unauthorized and drops the batch. The __sbx_auth cookie is the route's access token, so keep it, the token and sk-lf-… out of logs.

A v4 gotcha. Our first read-back used GET /api/public/traces/{id}. On this v4 server it answered: This endpoint is not available on deployments running in Langfuse v4 events_only mode … send traces directly to POST /api/public/otel/v1/traces. Read observations with langfuse.api.observations.get_many(trace_id=…), as above. Older examples that use the v3 traces endpoint fail against the current compose file.

Under the hood the SDK posts spans to /api/public/otel/v1/traces with Basic auth from the two keys. Another OTLP/HTTP exporter can use the same endpoint (Langfuse OpenTelemetry docs) if you add the same Cookie header; we tested only the Python SDK.

8. See it in the UI#

Open the UI: link in the browser where you signed in. The trace page showed the support-agent agent span with lookup_order (0.12 s) and draft-reply (19 tokens) nested under it, with the question as input and the reply as output.

We also sent 200 traces in one loop (a root span and a child each). The SDK exported all 400 observations in 0.37 s, and all 200 root spans were readable 1.2 s after the loop started.

9. What survives stop and resume#

sbx stop $id --wait
sbx resume $id

Stop took 2.15 s and resume 1.34 s, with memoryRestored: true. All six containers kept their PIDs (for example langfuse-web pid 2278 before and after). The old cookie still worked and both traces were readable.

Then we forced a resume that boots from disk, and grew the sandbox to Langfuse's recommended size at the same time:

sbx stop $id --wait
sbx resume $id --type large
ready large 16 memoryRestored= False resumeReason= resized
10:56:36 +0.2s health=502
10:56:48 +12.2s health=200

/proc/uptime read 20 seconds, so this was a fresh boot. Docker starts at boot, and every service in Langfuse's compose file has restart: always, so all six containers came back on their own. The hosted URL returned 502 for 12 seconds while they started, then 200, with no new sbx host call. Data lives in named Docker volumes on the sandbox disk (100.9 MB after our tests), so the earlier traces were still there and a new trace was readable 1.9 s after sending. On large, memory use was 2.8 GB of 16 GB.

When you're done:

sbx delete $id --yes

FAQ#

Why does the SDK get 401 from my self-hosted Langfuse?#

Two different 401s are possible. An HTML 401 page comes from the Sandboxer proxy: the request had no _token and no __sbx_auth cookie, so pass the cookie in additional_headers. A JSON 401 comes from Langfuse itself: check LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY against the project's keys.

Do I need to create a project and API keys in the UI?#

No. The LANGFUSE_INIT_* variables create the organization, project, keys and an owner account on first start, if they don't exist yet. You can still create more projects and keys in the UI after signing in.

What should NEXTAUTH_URL be behind the hosted URL?#

The exact URL you open in the browser: https://<subdomain>-3000.box.sandboxer.com. With the default http://localhost:3000, sign-in redirects pointed at localhost and the session cookie lost its Secure flag in our test.

Can an agent running in another sandbox send traces here?#

Yes. The same script works from any machine that has the full private URL. Making the port public with sbx host $id 3000 --public removes the cookie step, but then Langfuse's own login and API keys are the only lock.

Does the stack restart after an auto-stop?#

Auto-stop and idle pause keep memory, so containers keep running when the sandbox resumes. If a resume boots from disk instead, Docker restarts all six containers because of restart: always. Expect a short 502 while Langfuse starts.

More guides

Top AI sandbox platforms for code execution in 2026, ranked

For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.

Read the guide
Best sandboxes for running coding agents (Claude Code, Codex, Gemini CLI) in 2026, ranked

For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.

Read the guide
Best Daytona sandbox alternatives in 2026, ranked

The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.

Read the guide
Best E2B alternatives in 2026, ranked

If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.

Read the guide