Self-Host Langfuse on a VPS for LLM Observability
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
- A VPS running Debian 12 or Ubuntu 24.04 with at least 4 vCPU and 8 GB RAM
- Docker and Docker Compose installed Docker in Production on a VPS: What Breaks and How to Fix It
- A domain name with an A record pointing to your VPS IP (for TLS)
- SSH access with key-based authentication SSH Hardening on a Linux VPS: Complete sshd_config Security Guide
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:3000instead of0.0.0.0:3000. All traffic goes through the reverse proxy. CLICKHOUSE_MIGRATION_URLuses the native protocol (clickhouse://) on port 9000, whileCLICKHOUSE_URLuses HTTP on port 8123. Both are required. MissingCLICKHOUSE_MIGRATION_URLcauses the web container to crash on startup.- Redis uses separate
REDIS_HOST,REDIS_PORT, andREDIS_AUTHvariables instead of a connection string. This is the format Langfuse v3 expects. - The MinIO entrypoint creates the
langfusebucket 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 noevictionto 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:
- Create a new project (e.g., "production")
- Go to Settings > API Keys
- Click Create API Key
- 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
Ready to try it yourself?
Deploy Langfuse and your AI stack on a high-performance European VPS. →