Self-Hosting Guide
Deploy lehuo Agents on your own infrastructure.
Architecture Overview
lehuo Agents has three components:
| Component | Description | Technology |
|---|---|---|
| Backend | REST API + WebSocket server | Go (single binary) |
| Frontend | Web application | Next.js 16 |
| Database | Primary data store | PostgreSQL 17 with pgvector |
Each user who wants to run AI agents locally also installs the lehuo CLI and runs the agent daemon on their own machine.
Prerequisites
- Docker and Docker Compose
Quick Install
Two commands to set up everything:
# Install CLI + provision self-host server
curl -fsSL https://agents.lehuo.app/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
lehuo setup self-hostThis installs the CLI, checks out the latest self-host assets, pulls the official lehuo Agents images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure RESEND_API_KEY in .env for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See Step 2 — Log In for details.
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, download it from agents.lehuo.app/download.
For a step-by-step setup, see below.
Step-by-Step Setup
Step 1 — Start the Server
# Download the self-host bundle from https://agents.lehuo.app/download
curl -fsSL https://agents.lehuo.app/download/selfhost.tar.gz -o lehuo-selfhost.tar.gz
tar -xzf lehuo-selfhost.tar.gz
cd lehuo
make selfhostmake selfhost automatically creates .env, generates a random JWT_SECRET, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run make selfhost-build.
If the selected GHCR tag has not been published yet, make selfhost now tells you to fall back to make selfhost-build.
make selfhost-build uses local lehuo-backend:dev / lehuo-web:dev tags, so it does not overwrite the pulled :latest images.
Once ready:
- Frontend: http://localhost:3000
- Backend API: http://localhost:8080
If you prefer running the Docker Compose steps manually: cp .env.example .env, edit JWT_SECRET, then docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d.
Step 2 — Log In
Open http://localhost:3000. The Docker self-host stack defaults to APP_ENV=production (set in docker-compose.selfhost.yml), and there is no fixed verification code by default. Pick one of the following to log in:
- Recommended (production): configure
RESEND_API_KEYin.env, then restart the backend. Real verification codes will be sent to the email address you enter. See Configuration below. - Without email configured: the verification code is generated server-side and printed to the backend container logs (look for
[DEV] Verification code for ...:). Useful for one-off testing on a single machine. - Deterministic local/private testing: set
APP_ENV=developmentandLEHUO_DEV_VERIFICATION_CODE=888888in.env, then restart the backend. This fixed code is ignored whenAPP_ENV=production.
Changes to ALLOW_SIGNUP and GOOGLE_CLIENT_ID also take effect after restarting the backend / compose stack. The web UI reads both from /api/config at runtime, so no web rebuild is needed.
Warning: do not set LEHUO_DEV_VERIFICATION_CODE on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
a) Install the CLI and an AI agent
Download and install the lehuo Agents CLI from agents.lehuo.app/download, or use the install script:
curl -fsSL https://agents.lehuo.app/install.sh | bashYou also need at least one AI agent CLI:
- Claude Code (
claudeon PATH) - Codex (
codexon PATH) - Gemini CLI (
geminion PATH) - OpenCode (
opencodeon PATH) - OpenClaw (
openclawon PATH) - Hermes (
hermeson PATH)
b) One-command setup
lehuo setup self-hostThis automatically:
- Configures the CLI to connect to
localhost - Opens your browser for authentication
- Discovers your workspaces
- Starts the daemon in the background
For on-premise deployments with custom domains:
lehuo setup self-host --server-url https://api.example.com --app-url https://app.example.comVerify the daemon is running:
lehuo daemon statusAlternatively, configure step by step: lehuo config set server_url http://localhost:8080 && lehuo config set app_url http://localhost:3000 && lehuo login && lehuo daemon start
Step 4 — Verify & Start Using
- Open your workspace at http://localhost:3000
- Navigate to Settings → Runtimes — you should see your machine listed
- Go to Settings → Agents and create a new agent
- Create an issue and assign it to your agent
Usage Dashboard Rollup (Required)
Starting with v0.3.5, the Usage / Runtime dashboards read from a derived task_usage_hourly table populated by rollup_task_usage_hourly(). The bundled pgvector/pgvector:pg17 image does not include pg_cron, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though task_usage is populated.
Pick one supported path before relying on the Usage / Runtime dashboard:
- External cron / systemd-timer / Kubernetes
CronJob: scheduleSELECT rollup_task_usage_hourly()every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe. - Postgres with
pg_cron: swap the bundled Postgres image for one that shipspg_cron, setshared_preload_libraries=pg_cron, thenSELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')once. - Backfill historical data: required on the
v0.3.4 → v0.3.5+upgrade path when the database already hastask_usagerows — migration103is fail-closed and will abortmigrate upwithrefusing to drop legacy daily rollups: ...until the hourly table is seeded. Run./backfill_task_usage_hourly --sleep-between-slices=2sinside the backend container, then re-run the upgrade and configure one of the schedules above.
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup.
Stopping Services
# Stop Docker Compose services
make selfhost-stop
# Stop the local daemon
lehuo daemon stopSwitching to lehuo Agents Cloud
If you've been self-hosting and want to switch your CLI to lehuo Agents Cloud:
lehuo setupThis reconfigures the CLI for agents.lehuo.app, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
Your local Docker services are unaffected. Stop them separately if you no longer need them.
Upgrading
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -dPin LEHUO_IMAGE_TAG in .env to an exact version like v0.2.4 if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to make selfhost-build or docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build.
Configuration
All configuration is done via environment variables. Copy .env.example as a starting point.
Required Variables
| Variable | Description | Example |
|---|---|---|
DATABASE_URL | PostgreSQL connection string | postgres://lehuo:lehuo@localhost:5432/lehuo?sslmode=disable |
JWT_SECRET | Must change from default. Secret key for signing JWT tokens. Use a long random string. | openssl rand -hex 32 |
FRONTEND_ORIGIN | URL where the frontend is served (used for CORS) | https://app.example.com |
Email (Required for Authentication)
lehuo Agents uses email-based magic link authentication via Resend.
| Variable | Description |
|---|---|
RESEND_API_KEY | Your Resend API key |
RESEND_FROM_EMAIL | Sender email address (default: noreply@agents.lehuo.app) |
Google OAuth (Optional)
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID | Google OAuth client ID |
GOOGLE_CLIENT_SECRET | Google OAuth client secret |
GOOGLE_REDIRECT_URI | OAuth callback URL (e.g. https://app.example.com/auth/callback) |
Changes take effect after restarting the backend / compose stack. The web UI reads GOOGLE_CLIENT_ID from /api/config at runtime, so no web rebuild is needed.
Signup Controls (Optional)
| Variable | Description |
|---|---|
ALLOW_SIGNUP | Set to false to disable new user signups on a private instance |
ALLOWED_EMAIL_DOMAINS | Optional comma-separated allowlist of email domains |
ALLOWED_EMAILS | Optional comma-separated allowlist of exact email addresses |
Changes take effect after restarting the backend / compose stack. The web UI reads ALLOW_SIGNUP from /api/config at runtime, so no web rebuild is needed.
File Storage (Optional)
For file uploads and attachments, configure S3 and (optionally) CloudFront:
| Variable | Description |
|---|---|
S3_BUCKET | Bucket name only (e.g. my-bucket). Do not include the .s3.<region>.amazonaws.com suffix — the server constructs the public URL from S3_BUCKET + S3_REGION |
S3_REGION | AWS region (default: us-west-2). Must match the bucket's actual region — used for both SDK signing and public URLs |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY | Static credentials. When both are unset, the AWS SDK default credential chain is used |
AWS_ENDPOINT_URL | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
CLOUDFRONT_DOMAIN | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
CLOUDFRONT_KEY_PAIR_ID | CloudFront key pair ID for signed URLs |
CLOUDFRONT_PRIVATE_KEY | CloudFront private key (PEM format) |
Cookies
| Variable | Description |
|---|---|
COOKIE_DOMAIN | Optional Domain attribute for session + CloudFront cookies. Leave empty for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. .example.com). Do not use an IP literal — RFC 6265 forbids IP addresses in the cookie Domain attribute and browsers will drop such Set-Cookie headers. |
The Secure flag on session cookies is derived automatically from the scheme of FRONTEND_ORIGIN: HTTPS origins get Secure cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
Server
| Variable | Default | Description |
|---|---|---|
PORT | 8080 | Backend server port |
FRONTEND_PORT | 3000 | Frontend port |
CORS_ALLOWED_ORIGINS | Value of FRONTEND_ORIGIN | Comma-separated list of allowed origins |
LOG_LEVEL | info | Log level: debug, info, warn, error |
CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|---|---|---|
LEHUO_SERVER_URL | ws://localhost:8080/ws | WebSocket URL for daemon → server connection |
LEHUO_APP_URL | http://localhost:3000 | Frontend URL for CLI login flow |
LEHUO_DAEMON_POLL_INTERVAL | 3s | How often the daemon polls for tasks |
LEHUO_DAEMON_HEARTBEAT_INTERVAL | 15s | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|---|---|
LEHUO_CLAUDE_PATH | Custom path to the claude binary |
LEHUO_CLAUDE_MODEL | Override the Claude model used |
LEHUO_CODEX_PATH | Custom path to the codex binary |
LEHUO_CODEX_MODEL | Override the Codex model used |
LEHUO_OPENCODE_PATH | Custom path to the opencode binary |
LEHUO_OPENCODE_MODEL | Override the OpenCode model used |
LEHUO_OPENCLAW_PATH | Custom path to the openclaw binary |
LEHUO_OPENCLAW_MODEL | Override the OpenClaw model used |
LEHUO_HERMES_PATH | Custom path to the hermes binary |
LEHUO_HERMES_MODEL | Override the Hermes model used |
LEHUO_GEMINI_PATH | Custom path to the gemini binary |
LEHUO_GEMINI_MODEL | Override the Gemini model used |
Database Setup
lehuo Agents requires PostgreSQL 17 with the pgvector extension.
Using the Included Docker Compose
docker compose up -d postgresThis starts a pgvector/pgvector:pg17 container on port 5432 with default credentials (lehuo/lehuo).
Using Your Own PostgreSQL
Ensure the pgvector extension is available:
CREATE EXTENSION IF NOT EXISTS vector;Running Migrations
Migrations must be run before starting the server:
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate upManual Setup (Without Docker Compose)
If you prefer to build and run services manually:
Prerequisites: Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/serverFor the frontend:
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm startReverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
Caddy (Recommended)
Single-domain layout — frontend and backend served on the same hostname (this is what docker-compose.selfhost.yml defaults to):
lehuo.example.com {
# WebSocket route — must come before the catch-all
@lehuo_ws path /ws /ws/*
handle @lehuo_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}Separate-domain layout — frontend and backend on different hostnames:
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@lehuo_ws path /ws /ws/*
handle @lehuo_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}Two non-obvious bits inside the /ws block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
path /ws /ws/*(not/ws*) — barehandle /wsis an exact match, so future path variants under/ws/fall through to the frontend block. The obvious shortcuthandle /ws*overcorrects in the other direction: Caddy's*is a glob without a path-segment boundary, so it would also catch unrelated paths like/ws-foo, which is a legitimate workspace URL (only the exact slugwsis reserved). Listing/wsand/ws/*explicitly covers both real cases without overreach.flush_interval -1— disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
Nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}When using separate domains for frontend and backend, set these environment variables accordingly:
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/wsHealth Check
The backend exposes public health endpoints:
GET /health
→ {"status":"ok"}
GET /readyz
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
GET /healthz
→ same response as /readyzUse /health for basic liveness / reachability checks. Use /readyz for
dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. /healthz is
kept as an alias for operator familiarity.
Upgrading
- Pull the latest code or image
- Run migrations:
./server/bin/migrate up - Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.