If you call the Claude or OpenAI API from a side project, you probably have no idea what last week cost, which calls were slow, or whether yesterday's prompt tweak made the output worse. The fix is to self-host Langfuse. It's the open-source LLM observability platform with roughly 29k GitHub stars, and it runs entirely on your own hardware. This guide sets up the official Langfuse Docker Compose stack behind Traefik, connects it to a Node app that calls Claude, and covers the three dashboards you'll actually open each day.
Langfuse: an open-source tracing, prompt management and evaluation platform for LLM apps. It records every model call with its input, output, token usage, cost and latency.
- Provision a Docker host with at least 4 GB of RAM and Traefik already running on an external
proxynetwork. - Download the official Langfuse v3 docker-compose.yml and generate every secret into a .env file.
- Add a compose override that routes langfuse-web through Traefik and removes the published host ports.
- Start the stack, create your organisation and project, then turn off public sign-ups.
- Add the Langfuse OpenTelemetry SDK to your Node app and wrap each Claude or OpenAI call as a generation.
- Set up the cost, latency and prompt-version views and check them as part of your normal routine.
What does a self-hosted Langfuse stack actually run?
Langfuse v3 runs six containers: a web app, a background worker, Postgres, ClickHouse, Redis and MinIO. That's heavier than v2, which only needed Postgres, but this design is why the UI stays fast after you've stored a few hundred thousand traces.
- langfuse-web serves the UI and the ingestion API on port 3000. This is the only container Traefik needs to reach.
- langfuse-worker reads events off the Redis queue and writes them into ClickHouse.
- Postgres holds users, projects, API keys and prompts.
- ClickHouse stores traces, observations and scores. It uses the most resources.
- Redis runs the queue and the cache.
- MinIO is S3-compatible storage for raw event payloads and media.
How much hardware does self-hosted Langfuse need?
Plan for 4 GB of RAM and 2 vCPUs for a homelab-sized workload. ClickHouse takes most of that memory. On a 2 GB VPS it gets OOM-killed, and you'll spend an hour reading worker logs before you find out why.
A refurbished Beelink mini-PC with 16 GB of RAM runs this stack plus the rest of a homelab comfortably. If you want it public and off your home connection, a Hetzner CX32 (4 vCPU, 8 GB) costs less per month than a single bad prompt loop on a frontier model. If you're also running local models, the same box can sit alongside your Ollama and Open-WebUI stack.
Step 1–2: Langfuse Docker Compose and secrets
Use the official compose file instead of writing your own. Langfuse changes environment variables between minor releases, so your override should stay small and the upstream file should carry the rest.
mkdir -p ~/stacks/langfuse && cd ~/stacks/langfuse
curl -fsSLO https://raw.githubusercontent.com/langfuse/langfuse/main/docker-compose.yml
grep -n CHANGEME docker-compose.ymlThat grep lists every insecure default. One thing the docs don't spell out: some passwords appear in more than one place. The Postgres password is also inside DATABASE_URL, and the MinIO root password is reused as the S3 secret key three times. If you change one and miss another, the worker crash-loops with an auth error that doesn't tell you which credential is wrong. Generate each password once and reuse the variable:
PG=$(openssl rand -hex 16); CH=$(openssl rand -hex 16)
RD=$(openssl rand -hex 16); S3=$(openssl rand -hex 16)
cat > .env <<EOF
NEXTAUTH_URL=https://langfuse.example.com
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=${CH}
REDIS_AUTH=${RD}
MINIO_ROOT_PASSWORD=${S3}
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=${S3}
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=${S3}
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=${S3}
TELEMETRY_ENABLED=false
EOF
chmod 600 .env
docker compose config | grep -c CHANGEMEThe last command should print 0. If it doesn't, find the variable that isn't being substituted and add it to .env. ENCRYPTION_KEY has to be exactly 64 hex characters, which openssl rand -hex 32 gives you.
Step 3: Put Langfuse behind Traefik
Add a docker-compose.override.yml that attaches langfuse-web to your Traefik network and removes its host port. This assumes Traefik 3 with a letsencrypt cert resolver on an external proxy network, as set up in the Traefik 3 + Docker Compose guide.
services:
langfuse-web:
ports: !reset []
networks: [default, proxy]
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.langfuse.rule=Host(`langfuse.example.com`)
- traefik.http.routers.langfuse.entrypoints=websecure
- traefik.http.routers.langfuse.tls.certresolver=letsencrypt
- traefik.http.services.langfuse.loadbalancer.server.port=3000
minio:
ports: !reset []
networks:
proxy:
external: true!reset needs Docker Compose 2.24 or later. Older versions merge the port lists, so port 3000 stays published and bypasses Traefik. Run docker compose version first. Removing MinIO's port is fine for text-only tracing. Only expose it through its own router if you plan to log images or audio.
Step 4: Start it and lock it down
Bring the stack up, create your account, then turn sign-ups off.
docker compose up -d
docker compose logs -f langfuse-workerThe first boot runs ClickHouse migrations and takes a minute or two. Once the worker logs stop showing errors, open https://langfuse.example.com, sign up, and create an organisation and project. Copy the public and secret keys from Project Settings, then run:
echo 'AUTH_DISABLE_SIGNUP=true' >> .env && docker compose up -dIf the instance only needs to be reachable from your own machines, put it behind Tailscale and skip the public DNS record entirely.
Step 5: Wiring Langfuse into a Node app for Claude API monitoring
The v4 JS SDK is built on OpenTelemetry. You register a span processor once, then wrap each model call in an observation of type generation.
npm i @anthropic-ai/sdk @langfuse/tracing @langfuse/otel @opentelemetry/sdk-node# .env for your app
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=https://langfuse.example.com// instrumentation.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseSpanProcessor } from "@langfuse/otel";
export const sdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
sdk.start();// summarise.ts
import "./instrumentation";
import Anthropic from "@anthropic-ai/sdk";
import { startActiveObservation, startObservation } from "@langfuse/tracing";
const anthropic = new Anthropic();
export async function summarise(text: string, userId: string) {
return startActiveObservation("summarise", async (span) => {
span.updateTrace({ userId, tags: ["side-project"] });
const model = "claude-sonnet-5-5";
const messages = [{ role: "user" as const, content: `Summarise in three bullets: ${text}` }];
const gen = startObservation("claude.messages.create", { model, input: messages }, { asType: "generation" });
try {
const res = await anthropic.messages.create({ model, max_tokens: 512, messages });
const output = res.content.find((b) => b.type === "text")?.text ?? "";
gen.update({ output, usageDetails: { input: res.usage.input_tokens, output: res.usage.output_tokens } });
return output;
} catch (err) {
gen.update({ level: "ERROR", statusMessage: String(err) });
throw err;
} finally {
gen.end();
}
});
}Two things to watch. First, spans are sent in batches, so a short-lived script exits before anything gets sent. Call await sdk.shutdown() before the process ends. Long-running servers don't need this. Second, if you're using OpenAI, or Ollama through its OpenAI-compatible endpoint, skip the manual wrapping and use observeOpenAI() from @langfuse/openai. It records usage automatically.
Why does my Claude cost show $0 in Langfuse?
Langfuse calculates cost by matching the model name against its built-in price table. A model released after your Langfuse image was built doesn't match anything, so it shows $0. It took me 20 minutes to work out this wasn't an ingestion bug.
Go to Project Settings → Models and add a definition with a match pattern such as (?i)^claude-sonnet-5-5 and the input and output price per token from Anthropic's pricing page. The new price applies to future traces only, not old ones. Updating the Langfuse image regularly also keeps the built-in table current.
The three dashboards that matter for LLM observability, self-hosted
You'll use three views every day: cost by model and user, p95 latency per generation name, and traces filtered by prompt version. The rest of the UI is useful for occasional investigation.
- Cost: the home dashboard's model cost chart, split by
userIdor tag. This is where you catch a retry loop that sent the same 40k-token context fifty times overnight. Check it every morning. - Latency: filter Observations to type generation and sort by p95 latency per name. If you stream responses, set
completionStartTimeso you also get time to first token, which is the latency users actually notice. - Prompt versions: move your prompts into Langfuse Prompt Management (
langfuse.prompt.get("summariser")from@langfuse/client) and pass the prompt object when you create the generation. Every trace is then tagged with a prompt version, so after you edit a prompt you can compare cost, latency and scores between versions instead of judging it by feel.
Should you self-host Langfuse or use Langfuse Cloud?
Self-host if you already run Docker and Traefik and don't want prompts containing personal data leaving your network. Use Langfuse Cloud's free tier if you'd rather not maintain ClickHouse. For a homelab with a reverse proxy already in place, self-hosting takes about 30 minutes and costs nothing extra.
My recommendation: deploy it this weekend and instrument your most expensive call first. Within a week the cost chart will probably show something you didn't know about.
FAQs
Is Langfuse free to self-host?
Yes. The core platform is MIT-licensed, including tracing, prompt management, evals and dashboards, and there are no usage limits when you self-host. A few enterprise features such as SCIM and audit logs need a commercial licence, and a homelab won't need them.
Can I run Langfuse on a Raspberry Pi or ARM server?
The Langfuse, ClickHouse, Postgres, Redis and MinIO images are all published for arm64, so it runs on ARM VPSs and an 8 GB Pi 5. On a Pi, run ClickHouse from an SSD rather than an SD card, or ingestion will fall behind.
Does Langfuse work with Ollama and local models?
Yes. Point the OpenAI SDK at Ollama's /v1 endpoint and wrap it with observeOpenAI(). Latency and token counts are recorded as normal. Cost will show $0 unless you add a custom model price, which for local inference is accurate.
How do I back up a self-hosted Langfuse instance?
Back up Postgres with pg_dump, plus the ClickHouse and MinIO volumes. Postgres holds your prompts and keys, so it's the part you can't lose. Send the backups off-site to something like Backblaze B2, which is cheap S3-compatible storage that works with restic.
How do I upgrade Langfuse v3 without breaking it?
Pin the major version tag (langfuse/langfuse:3), then run docker compose pull && docker compose up -d. Migrations run automatically when the containers start. Read the release notes before any major version jump, because the v2 to v3 upgrade needed a separate migration path.