Self-Hosting Guide
Deploy Multica on your own infrastructure.
Architecture Overview
Multica 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 multica 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://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
multica setup self-hostThis installs the CLI, checks out the latest self-host assets, pulls the official Multica 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, install it with Homebrew: brew install multica-ai/tap/multica.
For a step-by-step setup, see below.
Step-by-Step Setup
Step 1 — Start the Server
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhostmake selfhost automatically creates .env, generates a random JWT_SECRET and Postgres password, 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 multica-backend:dev / multica-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, POSTGRES_PASSWORD, and the password segment in DATABASE_URL, 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=developmentandMULTICA_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 MULTICA_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
brew install multica-ai/tap/multicaYou 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
multica 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:
multica setup self-host --server-url https://api.example.com --app-url https://app.example.comVerify the daemon is running:
multica daemon statusAlternatively, configure step by step: multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica 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
The Usage / Runtime dashboards read from a derived task_usage_hourly table populated by rollup_task_usage_hourly(). As of MUL-2957 the backend runs this rollup in-process on every replica via a DB-backed scheduler (sys_cron_executions). A fresh self-host install needs no operator action — the bundled pgvector/pgvector:pg17 image works as-is, and you do not need to swap it for an image that ships pg_cron, register an external cron job, run a systemd timer, or schedule a Kubernetes CronJob.
Multiple backend replicas are safe: every replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key (job_name, scope_kind, scope_id, plan_time) means only one wins each plan. Inspect the audit table to confirm steady-state operation:
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;Upgrading from v0.3.4 to v0.3.5+? As of MUL-2957 the migrate up command runs an idempotent monthly-slice backfill automatically right before applying migration 103, so the upgrade completes in a single invocation — no operator step required. If you are on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run backfill_task_usage_hourly against the same database and re-run the upgrade. Full recovery flow lives in SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup.
Compatibility paths (existing deployments only)
External schedulers — pg_cron registered on the database, an external cron job, a systemd timer, or a Kubernetes CronJob — that call SELECT rollup_task_usage_hourly() directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
If you already have a pg_cron job in production and want to retire it, the safe sequence is:
-
Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in
sys_cron_executionsforrollup_task_usage_hourly:SELECT plan_time, status, runner_id, finished_at FROM sys_cron_executions WHERE job_name = 'rollup_task_usage_hourly' AND status = 'SUCCESS' ORDER BY plan_time DESC LIMIT 5; -
Once SUCCESS rows are arriving on schedule, unschedule the redundant
pg_cronentry:SELECT cron.unschedule('rollup_task_usage_hourly') FROM cron.job WHERE jobname = 'rollup_task_usage_hourly'; -
Leave the
pg_cronextension itself installed unless you are sure no other workload depends on it. The bundledpgvector/pgvector:pg17image does not shippg_cron, so nothing in Multica's default install needs it; uninstallingpg_cronfrom a custom image that other workloads still use is a separate decision.
External cron / systemd timer / Kubernetes CronJob setups that call SELECT rollup_task_usage_hourly() directly can be retired the same way — once sys_cron_executions shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the migration auto-hook) lives in SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup.
Stopping Services
# Stop Docker Compose services
make selfhost-stop
# Stop the local daemon
multica daemon stopSwitching to Multica Cloud
If you've been self-hosting and want to switch your CLI to Multica Cloud:
multica setupThis reconfigures the CLI for multica.ai, 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 MULTICA_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. Keep the password segment in sync with POSTGRES_PASSWORD. | postgres://multica:<postgres-password>@localhost:5432/multica?sslmode=disable |
POSTGRES_PASSWORD | Must change from default. Password used by the bundled Postgres container. Keep it in sync with DATABASE_URL. | openssl rand -hex 24 |
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)
Multica 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@multica.ai) |
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 |
|---|---|---|
MULTICA_SERVER_URL | ws://localhost:8080/ws | WebSocket URL for daemon → server connection |
MULTICA_APP_URL | http://localhost:3000 | Frontend URL for CLI login flow |
MULTICA_DAEMON_POLL_INTERVAL | 3s | How often the daemon polls for tasks |
MULTICA_DAEMON_HEARTBEAT_INTERVAL | 15s | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|---|---|
MULTICA_CLAUDE_PATH | Custom path to the claude binary |
MULTICA_CLAUDE_MODEL | Override the Claude model used |
MULTICA_CODEX_PATH | Custom path to the codex binary |
MULTICA_CODEX_MODEL | Override the Codex model used |
MULTICA_OPENCODE_PATH | Custom path to the opencode binary |
MULTICA_OPENCODE_MODEL | Override the OpenCode model used |
MULTICA_OPENCLAW_PATH | Custom path to the openclaw binary |
MULTICA_OPENCLAW_MODEL | Override the OpenClaw model used |
MULTICA_HERMES_PATH | Custom path to the hermes binary |
MULTICA_HERMES_MODEL | Override the Hermes model used |
MULTICA_GEMINI_PATH | Custom path to the gemini binary |
MULTICA_GEMINI_MODEL | Override the Gemini model used |
Database Setup
Multica 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 (multica/multica).
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):
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_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 {
@multica_ws path /ws /ws/*
handle @multica_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.