Multica Docs

Self-Hosting Guide

Deploy Multica on your own infrastructure.

Architecture Overview

Multica has three components:

ComponentDescriptionTechnology
BackendREST API + WebSocket serverGo (single binary)
FrontendWeb applicationNext.js 16
DatabasePrimary data storePostgreSQL 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-host

This 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 selfhost

make 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:

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_KEY in .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=development and MULTICA_DEV_VERIFICATION_CODE=888888 in .env, then restart the backend. This fixed code is ignored when APP_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/multica

You also need at least one AI agent CLI:

  • Claude Code (claude on PATH)
  • Codex (codex on PATH)
  • Gemini CLI (gemini on PATH)
  • OpenCode (opencode on PATH)
  • OpenClaw (openclaw on PATH)
  • Hermes (hermes on PATH)

b) One-command setup

multica setup self-host

This automatically:

  1. Configures the CLI to connect to localhost
  2. Opens your browser for authentication
  3. Discovers your workspaces
  4. 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.com

Verify the daemon is running:

multica daemon status

Alternatively, 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

  1. Open your workspace at http://localhost:3000
  2. Navigate to Settings → Runtimes — you should see your machine listed
  3. Go to Settings → Agents and create a new agent
  4. 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:

  1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in sys_cron_executions for rollup_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;
  2. Once SUCCESS rows are arriving on schedule, unschedule the redundant pg_cron entry:

    SELECT cron.unschedule('rollup_task_usage_hourly')
      FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
  3. Leave the pg_cron extension itself installed unless you are sure no other workload depends on it. The bundled pgvector/pgvector:pg17 image does not ship pg_cron, so nothing in Multica's default install needs it; uninstalling pg_cron from 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 stop

Switching to Multica Cloud

If you've been self-hosting and want to switch your CLI to Multica Cloud:

multica setup

This 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 -d

Pin 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

VariableDescriptionExample
DATABASE_URLPostgreSQL connection string. Keep the password segment in sync with POSTGRES_PASSWORD.postgres://multica:<postgres-password>@localhost:5432/multica?sslmode=disable
POSTGRES_PASSWORDMust change from default. Password used by the bundled Postgres container. Keep it in sync with DATABASE_URL.openssl rand -hex 24
JWT_SECRETMust change from default. Secret key for signing JWT tokens. Use a long random string.openssl rand -hex 32
FRONTEND_ORIGINURL 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.

VariableDescription
RESEND_API_KEYYour Resend API key
RESEND_FROM_EMAILSender email address (default: noreply@multica.ai)

Google OAuth (Optional)

VariableDescription
GOOGLE_CLIENT_IDGoogle OAuth client ID
GOOGLE_CLIENT_SECRETGoogle OAuth client secret
GOOGLE_REDIRECT_URIOAuth 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)

VariableDescription
ALLOW_SIGNUPSet to false to disable new user signups on a private instance
ALLOWED_EMAIL_DOMAINSOptional comma-separated allowlist of email domains
ALLOWED_EMAILSOptional 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:

VariableDescription
S3_BUCKETBucket 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_REGIONAWS 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_KEYStatic credentials. When both are unset, the AWS SDK default credential chain is used
AWS_ENDPOINT_URLCustom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs
CLOUDFRONT_DOMAINCloudFront distribution domain — when set, public URLs use this host instead of the S3 host
CLOUDFRONT_KEY_PAIR_IDCloudFront key pair ID for signed URLs
CLOUDFRONT_PRIVATE_KEYCloudFront private key (PEM format)

Cookies

VariableDescription
COOKIE_DOMAINOptional 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

VariableDefaultDescription
PORT8080Backend server port
FRONTEND_PORT3000Frontend port
CORS_ALLOWED_ORIGINSValue of FRONTEND_ORIGINComma-separated list of allowed origins
LOG_LEVELinfoLog level: debug, info, warn, error

CLI / Daemon

These are configured on each user's machine, not on the server:

VariableDefaultDescription
MULTICA_SERVER_URLws://localhost:8080/wsWebSocket URL for daemon → server connection
MULTICA_APP_URLhttp://localhost:3000Frontend URL for CLI login flow
MULTICA_DAEMON_POLL_INTERVAL3sHow often the daemon polls for tasks
MULTICA_DAEMON_HEARTBEAT_INTERVAL15sHeartbeat frequency

Agent-specific overrides:

VariableDescription
MULTICA_CLAUDE_PATHCustom path to the claude binary
MULTICA_CLAUDE_MODELOverride the Claude model used
MULTICA_CODEX_PATHCustom path to the codex binary
MULTICA_CODEX_MODELOverride the Codex model used
MULTICA_OPENCODE_PATHCustom path to the opencode binary
MULTICA_OPENCODE_MODELOverride the OpenCode model used
MULTICA_OPENCLAW_PATHCustom path to the openclaw binary
MULTICA_OPENCLAW_MODELOverride the OpenClaw model used
MULTICA_HERMES_PATHCustom path to the hermes binary
MULTICA_HERMES_MODELOverride the Hermes model used
MULTICA_GEMINI_PATHCustom path to the gemini binary
MULTICA_GEMINI_MODELOverride the Gemini model used

Database Setup

Multica requires PostgreSQL 17 with the pgvector extension.

Using the Included Docker Compose

docker compose up -d postgres

This 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 up

Manual 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/server

For the frontend:

pnpm install
pnpm build

# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start

Reverse Proxy

In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.

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*) — bare handle /ws is an exact match, so future path variants under /ws/ fall through to the frontend block. The obvious shortcut handle /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 slug ws is reserved). Listing /ws and /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/ws

Health 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 /readyz

Use /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

  1. Pull the latest code or image
  2. Run migrations: ./server/bin/migrate up
  3. 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.