Skip to main content
Glama

cervo

A demo app for managing static website hosting on a shared VPS. It's built as an MCP server (FastMCP, served over HTTP), so the hosting is managed by chatting with an AI: in development, Claude Code acts as the chat interface for exercising and testing the server's tools.

Quick start

Prerequisite: Docker (with Compose).

bin/dev    # docker compose up -d: app, worker, caddy, mail

Caddy fronts everything on port 80: the MCP server at http://localhost/mcp, and each created site at http://{slug}.localhost. It boots with no config file — on a fresh checkout it serves nothing for a few seconds, until the worker writes its config over the admin API.

Then open Claude Code in this repo — the server is pre-registered in .mcp.json, so its tools become available directly in the chat. Connecting runs cervo's OAuth sign-in in the browser: enter an email and type back the code that lands in mailcatcher (no real mail is sent in development). Start the stack before opening the Claude Code session (connections are made at startup), and run /mcp to reconnect whenever you change the MCP server code (docker compose restart app) — Claude Code doesn't reconnect automatically.

Development works with zero configuration; settings can be overridden via a .env file (see the configuration table).

Related MCP server: StaticX MCP Server

Connecting from claude.ai

Once deployed (see below), add cervo as a custom connector:

  1. On claude.ai, open Settings → Connectors and click Add custom connector.

  2. Name it (e.g. cervo) and set the remote MCP server URL to https://{your-domain}/mcp.

  3. Under advanced settings, keep Use Anthropic's hosted client metadata selected — the recommended option; cervo advertises CIMD support so it works out of the box (plain dynamic client registration works too) — and set authentication to always required.

  4. Click Connect: your browser opens cervo's sign-in page. Enter your email and type back the six-digit code from your inbox. The connection then stays signed in on its own.

The same four steps, written for users and illustrated, are on the deployed instance's /docs page.

Deploying

Production is the same image on a VPS, run by rootful podman quadlets (podman ≥ 4.4 — Debian 13). Deploys run from your machine: bin/deploy builds the image for linux/amd64, pushes it to Docker Hub tagged with the git sha, and runs the ansible playbook in deploy/, which writes the quadlet units and environment, pulls the image, and restarts the services — caddy first (it resumes what it was serving), then the worker, whose startup sync rewrites caddy's whole config from the database, so config changes always land (see Reconciling caddy). Secrets never live in the repo: the Docker Hub token and SMTP password are read from 1Password by the op CLI at deploy time.

One-time setup:

  1. DNS: an A record for the apex and a wildcard A * record, both to the server's IP — sites live at https://{slug}.{domain}.

  2. 1Password items (vault cervo): docker-hub with a token field, and smtp with a password field (the op:// paths are inventory vars, so any layout works).

  3. Create deploy/inventory.yml (gitignored — every deploy setting lives here, nothing is hardcoded):

    cervo:
      hosts:
        cervo-vps:
          ansible_host: your.server.ip
          ansible_port: 22
          ansible_user: debian
      vars:
        image_repo: docker.io/you/cervo
        dockerhub_user: you
        op_dockerhub_token: op://cervo/docker-hub/token
        domain: example.com
        acme_email: you@example.com
        email_host: smtp.example.com # port-587 STARTTLS provider
        email_port: 587
        email_user: your-smtp-user
        email_from: cervo@example.com # a sender your provider verified
        op_smtp_password: op://cervo/smtp/password
        # Optional — with it, errors, request/job telemetry, and container
        # logs flow to Honeybadger, and every deploy is reported there:
        honeybadger_api_key: your-project-api-key
  4. On your machine: ansible and op installed, op signed in, docker logged out is fine — bin/deploy logs in itself.

Then every deploy — first and later alike — is:

bin/deploy

With SCHEME=https (set by the playbook) caddy obtains a certificate per hostname from Let's Encrypt and redirects plain http; the first request to a fresh site waits a few seconds while its certificate is issued. Certificates persist in the caddy-data volume, so redeploys never re-issue them.

With honeybadger_api_key set, the playbook also installs vector to forward the services' journald output to Honeybadger Insights, and reports each deploy (with its exact commit sha) to Honeybadger — errors, request and job telemetry, logs, and deploys all land in one project.

Reconciling caddy

There is no Caddyfile anywhere. Caddy runs caddy run --resume, so it comes back holding the config it last saved (in the caddy-config volume), and everything it serves — cervo's own reverse proxy and every hosted site — is written into its running config by the worker over the admin API. The database is the source of truth; caddy's config is a cache of it.

That needs no operator either way: the worker rewrites caddy's whole config from the database at startup and every five minutes, so a caddy that lost its autosave is serving again by the next sync — right away when the worker restarted with it, within five minutes when caddy bounced alone — and one that resumed a stale config has it replaced wholesale. A sync that finds nothing listening spends its attempts and fails; the worker sees that on its next poll and asks for another, so a slow caddy costs seconds, not the whole interval.

To force a reconciliation now, run the cervo-sync command inside the worker container:

podman exec worker uv run cervo-sync           # on the VPS
docker compose exec worker uv run cervo-sync   # in development

It only queues the job (deduped — a sync already waiting is reused and said so); the worker runs it within a poll or two.

The same property sets the deploy order — caddy, then worker, then app: the restart costs caddy a second or two of refused connections and no unrouted site (--resume), and the worker's startup sync brings its config back in step right after.

Documentation

Detailed information lives in CLAUDE.md (also loaded by Claude Code as project context):

The look of every cervo-served page — tokens, components, and the brand assets in src/cervo/brand/ — is written down in design-system/DESIGN_SYSTEM.md.

Related MCP Connectors

Related MCP Servers