# How to Self-Host Langfuse in a Cloud Sandbox

> 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.

Source: https://sandboxer.com/guides/run-langfuse-in-a-sandbox. Updated 2026-10-27.

**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](https://langfuse.com/self-hosting/deployment/docker-compose) 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

```bash
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](/docs/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](https://langfuse.com/self-hosting/administration/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:

```bash
# 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:

```yaml
# 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"
```

```bash
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"
```

```text
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:

```bash
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}}'"
```

```text
{"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](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service) for how Compose behaves across stops in general.

## 5. Host it privately

```bash
sbx host $id 3000 --localhost --title Langfuse
```

```text
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:

```text
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](https://langfuse.com/self-hosting/configuration) 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:

```python
# 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))
```

```bash
pip install langfuse==5.0.4 requests
python send_trace.py
```

```text
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](https://langfuse.com/integrations/native/opentelemetry)) 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

```bash
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:

```bash
sbx stop $id --wait
sbx resume $id --type large
```

```text
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:

```bash
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.

## Related

- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Run an MLflow tracking server in a sandbox](/guides/run-mlflow-tracking-server-in-a-sandbox)
- [Run Grafana and Prometheus in a sandbox](/guides/run-grafana-and-prometheus-in-a-sandbox)
- [Hosting](/docs/hosting)
