SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-08

Self-host Langfuse to trace your AI agents

Run Langfuse on your own VPS: the real resource floor, pinned image tags, TLS, ClickHouse retention before it fills the disk, and backups that work.

Why trace an AI agent at all

You self-host Langfuse to see what your agent actually did on a run. Langfuse is an open source LLM (large language model) observability tool. It records every prompt, every model response, every tool call and every token, then groups them under one trace you can open and read. Running it on your own VPS means those prompts never leave a server you control.

The reason to bother is plain. You cannot fix a cost problem or a quality problem you cannot see. A provider invoice tells you that Tuesday cost four times what Monday cost. A trace tells you which agent run did it, which prompt grew to 40,000 tokens, and which retry loop ran nine times before giving up. The invoice gives you the number. The trace gives you the code that produced it.

Three terms are used throughout this guide. A trace is one end to end run of your agent. An observation is one step inside that run: a span for ordinary code, a generation for a call to a model. A score is a number attached to a trace, from a human review or an automated evaluator. Langfuse speaks OpenTelemetry (OTel), the vendor neutral standard for distributed tracing, so instrumentation you already have can point at it.

What self-hosting Langfuse actually runs

Langfuse v4 is not one container. It is two application containers and four storage services, and on a single VPS all six run on your box.

  • langfuse-web serves the web interface and the ingestion API.
  • langfuse-worker drains the queue in the background. It parses ingestion batches, calculates cost, and runs the nightly retention job.
  • Postgres holds transactional data such as users, organisations, projects, API keys and prompts.
  • ClickHouse holds the trace data itself, meaning observations and scores. It is a column store built for analytical queries, which is why a dashboard over a hundred million rows still answers quickly.
  • Redis is the queue and the cache that sits between web and worker.
  • MinIO gives you S3 compatible object storage on the box. It holds every raw incoming event plus any media you attach.

Langfuse publishes minimum resources for the three components that do the work.

ChartLangfuse published minimum resources per component
The data behind this chart
[
  {
    "label": "ClickHouse",
    "cpu_cores": 2,
    "memory_gib": 8
  },
  {
    "label": "Langfuse web",
    "cpu_cores": 2,
    "memory_gib": 4
  },
  {
    "label": "Langfuse worker",
    "cpu_cores": 2,
    "memory_gib": 4
  }
]

ClickHouse alone asks for 8 GiB of memory. The web container and the worker ask for 4 GiB each. Those are the published floors for the 3 components Langfuse sizes, and Postgres, Redis and MinIO still need memory on top. The project's own Docker Compose guide recommends a machine with 4 cores and 16 GiB of memory and around 100 GiB of storage, which matches that arithmetic rather than padding it.

Do not try this on a 2 GiB plan. ClickHouse starts, accepts writes for a while, then dies during a background merge, because a merge loads large parts of a table into memory. You will see docker compose ps reporting the clickhouse container as restarting, dmesg carrying a line like Out of memory: Killed process 1234 (clickhouse-serv), and every Langfuse dashboard returning 500. Under lighter pressure ClickHouse refuses the query instead and logs DB::Exception: Memory limit (total) exceeded. Eight GiB is workable for one developer sending a few thousand traces a day. Sixteen is the number to plan for.

Deploy Langfuse with Docker Compose

Clone the repository. The stack, the wiring and the default environment all live in its docker-compose.yml.

git clone https://github.com/langfuse/langfuse.git
cd langfuse

Every value you must change is marked # CHANGEME in that file. Generate the three application secrets first.

openssl rand -base64 32   # NEXTAUTH_SECRET
openssl rand -base64 32   # SALT
openssl rand -hex 32      # ENCRYPTION_KEY

ENCRYPTION_KEY must be 256 bits written as 64 hex characters, which is exactly what openssl rand -hex 32 prints. It encrypts sensitive values at rest, including any LLM provider keys you store in the instance. Change it after data exists and those rows can no longer be decrypted, so treat it as permanent from the first boot. SALT is used to hash your Langfuse API keys, so changing it invalidates every key your agents are already using.

Then set POSTGRES_PASSWORD, CLICKHOUSE_PASSWORD, REDIS_AUTH and MINIO_ROOT_PASSWORD. The MinIO password appears in four places: once as MINIO_ROOT_PASSWORD, then again as LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY, LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY and LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY. Miss one and MinIO rejects that client with SignatureDoesNotMatch, which lands in the worker log while the web interface still looks healthy. Keeping these values in an env file instead of in the tracked compose file is the pattern covered in Docker Compose env files and secrets.

Pin the image tags before you start

The shipped file uses langfuse/langfuse:4 and langfuse/langfuse-worker:4. Those tags move. Langfuse runs its Postgres and ClickHouse migrations automatically at start, so a routine docker compose pull months later becomes an unplanned schema migration on a database you did not back up that morning. Pin both to one release in a docker-compose.override.yml, which Compose merges on top of the shipped file so a later git pull never fights your edits.

services:
  langfuse-web:
    image: docker.io/langfuse/langfuse:4.3.1
  langfuse-worker:
    image: docker.io/langfuse/langfuse-worker:4.3.1

Version 4.3.1 was the current 4.3 release as of August 2026 (4.4.0 has since shipped). Check the project's GitHub releases page, pin whatever is current on the day you deploy, then move that number deliberately. The storage images in the shipped file are already pinned to majors, postgres:17, clickhouse-server:25.12 and redis:7, and they deserve the same treatment.

Bring it up.

docker compose up -d
docker compose ps
docker compose logs -f langfuse-worker

First boot runs the migrations, so allow a minute or two before anything answers. docker compose ps should list six services in state running. If the worker restarts in a loop, its log holds the reason: CLICKHOUSE_MIGRATION_URL uses the ClickHouse native protocol on port 9000, not the HTTP port 8123, and pointing it at 8123 fails there while the web container still looks fine.

Check health from the box itself.

curl -s "http://localhost:3000/api/public/health?failIfDatabaseUnavailable=true"
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/api/public/ready

A plain /api/public/health call only proves the API process is alive, because it deliberately skips the database so the service keeps serving while Postgres blips. The failIfDatabaseUnavailable=true form is the one worth pointing a monitor at, and it returns 503 when the database is unreachable. /api/public/ready returns 200 once migrations are done and the container will accept traffic. Both are ordinary HTTP checks, so an Uptime Kuma status page can watch them and tell you the stack is down before your agents do.

Put TLS in front and close the extra ports

The shipped compose file publishes 3000:3000 for the web container and 9090:9000 for MinIO. Both bind on every interface. On a public IP that means anyone who scans port 3000 reaches your sign up page, and anyone who scans 9090 is talking to the bucket holding your raw prompts.

A firewall rule alone does not close them. Docker writes its own DNAT rules into the nat table, and those are evaluated before ufw's filter rules ever see the packet, so ufw deny 3000 leaves the published port open. This catches enough people to have its own guide: why Docker published ports bypass ufw. Bind on loopback in your override file instead.

services:
  langfuse-web:
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      NEXTAUTH_URL: https://langfuse.example.com
  minio:
    ports:
      - "127.0.0.1:9090:9000"
      - "127.0.0.1:9091:9001"

NEXTAUTH_URL must be the exact public address including the scheme, because the login flow builds its callback URL from that value. Leave it as http://localhost:3000 behind an HTTPS proxy and the sign in round trip sends the browser somewhere it cannot reach.

Now point a reverse proxy at 127.0.0.1:3000 and let it hold the certificate. Traefik in the same Compose project is the usual choice, and the routing labels are the ones covered in running several apps behind one Traefik reverse proxy. Caddy does the same job in two lines if Langfuse is the only thing on the box. Verify with curl -sI https://langfuse.example.com/api/public/ready, then confirm from a second machine that curl http://YOUR_IP:3000 now times out.

One caveat on MinIO. Langfuse serves attached media to your browser through presigned URLs pointing at that S3 endpoint, so if you use multi-modal traces carrying images or audio, a loopback-only MinIO means those attachments will not load. Read the blob storage configuration page before proxying it, because the endpoint written into the presigned URL has to match what you publish. Plain text traces are unaffected.

Create your account on the first visit, then keep the instance yours. Set LANGFUSE_ALLOWED_ORGANIZATION_CREATORS to your own email address, so a stranger who reaches the page cannot create an organisation on your server. If you are already running Authentik as your own identity provider, Langfuse takes a standard OIDC connection, so accounts come and go with the rest of your apps instead of living in a password list only this box knows about.

Send your first trace

Create a project in the web interface and copy its public and secret keys from project settings. The Python SDK reads three environment variables.

export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_BASE_URL="https://langfuse.example.com"

LANGFUSE_BASE_URL is the variable name in SDK v4, which was released in March 2026. Older code and older guides use LANGFUSE_HOST. If your traces are landing on Langfuse Cloud instead of your server, an unset base URL is the reason, because the default points at the hosted instance.

pip install langfuse opentelemetry-instrumentation-anthropic anthropic
import os
from anthropic import Anthropic
from langfuse import get_client, observe
from opentelemetry.instrumentation.anthropic import AnthropicInstrumentor

AnthropicInstrumentor().instrument()
langfuse = get_client()
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

@observe(as_type="tool")
def lookup_order(order_id: str) -> str:
    return f"order {order_id}: shipped"

@observe()
def handle_request(question: str) -> str:
    context = lookup_order("A-1042")
    message = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=512,
        messages=[{"role": "user", "content": f"{context}\n\n{question}"}],
    )
    return message.content[0].text

if __name__ == "__main__":
    assert langfuse.auth_check()
    print(handle_request("Where is my order?"))
    langfuse.flush()

The @observe decorator opens an observation around the function, captures its arguments and its return value, and nests it under whatever observation is already active. AnthropicInstrumentor is the OpenTelemetry instrumentation for the Anthropic client, and it turns each messages.create call into a generation carrying the model name, the token usage and the latency, with no change at the call site.

Two calls do the checking for you. langfuse.auth_check() returns False on bad keys or a wrong base URL, which is faster than wondering why the dashboard is empty. langfuse.flush() blocks until queued spans are sent, and short lived processes need it, because the SDK batches in the background and a script that exits immediately takes its unsent batch with it.

Why does ClickHouse keep growing?

Traces are the fastest growing data most people ever self-host. Every agent run writes one row per step, and inputs and outputs are stored in full, so a chatty agent with long prompts produces far more bytes per day than the application it is watching. Left alone, ClickHouse fills the disk, and a full disk stops ingestion rather than slowing it.

Two separate things grow here, and they need two separate fixes.

The first is your own trace data, and the fix is the retention setting. Open project settings in the web interface and set a data retention period in days. Langfuse accepts a minimum of 3 days. A nightly job then selects traces, observations, scores and media assets older than that window and deletes them from ClickHouse and from blob storage. The job needs DeleteObject permission on the bucket, which the MinIO root credentials in the default compose file already have. Deletion is permanent, so configure a blob storage export first if you need long term history. Do not hand-write TTL clauses on Langfuse's own tables: the retention job is what keeps ClickHouse and the bucket in step, and a manual TTL deletes one side only.

Pick the window from what you actually use. Cost and quality review happens on days-old data, not months-old data. Thirty days is a reasonable start for a small team, and 14 days is enough if you only open a trace when something breaks.

The second is ClickHouse's own system log tables, and this one surprises people, because the disk keeps growing after retention is configured. ClickHouse writes trace_log, text_log, opentelemetry_span_log, metric_log and asynchronous_metric_log for its own diagnostics, they ship with no TTL, and Langfuse never reads them. Find out where the disk actually went first.

SELECT table, formatReadableSize(size) AS size, rows FROM (
    SELECT table, database, sum(bytes) AS size, sum(rows) AS rows
    FROM system.parts
    WHERE active
    GROUP BY table, database
    ORDER BY size DESC
)

Run it with docker compose exec clickhouse clickhouse-client --password "$CLICKHOUSE_PASSWORD". If system tables sit near the top, turn them off with a config overlay, because ClickHouse merges every file in /etc/clickhouse-server/config.d/ over its main config at start.

<clickhouse>
    <trace_log remove="1"/>
    <text_log remove="1"/>
    <opentelemetry_span_log remove="1"/>
    <asynchronous_metric_log remove="1"/>
    <metric_log remove="1"/>
</clickhouse>

Mount it and restart ClickHouse.

services:
  clickhouse:
    volumes:
      - ./clickhouse-config.d/system-logs.xml:/etc/clickhouse-server/config.d/system-logs.xml:ro

That stops new writes. Rows already on disk stay there, so reclaim the space explicitly with DROP TABLE IF EXISTS system.trace_log and the same for each table you removed. If you would rather keep the diagnostics, the alternative is an aggressive TTL on each table instead of remove="1", which the Langfuse scaling docs spell out.

One more table is worth knowing about. blob_storage_file_log tracks the event files uploaded to your bucket. If you also set a lifecycle policy on the bucket, give the table a matching TTL so the two do not drift apart.

ALTER TABLE blob_storage_file_log MODIFY TTL created_at + INTERVAL 30 DAY DELETE;

Put a plain df -h alert on the data disk as well. Traces do not grow smoothly. They grow the day you ship a new agent, and the first sign of that should not be ingestion failing.

Back up Postgres and ClickHouse

A Langfuse backup has three parts. Postgres holds your users, organisations, projects and API keys. ClickHouse holds the traces. MinIO holds the raw events. Restore only Postgres and you get a working login with no history. Restore only ClickHouse and you get history nobody can log in to see.

Postgres is a plain pg_dump, which is what the Langfuse backup docs recommend.

docker compose exec -T postgres pg_dump -U postgres postgres \
  | gzip > langfuse-pg-$(date +%F).sql.gz

ClickHouse takes more care, because a live data directory copied while merges are running is not a consistent backup. The simple approach on one box is to stop the container and archive the volume.

docker compose stop clickhouse
docker volume ls | grep clickhouse
docker run --rm -v langfuse_langfuse_clickhouse_data:/data -v "$PWD":/backup alpine \
  tar czf /backup/langfuse-ch-$(date +%F).tar.gz -C /data .
docker compose start clickhouse

Use the volume name docker volume ls prints, not the one written in the YAML. The file declares langfuse_clickhouse_data, and Compose prefixes it with the project name, so a clone in a directory called langfuse produces langfuse_langfuse_clickhouse_data. Get that wrong and docker run creates a new empty volume without complaining, and your archive contains nothing.

The web container writes every incoming event to the bucket before the worker processes it, so a short ClickHouse stop mostly means the worker retries afterwards. Do it in a quiet hour and keep it short. For a busier instance, ClickHouse's own BACKUP DATABASE default TO S3(...) statement writes a consistent backup without stopping the server. MinIO is the third piece, and mc mirror or MinIO replication to an off-box bucket covers it. Whatever you produce, get it off the server, which is what encrypted restic backups on a VPS are for.

Redis needs no backup. It holds the queue and the cache, so losing it costs you the events currently in flight and nothing older.

The consistency caveat is real and worth stating plainly. Postgres and ClickHouse are dumped at different moments, so a restore can leave a project row with no traces, or traces belonging to a project that no longer exists. Langfuse tolerates that, but take both dumps close together and in a low-traffic window. The event bucket is the real safety net, because Langfuse persists every incoming event there before processing it.

Restore into a scratch stack at least once. That is how you find out about a wrong volume name now, instead of during an outage.

What to look at first

Four things earn their place in the first week.

  • Cost per trace. Langfuse computes cost from the model name and the token usage, so sort traces by cost and read the most expensive one end to end. The answer is usually a prompt that grew: a whole document pasted into context, or a conversation history nobody trims. Once you can see it, controlling what an AI agent costs you becomes an engineering task instead of a guess.
  • Token usage split by input and output. Input tokens are numerous and cheap, output tokens are few and expensive, and cached input is cheaper again. The same accounting is unpacked in how Claude Code token usage is counted, and it applies to any agent you write yourself.
  • Latency percentiles. The median hides the problem. p95 and p99 are where the timeouts live, and inside an agent loop a slow tool call at p95 gets multiplied by the number of iterations.
  • Failed tool calls. Filter observations by level ERROR. A tool that fails 5% of the time is invisible in an aggregate success rate and very visible in the traces, where you watch the model retry and then burn tokens working around it.

Set the retention window and pick the dashboard you will check weekly on the same day you deploy. An observability tool nobody opens is a database that fills a disk.

FAQ

How much memory does a self-hosted Langfuse need?

Plan for 4 CPU cores and 16 GiB of memory, which is what the Langfuse Docker Compose guide recommends for a single virtual machine, plus around 100 GiB of storage. The published component minimums are 8 GiB for ClickHouse and 4 GiB each for the web and worker containers, and Postgres, Redis and MinIO still need memory on top of those. Eight GiB runs one developer's instance. Two GiB does not: ClickHouse is killed by the kernel during background merges, and dmesg shows Out of memory: Killed process.

Why does my ClickHouse disk keep filling up after I set data retention?

The retention setting covers Langfuse's own data only. ClickHouse separately writes its diagnostic tables trace_log, text_log, opentelemetry_span_log, metric_log and asynchronous_metric_log, and those ship with no TTL. Query system.parts grouped by table to see which one is largest, then disable the unused tables with a remove="1" entry in a file under /etc/clickhouse-server/config.d/, restart ClickHouse, and drop the existing tables to reclaim the space already used.

What is the minimum data retention period in Langfuse?

Three days. Retention is set per project in project settings, or through the projects API, and a nightly job deletes traces, observations, scores and media assets older than the window from both ClickHouse and blob storage. Deletion cannot be undone, so configure a blob storage export first if you need history beyond that window.

Do I have to back up both Postgres and ClickHouse?

Yes, because they hold different things. Postgres holds users, organisations, projects and API keys, and ClickHouse holds the trace data itself. A Postgres-only restore gives you an instance you can log into with nothing in it. Back up the MinIO bucket too, since it holds the raw events Langfuse persists on arrival, which is the closest thing to a source of truth in the stack.

Can I point an existing OpenTelemetry setup at self-hosted Langfuse?

Yes. Langfuse v4 and its v4 SDKs are built on OpenTelemetry, and the Anthropic and OpenAI OTel instrumentations export straight to it. In Python, run pip install langfuse opentelemetry-instrumentation-anthropic, call AnthropicInstrumentor().instrument() once at startup, and set LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL to your own host. Confirm with langfuse.auth_check() before hunting for a missing dashboard.