AWS Builder Center

Self-Hosting Langfuse on the Cheap: Two EC2 Instances with Docker Compose

A PoC-friendly, nearly $0/month self-hosted Langfuse setup using two EC2 t4g.small instances (free tier) with Docker Compose. Includes GitHub Actions one-click start/stop, Elastic IP-free design, and Strands Agent integration for prompt management and tracing.

Introduction

When developing AI agents, you often want to manage prompts outside your code and trace and visualize agent behavior.
Langfuse is an open-source platform that provides both of these capabilities, but the SaaS version can be overkill for a PoC, and there are cases where you'd rather keep data in-house. So I built a self-hosted Langfuse setup running on two EC2 instances for close to $0/month.
Start/stop is handled via GitHub Actions — anyone on the team can spin the environment up or down with a single click, so you only pay when it's running.
Note: This architecture is designed for PoC use. For production, consider ALB + HTTPS, private subnets, Aurora, and other hardening measures.

Prerequisites

  • AWS account (Tokyo region)
  • AWS CDK v2 (TypeScript)
  • Langfuse v3
  • GitHub repository (using GitHub Actions workflow_dispatch)

Architecture Overview

Langfuse serves two roles here: Prompt Management (version control and hot-swapping of prompts) and Tracing (visualization of each step in the agent pipeline).
architecture

Why Not ECS Fargate?

Langfuse's official documentation describes an ECS (Fargate) deployment: https://langfuse.com/self-hosting/deployment/aws-ecs 
However, for PoC use, the always-on cost of containers adds up. Running ALB + Fargate + Aurora means you're paying even when no one is using it.
Instead, I leveraged the t4g.small free tier (available through the end of December 2026 — 750 hours/month) and ran everything with docker compose directly on EC2. With a "start only when needed" policy, two instances combined still stay under the free limit at about 368 instance-hours/month (8 hours/day × 23 business days).

Why Split into Two Instances?

Langfuse v3 self-hosting requires six containers: langfuse-web, langfuse-worker, PostgreSQL, Redis, ClickHouse, and an S3-compatible store (MinIO). Running all of them on a single t4g.small (2 GB RAM) risks OOM kills — ClickHouse in particular is memory-hungry and can affect the other containers.
So I separated the stack into a Frontend (langfuse-web, worker, MinIO) and a Backend (PostgreSQL, Redis, ClickHouse).
This separation means ClickHouse memory spikes don't crash the Web UI, you can restart the DB tier without touching the Frontend, and you can independently scale the Backend to t4g.medium if needed.
Note: MinIO is the S3-compatible object store required by Langfuse v3 for event data and media files. It's placed on the Frontend instance so it shares a Docker network with langfuse-web and langfuse-worker. See: https://langfuse.com/self-hosting/deployment/infrastructure/blobstorage 

Avoiding Elastic IP Costs

EC2 public IPs change on every stop/start cycle. Elastic IPs fix this, but they cost $0.005/hour even when the instance is stopped: https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/ 
So I skipped Elastic IPs entirely and designed the system to tolerate changing IPs.

Backend (DB) Side

The Frontend connects via the VPC private IP, which does not change across stop/start cycles — no extra work needed. See: https://docs.aws.amazon.com/vpc/latest/userguide/vpc-ip-addressing.html 

Frontend (Web UI) Side

Because the public IP changes on every boot, Langfuse's NEXTAUTH_URL (the auth callback URL) must be updated each time.
I solve this with a systemd ExecStartPre script that fetches the current IP via IMDSv2 (the EC2 instance metadata service) and writes it into the .env file:
1
2
3
4
5
6
7
8
#!/bin/bash
TOKEN=$(curl -sf -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
PUBLIC_IP=$(curl -sf -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/public-ipv4)
if [ -n "$PUBLIC_IP" ]; then
sed -i "s|^NEXTAUTH_URL=.*|NEXTAUTH_URL=http://${PUBLIC_IP}:3000|" /opt/langfuse/.env
fi
systemd's execution order is ExecStartPre (IP update) → ExecStart (docker compose up), so Langfuse always boots with the correct IP.

Automating Start/Stop with GitHub Actions

To enable a "run only when needed" workflow, I set up GitHub Actions workflow_dispatch triggers so any team member can start or stop the EC2 instances with one click.

OIDC Federation (IAM Role)

To avoid storing AWS credentials in GitHub, I use GitHub OIDC federation. CDK provisions an IAM role with only EC2 Start/Stop and SSM permissions. After deployment, set the Role ARN output as the GitHub repository secret AWS_OIDC_ROLE_ARN and the workflows are ready to use.

Workflows (langfuse-up / down / status)

Three workflows are provided:
  • langfuse-up: Starts Backend EC2 → runs a DB health check → starts Frontend EC2, then prints the Langfuse URL in the Job Summary.
  • langfuse-down: Reverses the order — runs docker compose down on Frontend first, stops Frontend, then stops Backend.
  • langfuse-status: Reports the state of both EC2 instances and the Langfuse health check result.
Backend starts first because the DB must be ready before Langfuse can boot. docker compose down runs before stopping the instance to ensure PostgreSQL and ClickHouse shut down cleanly without data loss.

Passing Instance IDs

Workflows need Instance IDs to operate on EC2. CDK writes the IDs to SSM Parameter Store, and the workflows reference only the parameter names — set once in env:
1
2
3
4
# Shared env across all three workflows
env:
SSM_BACKEND_ID: /langfuse/dev/backend-instance-id
SSM_FRONTEND_ID: /langfuse/dev/frontend-instance-id
1
2
3
4
# Fetching inside a workflow step
BACKEND_ID=$(aws ssm get-parameter \
--name "$SSM_BACKEND_ID" \
--query 'Parameter.Value' --output text)
This way, even if you recreate the EC2 instances (and their IDs change), the workflows don't need updating.

Connecting Strands Agent to Langfuse

To connect a Strands Agent to Langfuse, just set three environment variables:
1
2
3
4
# .env
LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxx
LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxx
LANGFUSE_BASE_URL=http://<Frontend IP>:3000
Prompt retrieval uses the Langfuse Python SDK's get_prompt. I also added a fallback to local files in case Langfuse is down.

Conclusion

I built a self-hosted Langfuse environment on two EC2 instances with Docker Compose, managed by GitHub Actions for team-wide operation.
By leveraging the t4g.small free tier, you get Prompt Management and Tracing at virtually no cost — which makes it a practical option for PoC environments.
As a next step, I'm planning to archive trace data collected by Langfuse and AgentCore Observability to S3, then analyze it with DuckDB and a static dashboard. By setting a short ClickHouse retention period and offloading older data to S3, I can keep EC2 disk costs low as well.
Thanks for reading!
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article