multica deployment

This commit is contained in:
rblundon committed 2026-10-20 13:41:11 -05:00
1 parent 63c5f7eed7
commit 39e2b87941
26 files changed
+2746 -394

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# multica_docker
Deploys [Multica](https://multica.ai/docs/self-host-quickstart) (Docker Compose self-host) and a containerised
[Caddy](https://caddyserver.com) reverse proxy with automatic HTTPS.
Mirrors the official quickstart: clones the repo, creates `.env` from `.env.example` (first run only, like
`make selfhost`), manages specific keys in it, runs `docker-compose.selfhost.yml`, waits for `/readyz`, then
fronts it with Caddy using the documented single-origin or two-domain config.
## Requirements
- Docker Engine + Compose v2 on the target (the role checks; it does not install Docker)
- `ansible-galaxy collection install -r requirements.yml` (needs `community.docker >= 3.10`)
- DNS for your hostname(s) pointing at the target
## Quick start
```bash
cp group_vars/multica/vault.yml.example group_vars/multica/vault.yml # fill in, then:
ansible-vault encrypt group_vars/multica/vault.yml
# edit inventory.ini + group_vars/multica/vars.yml
ansible-playbook deploy_multica.yml --ask-vault-pass
```
## Key variables
| Variable | Default | Notes |
|---|---|---|
| `multica_layout` | `single` | `single` = one hostname; `split` = app + api hostnames |
| `multica_domain` | – | Hostname for `single` |
| `multica_app_domain` / `multica_api_domain` | – | Hostnames for `split` |
| `caddy_tls_mode` | `acme_http` | `acme_http`, `cloudflare_dns` (builds custom Caddy image), `internal` (Caddy CA, LAN only) |
| `caddy_http_port` / `caddy_https_port` | `80` / `443` | Change if the host already runs nginx etc. |
| `multica_version` / `multica_image_tag` | `main` / `latest` | Pin both to the same release tag for reproducible deploys |
| `multica_pull_latest` | `false` | `true` pulls images every run (needed to move `latest`) |
| `multica_backup_enabled` | `false` | `pg_dump` before changes to a running stack |
| `multica_disable_telemetry` | `false` | Sets `DO_NOT_TRACK=true` |
| `multica_extra_env` | `{}` | Extra `.env` keys, e.g. `ALLOW_SIGNUP: "false"` |
Secrets (vault): `vault_multica_jwt_secret`, `vault_multica_postgres_password`, `vault_multica_vcs_secret_key`,
`vault_cloudflare_api_token` (cloudflare_dns only), optional `vault_multica_resend_api_key`.
## Behaviour worth knowing
- **Idempotent**: `.env` is created once; only managed keys are rewritten. Compose recreates containers only when config changes.
- **Ports 3000/8080 stay bound to 127.0.0.1** (as upstream intends). Caddy uses `network_mode: host` to reach them.
- **Existing install?** The role overwrites `JWT_SECRET` (logs everyone out) and `POSTGRES_PASSWORD`. Postgres only reads its
password at first init, so for an existing database copy the *current* values from `.env` into the vault.
- **Upgrades**: `-e multica_backup_enabled=true -e multica_pull_latest=true` (or bump `multica_version`/`multica_image_tag`).
Migrations run automatically on backend start; the role waits for `/readyz`.
- **Tags**: `multica_preflight`, `multica`, `caddy`, `multica_verify`.
## After the play: connect a computer
The daemon runs on the machine with your AI coding tools, not the server:
```bash
multica setup self-host --server-url https://<domain> --app-url https://<domain> # single layout
```
First login code (no email configured): `docker compose -f docker-compose.selfhost.yml logs backend | grep "Verification code"`
@@ -0,0 +1,65 @@
---
# ───────────────────────── Multica ─────────────────────────
multica_install_dir: /opt/multica
multica_repo_url: https://github.com/multica-ai/multica.git
# Git ref for the compose file / .env.example. Pin to a release tag for reproducible deploys.
multica_version: main
multica_git_update: true
# Image tag for backend + frontend (written to MULTICA_IMAGE_TAG). Keep in step with multica_version when pinning.
multica_image_tag: latest
multica_compose_project: multica
# true = `docker compose pull` every run (needed to move a floating `latest` tag forward)
multica_pull_latest: false
# Sets DO_NOT_TRACK=true on the backend (disables first-party anonymous telemetry)
multica_disable_telemetry: false
# Secrets (supply via ansible-vault). Role fails fast if empty.
multica_jwt_secret: "{{ vault_multica_jwt_secret | default('') }}"
multica_postgres_password: "{{ vault_multica_postgres_password | default('') }}"
multica_vcs_secret_key: "{{ vault_multica_vcs_secret_key | default('') }}"
# Optional: enables emailed verification codes instead of reading them from backend logs
multica_resend_api_key: "{{ vault_multica_resend_api_key | default('') }}"
# Any extra .env keys, e.g. { ALLOW_SIGNUP: "false", GOOGLE_CLIENT_ID: "..." }
multica_extra_env: {}
# Postgres identity used by the optional backup task (match your .env if changed)
multica_postgres_user: multica
multica_postgres_db: multica
# Take a pg_dump before changing a running stack. Recommended before upgrades: -e multica_backup_enabled=true
multica_backup_enabled: false
multica_backup_dir: /var/backups/multica
# Hide secret-bearing task output
multica_no_log: true
# ───────────────────────── Routing ─────────────────────────
# single: one hostname (multica_domain)
# split : two hostnames (multica_app_domain + multica_api_domain)
multica_layout: single
multica_domain: ""
multica_app_domain: ""
multica_api_domain: ""
# ───────────────────────── Caddy ─────────────────────────
caddy_install_dir: /opt/caddy
caddy_container_name: caddy
# Caddy image tag. "2" tracks latest 2.x
caddy_version: "2"
# acme_http – automatic HTTPS via HTTP-01/TLS-ALPN-01 (needs public 80/443)
# cloudflare_dns – DNS-01 via Cloudflare; builds a custom Caddy image with the caddy-dns/cloudflare module
# internal – Caddy's internal CA (LAN only; clients must trust Caddy's root cert)
caddy_tls_mode: acme_http
caddy_acme_email: ""
caddy_cloudflare_api_token: "{{ vault_cloudflare_api_token | default('') }}"
# Rebuild the custom image on every run (cloudflare_dns only)
caddy_force_rebuild: false
# Caddy runs with host networking so it can reach Multica's 127.0.0.1-bound ports.
# Change these if 80/443 are already taken on the host (e.g. nginx).
caddy_http_port: 80
caddy_https_port: 443
# Post-deploy checks through the public hostname(s). Needs DNS to resolve from the target host.
multica_verify_public: true
@@ -0,0 +1,13 @@
---
- name: Validate Caddyfile
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
changed_when: false
listen: Reload caddy
- name: Reload Caddy
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
listen: Reload caddy
@@ -0,0 +1,14 @@
---
galaxy_info:
role_name: multica_docker
author: Ryan
company: WDW Labs
license: MIT
description: Deploy Multica (Docker Compose self-host) behind a containerised Caddy reverse proxy
min_ansible_version: "2.15"
platforms:
- name: Ubuntu
versions: [jammy, noble]
- name: Debian
versions: [bookworm]
dependencies: []
@@ -0,0 +1,51 @@
---
- name: Create Caddy directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ caddy_install_dir }}"
- "{{ caddy_install_dir }}/conf"
# Mount the conf *directory* (not the file): atomic template writes swap the inode,
# which a single-file bind mount would not see.
- name: Render Caddyfile
ansible.builtin.template:
src: Caddyfile.j2
dest: "{{ caddy_install_dir }}/conf/Caddyfile"
mode: "0644"
notify: Reload caddy
- name: Render Dockerfile for Cloudflare DNS module
ansible.builtin.template:
src: Dockerfile.caddy.j2
dest: "{{ caddy_install_dir }}/Dockerfile"
mode: "0644"
when: caddy_tls_mode == 'cloudflare_dns'
- name: Render Caddy environment file (Cloudflare token)
ansible.builtin.template:
src: caddy.env.j2
dest: "{{ caddy_install_dir }}/caddy.env"
mode: "0600"
when: caddy_tls_mode == 'cloudflare_dns'
no_log: "{{ multica_no_log }}"
- name: Render Caddy compose file
ansible.builtin.template:
src: docker-compose.caddy.yml.j2
dest: "{{ caddy_install_dir }}/docker-compose.yml"
mode: "0644"
- name: Start / update Caddy
community.docker.docker_compose_v2:
project_name: caddy
project_src: "{{ caddy_install_dir }}"
state: present
build: "{{ 'always' if caddy_force_rebuild | bool else 'policy' }}"
recreate: auto
register: _caddy_up
- name: Apply pending Caddy reloads now
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,16 @@
---
- name: Pre-flight checks
ansible.builtin.import_tasks: preflight.yml
tags: [multica_preflight]
- name: Deploy Multica
ansible.builtin.import_tasks: multica.yml
tags: [multica]
- name: Deploy Caddy
ansible.builtin.import_tasks: caddy.yml
tags: [caddy]
- name: Verify deployment
ansible.builtin.import_tasks: verify.yml
tags: [multica_verify]
@@ -0,0 +1,88 @@
---
- name: Create Multica install directory
ansible.builtin.file:
path: "{{ multica_install_dir }}"
state: directory
mode: "0755"
- name: Clone / update Multica repository
ansible.builtin.git:
repo: "{{ multica_repo_url }}"
dest: "{{ multica_install_dir }}"
version: "{{ multica_version }}"
update: "{{ multica_git_update }}"
force: false
register: _multica_git
# Optional: dump Postgres before touching a stack that is already running
- name: Back up Postgres before changes
when:
- multica_backup_enabled | bool
- _multica_compose_stat.stat.exists
- (_multica_ps.stdout | default('') | length) > 0
block:
- name: Create backup directory
ansible.builtin.file:
path: "{{ multica_backup_dir }}"
state: directory
mode: "0700"
- name: Gather date facts
ansible.builtin.setup:
gather_subset: [date_time]
- name: Dump database (redirect to file first so pg_dump's exit status counts)
ansible.builtin.shell: >-
docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }}
exec -T postgres pg_dump -U {{ multica_postgres_user }} {{ multica_postgres_db }}
> {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
&& gzip {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
args:
chdir: "{{ multica_install_dir }}"
changed_when: true
# `make selfhost` only creates .env when missing; we do the same, then manage specific keys.
- name: Create .env from .env.example (first run only)
ansible.builtin.copy:
src: "{{ multica_install_dir }}/.env.example"
dest: "{{ multica_install_dir }}/.env"
remote_src: true
force: false
mode: "0600"
- name: Set managed keys in .env
ansible.builtin.lineinfile:
path: "{{ multica_install_dir }}/.env"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
mode: "0600"
loop: "{{ _multica_env | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: "{{ multica_no_log }}"
# Compose re-reads .env and recreates containers whose config changed (equivalent of `up -d`).
- name: Start / update Multica stack
community.docker.docker_compose_v2:
project_name: "{{ multica_compose_project }}"
project_src: "{{ multica_install_dir }}"
files:
- "{{ _multica_compose_file }}"
state: present
pull: "{{ 'always' if multica_pull_latest | bool else 'missing' }}"
recreate: auto
register: _multica_up
- name: Wait for backend /readyz (db + migrations ok)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ _multica_backend_port }}/readyz"
return_content: true
status_code: 200
register: _multica_ready
until: >-
_multica_ready.status == 200 and
(_multica_ready.json.checks.db | default('')) == 'ok' and
(_multica_ready.json.checks.migrations | default('')) == 'ok'
retries: 30
delay: 5
changed_when: false
@@ -0,0 +1,88 @@
---
- name: Assert layout and hostnames
ansible.builtin.assert:
that:
- multica_layout in ['single', 'split']
- caddy_tls_mode in ['acme_http', 'cloudflare_dns', 'internal']
- multica_layout == 'split' or (multica_domain | length) > 0
- multica_layout == 'single' or ((multica_app_domain | length) > 0 and (multica_api_domain | length) > 0)
fail_msg: >-
Set multica_domain (layout=single) or multica_app_domain + multica_api_domain (layout=split),
and use a valid caddy_tls_mode.
- name: Assert required secrets are provided
ansible.builtin.assert:
that:
- (multica_jwt_secret | length) >= 32
- (multica_postgres_password | length) >= 16
- (multica_vcs_secret_key | length) >= 16
- caddy_tls_mode != 'cloudflare_dns' or (caddy_cloudflare_api_token | length) > 0
fail_msg: >-
Missing secrets. Define vault_multica_jwt_secret (>=32 chars), vault_multica_postgres_password (>=16),
vault_multica_vcs_secret_key (>=16) and, for cloudflare_dns, vault_cloudflare_api_token.
Generate with: openssl rand -hex 32
quiet: true
- name: Check Docker Compose v2 is available
ansible.builtin.command: docker compose version
register: _multica_compose_ver
changed_when: false
- name: Ensure git is installed
ansible.builtin.package:
name: git
state: present
# ── Port checks (skipped when our own stack already holds the ports) ──
- name: Look for an existing Multica deployment
ansible.builtin.stat:
path: "{{ multica_install_dir }}/{{ _multica_compose_file }}"
register: _multica_compose_stat
- name: List running Multica containers
ansible.builtin.command:
cmd: "docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }} ps -q"
chdir: "{{ multica_install_dir }}"
register: _multica_ps
changed_when: false
when: _multica_compose_stat.stat.exists
- name: Check for a running Caddy container
ansible.builtin.command: "docker ps -q --filter name=^{{ caddy_container_name }}$"
register: _caddy_ps
changed_when: false
- name: Ensure Multica ports are free on first deploy
when: not (_multica_compose_stat.stat.exists and ((_multica_ps.stdout | default('')) | length) > 0)
block:
- name: Wait for 3000/8080 to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop:
- "{{ _multica_frontend_port }}"
- "{{ _multica_backend_port }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: "Port 3000 or 8080 is already in use on the target host and Multica is not running. Free it before deploying."
- name: Ensure Caddy ports are free on first deploy
when: (_caddy_ps.stdout | length) == 0
block:
- name: Wait for Caddy HTTP/HTTPS ports to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop: "{{ [caddy_http_port, caddy_https_port] | map('int') | list }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: >-
Port {{ caddy_http_port }} or {{ caddy_https_port }} is already in use on the target host
(another reverse proxy such as nginx?). Set caddy_http_port / caddy_https_port to free ports,
or deploy Caddy on a different host.
@@ -0,0 +1,32 @@
---
- name: Verify through the public hostname(s)
when: multica_verify_public | bool
block:
- name: Check health endpoint via Caddy
ansible.builtin.uri:
url: "{{ _multica_public_health_url }}"
status_code: 200
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_pub_health
until: _multica_pub_health.status == 200
retries: 18
delay: 10
changed_when: false
- name: Fetch /api/config via the app hostname
ansible.builtin.uri:
url: "{{ _multica_app_url }}/api/config"
return_content: true
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_cfg
changed_when: false
- name: Assert daemons will be told the public URL, not localhost
ansible.builtin.assert:
that:
- _multica_cfg.json.daemon_server_url is defined
- "'localhost' not in _multica_cfg.json.daemon_server_url"
fail_msg: >-
daemon_server_url is missing or points at localhost. Check FRONTEND_ORIGIN / MULTICA_APP_URL in
{{ multica_install_dir }}/.env and recreate the stack.
success_msg: "daemon_server_url = {{ _multica_cfg.json.daemon_server_url }}"
@@ -0,0 +1,73 @@
# Managed by Ansible (multica_docker) - local changes will be overwritten
{
{% if caddy_acme_email | length > 0 %}
email {{ caddy_acme_email }}
{% endif %}
{% if caddy_http_port | int != 80 %}
http_port {{ caddy_http_port }}
{% endif %}
{% if caddy_https_port | int != 443 %}
https_port {{ caddy_https_port }}
{% endif %}
{% if caddy_tls_mode == 'cloudflare_dns' %}
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
{% endif %}
}
{% if multica_layout == 'single' %}
{{ multica_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
# CLI reachability probe: `multica setup` GETs <server-url>/health and needs a 200
handle /health {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }}
}
# Browser realtime WebSocket (the web image cannot proxy WS upgrades)
handle /ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Daemon long-connection WebSocket: daemons dial {server-url}/api/daemon/ws
handle /api/daemon/ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{% else %}
{{ multica_app_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
# Hand browser WebSockets straight to the backend
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{{ multica_api_domain }} {
{% if caddy_tls_mode == 'internal' %}
tls internal
{% endif %}
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
{% endif %}
@@ -0,0 +1,6 @@
# Managed by Ansible (multica_docker)
FROM caddy:{{ caddy_version }}-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:{{ caddy_version }}
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
@@ -0,0 +1 @@
CLOUDFLARE_API_TOKEN={{ caddy_cloudflare_api_token }}
@@ -0,0 +1,26 @@
# Managed by Ansible (multica_docker)
name: caddy
services:
caddy:
container_name: {{ caddy_container_name }}
{% if caddy_tls_mode == 'cloudflare_dns' %}
build:
context: .
dockerfile: Dockerfile
image: local/caddy-cloudflare:{{ caddy_version }}
env_file:
- caddy.env
{% else %}
image: caddy:{{ caddy_version }}
{% endif %}
# Host networking: Multica's ports are bound to 127.0.0.1 only
network_mode: host
restart: unless-stopped
volumes:
- {{ caddy_install_dir }}/conf:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
@@ -0,0 +1,32 @@
---
# Fixed by Multica's docker-compose.selfhost.yml (bound to 127.0.0.1)
_multica_frontend_port: 3000
_multica_backend_port: 8080
_multica_compose_file: docker-compose.selfhost.yml
_multica_app_host: "{{ multica_domain if multica_layout == 'single' else multica_app_domain }}"
_multica_api_host: "{{ multica_domain if multica_layout == 'single' else multica_api_domain }}"
_multica_port_suffix: "{{ '' if caddy_https_port | int == 443 else ':' ~ caddy_https_port }}"
_multica_app_url: "https://{{ _multica_app_host }}{{ _multica_port_suffix }}"
_multica_api_url: "https://{{ _multica_api_host }}{{ _multica_port_suffix }}"
_multica_env_core:
JWT_SECRET: "{{ multica_jwt_secret }}"
POSTGRES_PASSWORD: "{{ multica_postgres_password }}"
MULTICA_VCS_SECRET_KEY: "{{ multica_vcs_secret_key }}"
MULTICA_IMAGE_TAG: "{{ multica_image_tag }}"
FRONTEND_ORIGIN: "{{ _multica_app_url }}"
MULTICA_APP_URL: "{{ _multica_app_url }}"
# Split layout only: daemons/CLI talk to the API hostname
_multica_env_split: "{{ {'MULTICA_PUBLIC_URL': _multica_api_url} if multica_layout == 'split' else {} }}"
_multica_env_telemetry: "{{ {'DO_NOT_TRACK': 'true'} if multica_disable_telemetry | bool else {} }}"
_multica_env_resend: "{{ {'RESEND_API_KEY': multica_resend_api_key} if (multica_resend_api_key | length) > 0 else {} }}"
_multica_env: >-
{{ _multica_env_core
| combine(_multica_env_split, _multica_env_telemetry, _multica_env_resend, multica_extra_env) }}
# Health URL used by post-deploy verification
_multica_public_health_url: >-
{{ _multica_app_url ~ '/health' if multica_layout == 'single' else _multica_api_url ~ '/readyz' }}