Self-Host Langfuse on a VPS for LLM Observability

11 min read·Matthieu·LLM ObservabilityCI/CDDeepEvalDocker ComposeLangfuse|

Deploy Langfuse v3 with Docker Compose on your own VPS. Trace LLM calls, track costs, run automated evaluations with DeepEval, and wire quality gates into your CI/CD pipeline.

Langfuse is an open-source LLM observability platform. It traces every LLM call in your application: latency, token usage, cost, and prompt/completion pairs. Self-hosting it with Docker Compose keeps all trace data on your infrastructure. No per-event pricing. No data leaving your network.

This tutorial covers the full lifecycle: deploying Langfuse v3, adding TLS, instrumenting Python and TypeScript applications, and building automated evaluation pipelines with DeepEval integrated into CI/CD.

What does Langfuse v3 need to run on a VPS?

Langfuse v3 runs six containers: the web UI, an async worker, PostgreSQL for metadata, ClickHouse for trace analytics, Redis for queuing, and MinIO for blob storage. This is a significant change from v2, which only needed PostgreSQL.

Component Purpose Default Port RAM Baseline
langfuse-web Web UI and API 3000 ~512 MB
langfuse-worker Async event processing 3030 ~512 MB
PostgreSQL 17 Transactional metadata 5432 ~256 MB
ClickHouse OLAP trace analytics 8123 (HTTP), 9000 (native) ~1 GB
Redis 7 Queue and cache 6379 ~128 MB
MinIO Blob/media storage 9000 (API), 9001 (console) ~256 MB

Allocate at least 4 vCPU and 8 GB RAM. A Virtua Cloud VCS-8 (4 vCPU, 8 GB RAM, NVMe) handles this comfortably. Start with 100 GB disk. ClickHouse grows roughly 1-2 GB per million traces depending on prompt/completion sizes.

Resource sizing by scale

Traces/month Disk growth/month Recommended VPS
< 100K ~500 MB 4 vCPU / 8 GB
100K - 1M 1-2 GB 4 vCPU / 8 GB
1M - 10M 10-20 GB 8 vCPU / 16 GB
> 10M 50+ GB Dedicated / Kubernetes

Prerequisites

How do I deploy Langfuse with Docker Compose?

Clone the official repository and use the provided docker-compose.yml as a starting point. The key step is generating real secrets instead of using the placeholder values.

mkdir -p /opt/langfuse && cd /opt/langfuse

Create the environment file with generated secrets:

cat > .env << 'ENVEOF'
# PostgreSQL
POSTGRES_USER=langfuse
POSTGRES_PASSWORD=REPLACE_PG
POSTGRES_DB=langfuse

# ClickHouse
CLICKHOUSE_USER=clickhouse
CLICKHOUSE_PASSWORD=REPLACE_CH

# MinIO
MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=REPLACE_MINIO

# Redis
REDIS_AUTH=REPLACE_REDIS

# Langfuse secrets
NEXTAUTH_SECRET=REPLACE_NEXTAUTH
SALT=REPLACE_SALT
ENCRYPTION_KEY=REPLACE_ENCRYPTION

# Langfuse config
NEXTAUTH_URL=https://langfuse.example.com
LANGFUSE_CSP_ENFORCE_HTTPS=true
KEEP_ALIVE_TIMEOUT=70
ENVEOF

Now replace the placeholders with real random values. Use openssl rand -hex instead of -base64 because base64 output contains /, +, and = characters that break PostgreSQL connection URLs:

sed -i "s|REPLACE_PG|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_CH|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_MINIO|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_REDIS|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_NEXTAUTH|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_SALT|$(openssl rand -hex 32)|" .env
sed -i "s|REPLACE_ENCRYPTION|$(openssl rand -hex 32)|" .env

Lock down the file. Only root should read it:

chmod 600 .env
ls -la .env
-rw------- 1 root root  715 Mar 19 10:00 .env

How do I fix the port 9000 conflict between MinIO and ClickHouse?

Both MinIO and ClickHouse default to port 9000. The official docker-compose.yml already maps MinIO's API port to 9090 on the host (9090:9000), avoiding the conflict. If you are writing a custom compose file, make sure you remap one of them.

The official compose file also binds infrastructure ports to 127.0.0.1 only (PostgreSQL, ClickHouse native, Redis, MinIO console), preventing external access. The only port exposed to all interfaces is 3000 for the web UI, which we will put behind a reverse proxy.

Create the docker-compose.yml:

services:
  langfuse-web:
    image: docker.io/langfuse/langfuse:3
    ports:
      - "127.0.0.1:3000:3000"
    environment:
      - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      - NEXTAUTH_URL=${NEXTAUTH_URL}
      - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
      - SALT=${SALT}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
      - CLICKHOUSE_MIGRATION_URL=clickhouse://clickhouse:9000
      - CLICKHOUSE_URL=http://clickhouse:8123
      - CLICKHOUSE_USER=${CLICKHOUSE_USER}
      - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
      - CLICKHOUSE_CLUSTER_ENABLED=false
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_AUTH=${REDIS_AUTH}
      - LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
      - LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://minio:9000
      - LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=${MINIO_ROOT_USER}
      - LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
      - LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
      - LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
      - LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
      - LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://minio:9000
      - LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=${MINIO_ROOT_USER}
      - LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
      - LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
      - LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
      - LANGFUSE_CSP_ENFORCE_HTTPS=${LANGFUSE_CSP_ENFORCE_HTTPS}
      - KEEP_ALIVE_TIMEOUT=${KEEP_ALIVE_TIMEOUT}
      - LANGFUSE_LOG_LEVEL=info
    depends_on:
      postgres:
        condition: service_healthy
      clickhouse:
        condition: service_healthy
      minio:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  langfuse-worker:
    image: docker.io/langfuse/langfuse-worker:3
    ports:
      - "127.0.0.1:3030:3030"
    environment:
      - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      - NEXTAUTH_URL=${NEXTAUTH_URL}
      - SALT=${SALT}
      - ENCRYPTION_KEY=${ENCRYPTION_KEY}
      - CLICKHOUSE_MIGRATION_URL=clickhouse://clickhouse:9000
      - CLICKHOUSE_URL=http://clickhouse:8123
      - CLICKHOUSE_USER=${CLICKHOUSE_USER}
      - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
      - CLICKHOUSE_CLUSTER_ENABLED=false
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_AUTH=${REDIS_AUTH}
      - LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
      - LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://minio:9000
      - LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=${MINIO_ROOT_USER}
      - LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
      - LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
      - LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
      - LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
      - LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://minio:9000
      - LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=${MINIO_ROOT_USER}
      - LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
      - LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
      - LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
      - LANGFUSE_LOG_LEVEL=info
    depends_on:
      postgres:
        condition: service_healthy
      clickhouse:
        condition: service_healthy
      minio:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: docker.io/postgres:17
    ports:
      - "127.0.0.1:5432:5432"
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=${POSTGRES_DB}
    volumes:
      - langfuse_postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
      interval: 3s
      timeout: 3s
      retries: 10
    restart: unless-stopped

  clickhouse:
    image: docker.io/clickhouse/clickhouse-server
    user: "101:101"
    ports:
      - "127.0.0.1:8123:8123"
      - "127.0.0.1:9000:9000"
    volumes:
      - langfuse_clickhouse_data:/var/lib/clickhouse
      - langfuse_clickhouse_logs:/var/log/clickhouse-server
    environment:
      - CLICKHOUSE_DB=default
      - CLICKHOUSE_USER=${CLICKHOUSE_USER}
      - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  minio:
    image: docker.io/minio/minio
    entrypoint: sh
    command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
    ports:
      - "127.0.0.1:9090:9000"
      - "127.0.0.1:9091:9001"
    environment:
      - MINIO_ROOT_USER=${MINIO_ROOT_USER}
      - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
    volumes:
      - langfuse_minio_data:/data
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 3s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: docker.io/redis:7
    command: redis-server --requirepass ${REDIS_AUTH} --maxmemory-policy noeviction
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - langfuse_redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_AUTH}", "ping"]
      interval: 3s
      timeout: 3s
      retries: 10
    restart: unless-stopped

volumes:
  langfuse_postgres_data:
  langfuse_clickhouse_data:
  langfuse_clickhouse_logs:
  langfuse_minio_data:
  langfuse_redis_data:

A few things to note in this compose file:

  • The web container binds to 127.0.0.1:3000 instead of 0.0.0.0:3000. All traffic goes through the reverse proxy.
  • CLICKHOUSE_MIGRATION_URL uses the native protocol (clickhouse://) on port 9000, while CLICKHOUSE_URL uses HTTP on port 8123. Both are required. Missing CLICKHOUSE_MIGRATION_URL causes the web container to crash on startup.
  • Redis uses separate REDIS_HOST, REDIS_PORT, and REDIS_AUTH variables instead of a connection string. This is the format Langfuse v3 expects.
  • The MinIO entrypoint creates the langfuse bucket directory on first start (mkdir -p /data/langfuse). Without this, S3 uploads fail until the bucket exists.
  • ClickHouse runs as user: "101:101" to match the container's internal clickhouse user.
  • Redis runs with --maxmemory-policy noeviction to prevent data loss when memory is tight. Langfuse relies on Redis for its job queue, and evicting keys would cause silent data loss.

Start the stack:

docker compose up -d

Wait about 2-3 minutes for all containers to initialize. ClickHouse and PostgreSQL run migrations on first boot. Check the status:

docker compose ps

All six containers should show Up. The infrastructure containers (postgres, clickhouse, minio, redis) show (healthy). The langfuse-web and langfuse-worker containers do not define healthchecks, so they show Up without a health tag. If any container is restarting, check its logs:

docker compose logs langfuse-web --tail 50

Hit the health endpoint to confirm the API is up and the database connection works:

curl -s http://localhost:3000/api/public/health?failIfDatabaseUnavailable=true | python3 -m json.tool
{
    "status": "OK",
    "version": "3.160.0"
}

How do I add TLS to a self-hosted Langfuse instance?

Put Caddy in front of Langfuse. Caddy handles TLS certificate provisioning from Let's Encrypt automatically. No certbot cron jobs, no manual renewal.

Install Caddy:

apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy

Create the Caddyfile:

cat > /etc/caddy/Caddyfile << 'EOF'
langfuse.example.com {
    header -Server
    reverse_proxy 127.0.0.1:3000 {
        transport http {
            keepalive 75s
            keepalive_idle_conns 10
        }
    }
}
EOF

The keepalive 75s is set higher than Langfuse's KEEP_ALIVE_TIMEOUT=70 to prevent the reverse proxy from holding stale connections. This mismatch is the root cause of intermittent 502/504 errors that many self-hosters hit. The header -Server directive strips version information from responses.

systemctl enable --now caddy

enable makes Caddy survive reboots. --now starts it immediately.

systemctl status caddy
 caddy.service - Caddy
     Loaded: loaded (/usr/lib/systemd/system/caddy.service; enabled; preset: enabled)
     Active: active (running)

From your local machine, test the TLS endpoint:

curl -I https://langfuse.example.com/api/public/health
HTTP/2 200
content-type: application/json

Configure authentication and API keys

Open https://langfuse.example.com in your browser. Create the first user account. This becomes the admin account.

After signing in:

  1. Create a new project (e.g., "production")
  2. Go to Settings > API Keys
  3. Click Create API Key
  4. Save the Public Key and Secret Key. You need both to instrument your applications.

The public key identifies your project. The secret key authenticates writes. Treat the secret key like a database password.

How do I instrument a Python app to send traces to Langfuse?

The Langfuse Python SDK is built on OpenTelemetry. The @observe() decorator automatically creates traces and spans for decorated functions. Nested calls produce nested spans in the Langfuse UI.

Install the SDK:

pip install langfuse openai

Set the environment variables pointing to your self-hosted instance:

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

Here is a traced application:

from langfuse import observe, propagate_attributes, get_client
from langfuse.openai import openai  # patched OpenAI client

@observe()
def retrieve_context(query: str) -> str:
    # Simulating a retrieval step
    return "Paris is the capital of France, with a population of 2.1 million."

@observe()
def answer_question(query: str) -> str:
    context = retrieve_context(query)

    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": f"Answer based on this context: {context}"},
            {"role": "user", "content": query},
        ],
    )
    return response.choices[0].message.content

@observe()
def run_pipeline(query: str) -> str:
    with propagate_attributes(
        user_id="user_42",
        session_id="session_abc",
        tags=["production", "rag-pipeline"],
    ):
        return answer_question(query)

result = run_pipeline("What is the capital of France?")
print(result)

# Flush traces before exit in short-lived scripts
get_client().shutdown()

The @observe() on run_pipeline creates the root trace. answer_question and retrieve_context become nested spans. The patched openai import (from langfuse.openai import openai) automatically captures model name, token counts, latency, and cost as a generation span. The propagate_attributes context manager attaches user and session metadata to all nested observations.

After running this script, open the Langfuse dashboard. The Trace Explorer shows the full call tree with timing for each span.

How do I instrument a TypeScript application?

Install the Langfuse TypeScript SDK and the OpenTelemetry node SDK:

npm install @langfuse/tracing @langfuse/otel @opentelemetry/sdk-node

Set the same environment variables:

export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_BASEURL="https://langfuse.example.com"
import {
  observe,
  startActiveObservation,
  propagateAttributes,
} from "@langfuse/tracing";

const fetchContext = observe(
  async (query: string) => {
    return "Paris is the capital of France.";
  },
  { name: "fetch-context", asType: "span" }
);

const callLLM = observe(
  async (query: string, context: string) => {
    // Replace with your actual LLM call
    return `Based on context: ${context}, the answer is Paris.`;
  },
  { name: "llm-call", asType: "generation" }
);

await startActiveObservation("rag-pipeline", async (root) => {
  await propagateAttributes(
    { userId: "user_42", sessionId: "session_abc" },
    async () => {
      const context = await fetchContext("Capital of France?");
      const answer = await callLLM("Capital of France?", context);
      root.update({ output: { answer } });
    }
  );
});

Call forceFlush() on the span processor before serverless functions or scripts exit to prevent data loss.

Dashboard walkthrough

The Langfuse dashboard gives you four key views:

Trace Explorer shows individual request traces. Click any trace to see the full span tree: which functions ran, how long each took, and the exact prompts/completions sent to the LLM. Filter by user ID, tags, or time range.

Cost tracking breaks down spend by model. You see which models consume the most tokens and which API calls are expensive. Use this to identify prompts that waste tokens on unnecessarily long completions.

Latency percentiles (p50, p90, p99) per endpoint or span name. If your p99 latency spikes, you can drill into the slowest traces to find the bottleneck.

Token usage trends over time. Watch for unexpected jumps that indicate a prompt change or a bug generating longer outputs.

How do I set up automated LLM evaluation with DeepEval and Langfuse?

DeepEval is an open-source LLM evaluation framework that scores model outputs on metrics like hallucination, faithfulness, and relevance. Combined with Langfuse, you can fetch production traces, run evaluations against them, and push scores back to the Langfuse dashboard.

Install DeepEval:

pip install deepeval

Evaluation metrics

Metric What it measures Score range When to use
Hallucination Factual accuracy against provided context 0-1 (1 = no hallucination) RAG pipelines
Faithfulness Whether output aligns with retrieval context 0-1 (1 = faithful) RAG pipelines
Answer Relevancy Whether response answers the question 0-1 (1 = relevant) Any LLM app
G-Eval Custom criteria via LLM-as-judge 0-1 Custom quality checks

Evaluation script

This script fetches recent traces from Langfuse, runs DeepEval metrics, and pushes scores back:

import os
from langfuse import Langfuse
from deepeval.metrics import GEval, AnswerRelevancyMetric
from deepeval.test_case import LLMTestCase, LLMTestCaseParams

langfuse = Langfuse(
    public_key=os.environ["LANGFUSE_PUBLIC_KEY"],
    secret_key=os.environ["LANGFUSE_SECRET_KEY"],
    host=os.environ["LANGFUSE_HOST"],
)

# Fetch traces tagged for evaluation
traces = langfuse.api.trace.list(
    tags=["eval-candidate"],
    limit=50,
).data

# Define metrics
relevancy = AnswerRelevancyMetric(threshold=0.7)
correctness = GEval(
    name="Correctness",
    criteria="Determine whether the output is factually correct based on the context.",
    evaluation_params=[
        LLMTestCaseParams.ACTUAL_OUTPUT,
        LLMTestCaseParams.EXPECTED_OUTPUT,
    ],
)

for trace in traces:
    test_case = LLMTestCase(
        input=trace.input,
        actual_output=trace.output,
        retrieval_context=[trace.metadata.get("context", "")],
    )

    relevancy.measure(test_case)
    correctness.measure(test_case)

    # Push scores back to Langfuse
    langfuse.create_score(
        trace_id=trace.id,
        name="relevancy",
        value=relevancy.score,
        comment=relevancy.reason,
    )
    langfuse.create_score(
        trace_id=trace.id,
        name="correctness",
        value=correctness.score,
        comment=correctness.reason,
    )

langfuse.shutdown()
print(f"Evaluated {len(traces)} traces")

After running, evaluation scores appear in the Langfuse dashboard alongside the original traces. You can filter traces by score to find low-quality outputs.

How do I run LLM evaluations in a CI/CD pipeline?

Wire DeepEval into GitHub Actions to catch quality regressions before they reach production. The workflow runs your evaluation suite against a test dataset on every pull request.

Create .github/workflows/llm-eval.yml:

name: LLM Evaluation

on:
  pull_request:
    paths:
      - 'prompts/**'
      - 'src/llm/**'

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: pip install deepeval langfuse openai

      - name: Run evaluation suite
        env:
          LANGFUSE_PUBLIC_KEY: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
          LANGFUSE_SECRET_KEY: ${{ secrets.LANGFUSE_SECRET_KEY }}
          LANGFUSE_HOST: ${{ secrets.LANGFUSE_HOST }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
        run: deepeval test run tests/test_llm_quality.py

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: eval-results
          path: .deepeval/

Create the test file tests/test_llm_quality.py:

import deepeval
from deepeval import assert_test
from deepeval.test_case import LLMTestCase
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric
from deepeval.dataset import EvaluationDataset

# Load test cases from a JSON file or define inline
test_cases = [
    LLMTestCase(
        input="What is the capital of France?",
        actual_output=my_llm_function("What is the capital of France?"),
        retrieval_context=["France is a country in Western Europe. Its capital is Paris."],
    ),
    # Add more test cases covering your prompt changes
]

dataset = EvaluationDataset(test_cases=test_cases)

@deepeval.parametrize(dataset)
def test_answer_relevancy(test_case):
    metric = AnswerRelevancyMetric(threshold=0.7)
    assert_test(test_case, [metric])

@deepeval.parametrize(dataset)
def test_faithfulness(test_case):
    metric = FaithfulnessMetric(threshold=0.7)
    assert_test(test_case, [metric])

The workflow triggers only when prompt templates or LLM code change. If any metric drops below the threshold, the PR build fails. Developers see which test case failed and by how much in the GitHub Actions log.

How do I back up PostgreSQL and ClickHouse for Langfuse?

Self-hosting without backups is a liability. PostgreSQL holds user accounts, project settings, and API keys. ClickHouse holds all trace data. MinIO holds media uploads and batch exports.

PostgreSQL backup

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

ClickHouse backup

Export tables individually using the ClickHouse client:

docker compose exec clickhouse clickhouse-client \
  --user clickhouse \
  --password "$(grep CLICKHOUSE_PASSWORD /opt/langfuse/.env | cut -d= -f2)" \
  --query "SELECT * FROM traces FORMAT Native" > /opt/backups/traces-$(date +%F).native

ClickHouse also supports the BACKUP DATABASE command for full backups, but it requires configuring an allowed_disk in the ClickHouse server config. For Docker deployments, the per-table export above is simpler.

MinIO backup

Sync MinIO data to a remote S3-compatible bucket or a local directory:

docker run --rm --network langfuse_default \
  minio/mc alias set local http://minio:9000 minio "$(grep MINIO_ROOT_PASSWORD /opt/langfuse/.env | cut -d= -f2)" && \
  mc mirror local/langfuse /opt/backups/minio-langfuse/

Automate with cron

cat > /etc/cron.d/langfuse-backup << 'EOF'
0 3 * * * root cd /opt/langfuse && docker compose exec -T postgres pg_dump -U langfuse langfuse | gzip > /opt/backups/langfuse-pg-$(date +\%F).sql.gz
0 4 * * * root find /opt/backups -name "langfuse-pg-*.sql.gz" -mtime +14 -delete
EOF
chmod 644 /etc/cron.d/langfuse-backup

Production hardening

Update procedure

Pull new images and restart:

cd /opt/langfuse
docker compose pull
docker compose up -d

Langfuse runs database migrations automatically on startup. Check the web container logs after an update:

docker compose logs langfuse-web --tail 20

Look for Ready in the output. If migrations fail, the container will not start. Roll back by pinning the previous version tag in docker-compose.yml (e.g., langfuse/langfuse:3.x.x).

Monitoring Langfuse itself

The health endpoint at /api/public/health returns 200 OK when the API is functional. Add ?failIfDatabaseUnavailable=true for a deeper check that includes database connectivity.

Monitor disk usage on the ClickHouse volume. It is the fastest-growing component:

docker system df -v | grep clickhouse

Watch container memory with:

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}"

Firewall

Only ports 22 (SSH), 80, and 443 should be open. All database ports are bound to 127.0.0.1 in the compose file, but a firewall adds defense in depth How to Set Up a Linux VPS Firewall with UFW and nftables:

ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Langfuse vs LangSmith vs Opik

Feature Langfuse LangSmith Opik
License MIT (open source) Proprietary Apache 2.0
Self-hosting Docker Compose / K8s Enterprise license only Docker Compose / K8s
Trace storage Your infrastructure LangChain's cloud Your infrastructure
Eval framework External (DeepEval, etc.) Built-in Built-in
OpenTelemetry Native support No Partial

Langfuse and Opik are the two viable open-source options for self-hosting. Langfuse has a larger community and more integrations. LangSmith requires an Enterprise license for self-hosting. See the Langfuse self-hosting docs for the latest deployment options.

Troubleshooting

Container stays unhealthy: Check docker compose logs <service> --tail 100. Common causes: wrong password in .env (ClickHouse is case-sensitive on usernames), or MinIO failing to initialize buckets on first start. Restart the stack with docker compose down && docker compose up -d.

Web container crash loops with "invalid port number": Your PostgreSQL password contains special characters (/, +, =) that break the postgresql:// connection URL. Regenerate all passwords using openssl rand -hex 32 instead of -base64, then run docker compose down -v && docker compose up -d to reset with the new credentials.

Web container crash with "CLICKHOUSE_MIGRATION_URL is not configured": Add CLICKHOUSE_MIGRATION_URL=clickhouse://clickhouse:9000 to both the langfuse-web and langfuse-worker environment sections. This uses the native ClickHouse protocol on port 9000, separate from the HTTP API on port 8123.

502/504 errors through the reverse proxy: Set KEEP_ALIVE_TIMEOUT in the Langfuse web container to a value higher than your reverse proxy's idle timeout. Caddy defaults to 30s keepalive. We set Langfuse to 70s and Caddy to 75s.

Worker shutdown takes a long time: Under load, the worker flushes its queue before stopping. This can take up to an hour. If you need a faster shutdown, scale to zero first: docker compose stop langfuse-worker, wait for the queue to drain, then proceed.

ClickHouse disk filling up: Set up data retention. Langfuse provides built-in data retention settings in the dashboard under Settings > Data Retention. Configure trace TTL based on your storage capacity.

Logs: All Langfuse containers write to stdout. View them with:

journalctl -u docker -f
docker compose logs -f

AIOps on a VPS: AI-Driven Server Management with Open-Source Tools Self-Host AI Agents on a VPS Build and Self-Host a Custom MCP Server on a VPS Docker Compose for Multi-Service VPS Deployments

Self-Host Langfuse on a VPS: LLM Observability with Docker