cervo
by ericovis
README.md
<img src="src/cervo/brand/mark-512-transparent.png" alt="" width="64">
# cervo
A demo app for managing static website hosting on a shared VPS. It's built as an MCP server ([FastMCP](https://gofastmcp.com), served over HTTP), so the hosting is managed by chatting with an AI: in development, [Claude Code](https://claude.com/claude-code) acts as the chat interface for exercising and testing the server's tools.
## Quick start
Prerequisite: [Docker](https://www.docker.com/) (with Compose).
```bash
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](http://localhost:1080) (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](CLAUDE.md#configuration)).
## 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](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html)
(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](#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):
```yaml
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:
```bash
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](https://vector.dev) 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:
```bash
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](CLAUDE.md) (also loaded by Claude Code as project context):
- [Configuration](CLAUDE.md#configuration) — all settings and their defaults
- [Layout](CLAUDE.md#layout) — where things live in the codebase
- [Testing with Claude Code](CLAUDE.md#testing-with-claude-code) — the development/testing workflow
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](design-system/DESIGN_SYSTEM.md).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues