mavi

A content management system you run yourself: one Rust binary, one PostgreSQL, a React panel.

5
1
5
13
Rust
public

Mavi

A content management system you run yourself. The clean rewrite is one Rust
binary and one PostgreSQL database: site-scoped content, identity, media,
publishing, forms, mail, commerce, courses, automation and MCP.

The public panel is generated from the same clean canonical API as the Rust
runtime. The API and panel are separate images with one release/tag boundary:
the panel owns browser routing while the API owns site data, authorization and
runtime admission.
Mavi is a CMS, not a hosting business; organization, billing, metering and
shard lifecycle belong in mavi-operator.

MIT. Run it, change it, sell it.

  • Clean site boundary — every process is one fixed MAVI_SITE_ID; host
    routing and tenant lifecycle stay in the external control plane.
  • Canonical API/api/v1, /public/v1 and /mcp are described once and
    generate OpenAPI, TypeScript/Rust artifacts and MCP tool metadata.
  • Cursor-only lists — every public list uses opaque keyset cursors; page
    numbers and offsets are not part of the contract.
  • Scoped storage — every site-owned transaction sets PostgreSQL scope;
    composite keys and forced RLS protect the database boundary.
  • Observable runtime/healthz, /readyz and Prometheus /metrics are
    global operational endpoints, outside site admission.
  • Everything is written down — mutations are audited and background work
    uses transactional workflow intents relayed to Hatchet.

Quick start

curl -O https://raw.githubusercontent.com/productdevbook/mavi/main/docker-compose.yml
curl -O https://raw.githubusercontent.com/productdevbook/mavi/main/Caddyfile
{
  # The bundled compose file embeds this in DATABASE_URL; hex is URI-safe.
  echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
  echo "MAVI_KEYS=1:$(openssl rand -base64 32)"
  echo "MAVI_SITE_ID=$(uuidgen)"
  echo "HATCHET_POSTGRES_PASSWORD=$(openssl rand -hex 24)"
  echo "MAVI_HATCHET_BRIDGE_SECRET=$(openssl rand -hex 32)"
} > .env
docker compose up -d postgres hatchet
# Create a Hatchet token at http://localhost:8888, append it to .env, then:
echo "MAVI_HATCHET_TOKEN=replace-with-the-hatchet-token" >> .env
docker compose up -d

MAVI_SITE_ID is the durable identity of this self-hosted site’s rows. Keep it
stable across upgrades. MAVI_KEYS seals credentials and must also survive
restarts. The API starts with the fixed-site runtime and runs migrations before
opening its listener.

The API image exposes the canonical HTTP runtime. The panel image is mounted
at /admin, /learn and /shop by the self-host compose package. Setup is
also available directly at:

curl -sS -X POST http://localhost/api/v1/setup \
  -H 'content-type: application/json' \
  -d '{"site_name":"Example","email":"owner@example.com","name":"Owner","password":"change-this-password"}'

For upgrades, verified backups and recovery, follow the
self-host operations runbook. It keeps the
database, binary files, site ID and sealing key on one explicit lifecycle.

On a public machine set MAVI_DOMAIN=example.com, point DNS at the machine and
let Caddy terminate TLS. Any trusted reverse proxy may be used instead; pass
the Host header through and configure MAVI_TRUSTED_PROXY_CIDRS when it
supplies forwarded client signals.

The compose file runs a bundled Postgres. To use your own, set DATABASE_URL
and drop the postgres service — which is also what stops POSTGRES_PASSWORD
being asked for:

DATABASE_URL=postgres://user:password@your-host:5432/mavi docker compose up -d

When constructing DATABASE_URL yourself, percent-encode the username and
password if they contain reserved URI characters.

Images

API ghcr.io/productdevbook/mavi
Panel ghcr.io/productdevbook/mavi-panel
Hatchet bridge ghcr.io/productdevbook/mavi-hatchet-worker

All published images are built for linux/amd64 and linux/arm64.

Configuration

The clean API reads these at its binary boundary:

Variable Default Notes
DATABASE_URL PostgreSQL. Required.
MAVI_KEYS What seals a site’s secrets. 1:<thirty-two bytes, base64>, and a version and comma for each older key. Required; the process refuses to start without it, and refuses to start on one it cannot read rather than making one up.
MAVI_SITE_ID Fixed-site UUID. Required and stable for the lifetime of the installation.
MAVI_PROCESS_ROLE all all runs API and outbox relay; api and worker split the processes.
MAVI_FILES_DIR ./mavi-files / /data/files in the image Persistent site-scoped binary storage.
LISTEN 0.0.0.0:8080 HTTP listener address.
DATABASE_CONNECTIONS 10 PostgreSQL pool size.
MAVI_WORKER_ID generated default Site-worker identity for lease fencing.
MAVI_WORKER_LEASE_SECONDS worker default Queue lease duration.
MAVI_WORKER_POLL_MILLIS worker default Queue poll interval.
MAVI_TRUSTED_PROXY_CIDRS none Explicit proxy networks allowed to provide forwarded client IPs.
MAVI_MAIL_WEBHOOK_URL none Trusted HTTPS gateway used for outbound mail; absent means fail-closed delivery.
MAVI_MAIL_WEBHOOK_TOKEN none Optional bearer credential sent to the outbound mail gateway.
MAVI_MAIL_WEBHOOK_INGEST_TOKEN none Separate bearer credential required by the gateway for normalized bounce/complaint callbacks.
MAVI_MAIL_FROM Required when outbound mail is enabled; deployment default sender address.
MAVI_MAIL_FROM_NAME none Optional deployment default display name.
MAVI_MAIL_ALLOWED_SENDER_DOMAINS default sender domain Comma-separated domains allowed for site-configured sender identities.
MAVI_HATCHET_BRIDGE_URL bundled bridge Private Go Hatchet adapter URL; use a private external bridge when supplied by the tenant repository. The published Compose topology pulls the versioned mavi-hatchet-worker image; the development topology builds it locally.
MAVI_HATCHET_BRIDGE_SECRET Shared secret between Rust and the private bridge; never sent to the browser.
MAVI_HATCHET_TOKEN Hatchet API token used only by the Go bridge. Create it in the Hatchet dashboard after first start.
MAVI_HATCHET_TENANT_ID Hatchet tenant UUID encoded by the token.
MAVI_HATCHET_NAMESPACE mavi Hatchet namespace used by the bridge.
HATCHET_VERSION v0.71.14 Pinned bundled Hatchet image tag; override only with a tested compatible release.
MAVI_HATCHET_TLS_STRATEGY none for bundled Hatchet Hatchet gRPC transport: none, tls or mtls. The bundled compose server is plaintext; secured external Hatchet deployments must provide the SDK certificate settings.
MAVI_HATCHET_RATE_LIMIT_PER_MINUTE 60 Hatchet-side calls per minute from the bridge to the Rust executor.
MAVI_HATCHET_MAINTENANCE_CRON */5 * * * * Hatchet cron expression for the site-scoped maintenance tick.
MAVI_RUST_EXECUTOR_URL http://api:8080 Private Rust executor URL used by the Go bridge; in split mode use http://worker:8091.
MAVI_EXECUTOR_LISTEN 0.0.0.0:8091 Private listener used by MAVI_PROCESS_ROLE=worker.
RUST_LOG info

Split API and worker processes

The default all role is enough for a single container. For separate
deployment units, run the API and private Rust executor with the split
profile; the Go bridge remains the only process that talks to Hatchet:

MAVI_PROCESS_ROLE=api \
MAVI_RUST_EXECUTOR_URL=http://worker:8091 \
docker compose -f docker-compose.yml --profile split up -d

The API and worker share the same MAVI_SITE_ID, database, files volume and
sealing key. The worker’s port 8091 is internal-only; expose the API, not the
executor. The same topology is available from source with
docker-compose.dev.yml and --build.

Single-site and tenant boundary

Every Mavi process is one fixed site, selected by MAVI_SITE_ID. Host-to-site
routing, shard runtime, billing, placement and lifecycle are outside this
repository. A tenant/control-plane repository may provision and operate Mavi
through its container and versioned HTTP/OpenAPI boundary.

Site IDs, PostgreSQL RLS, composite foreign keys and site-bound encryption are
still enforced because they are data-isolation and security boundaries, not
tenant routing. See the clean workspace
server/README.md for the runtime and contract
details.

Compiled product features are plugins. Fresh setup enables only core and
writing; the Plugins screen enables or disables the other packages without
deleting their data. The active registry gates routes, OpenAPI, MCP,
navigation and workflow triggers at runtime.

Durable work is relayed through the private Go Hatchet adapter. Rust writes a
workflow intent in the same transaction as the domain mutation, and Hatchet
receives only small IDs and idempotency keys. Hatchet uses a PostgreSQL
database separate from Mavi’s database.

More than posts

A site is not always a blog. Content types in the panel say what this one
publishes: every site has posts and pages, and a site adds its own when what it
publishes has facts of its own — a course with a price and a level, a property
with rooms.

What a kind declares is what may be written: a field nothing declared is
refused rather than quietly kept, and a number that is not a number is refused
too. What was written under a field the kind no longer has is kept as it was,
and comes back if the field does.

Those fields are also what a front end asks about:
/api/posts?type=recipe&field=minutes&at_most=30 is every recipe under thirty
minutes, and a field nothing declared is refused rather than matching nothing.

Publishing

A design is rows on a draft: what a site looks like is written to src/ and
public/, built by whatever this machine is configured to build with, and put
live when somebody presses publish. Before that it can be built to an address
to look at — a preview, billed the same as a publish, that leaves what is live
alone.

A build that fails leaves what is live alone as well, because half a site is
worse than an old one.

A post given a state of scheduled and a moment goes out when that moment
arrives — the machine looks every minute — and whatever was waiting for it is
told.

Teaching

Courses hold modules and lessons; a lesson plays a video the site uploaded.
Somebody is put on a course for as long as the site says, and access that was
sold for ninety days stops opening the course after ninety days. What they
finished stays finished, and letting them back in is one call rather than an
enrolment written again.

A student is not a panel account: they sign in at the site’s own front, hold no
grants at all, and reach nothing in the panel.

Assistants

Every site answers the Model Context Protocol
at https://your-site/mcp.

An assistant is handed a key from API in the panel: it carries the grants
of whoever handed it over, expires by itself, and can be taken back. Nothing is
written with it that the record does not say was written by an assistant.

What it can do is what the panel can do, through the same grants — reading and
writing posts, filing them, uploading, reading what has come in through a form,
looking at orders, and working on the design. What it cannot do is publish:
that is a person’s, and there is no tool for it.

Connecting a front end

Every site publishes an llms.txt describing itself, and the API describes
itself at /openapi.json. The panel’s own TypeScript types are generated from
that description, and a test fails while they are stale — so a path this build
does not serve is a type error rather than a 404 somebody finds later.

Moving a site here

Nothing here moves a site from another machine: that was the mover, and it
belongs with the half that runs many machines rather than with the CMS. What
this does have is taking a copy — the languages,
what things are filed under, and everything written — from the panel’s own
settings, which is enough to carry a site somewhere else by hand.

Development

The clean runtime is the server/ workspace. The panel is generated from the
committed contract artifacts in server/mavi-http/contracts; a stale artifact
fails CI before either image is published.

cd server
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --doc
cargo run -p mavi-http --bin generate_contract -- fingerprint

The tests want a PostgreSQL, because a site is rows in one and a test of what a
site holds should be asked of one:

docker run -d --name mavi-test-db -p 127.0.0.1:5433:5432 \
  -e POSTGRES_PASSWORD=test -e POSTGRES_DB=mavi_test postgres:18-alpine
export TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:5433/mavi_test

Each shape is migrated once into a template and every test is handed a copy, so
nothing has to be run beforehand.

Or run the whole thing in containers, built from your checkout:

docker compose -f docker-compose.dev.yml up --build

Layout

server/         the clean API/runtime rewrite
  mavi-core/         typed IDs, scope, errors, grants and ports
  mavi-storage/      scoped PostgreSQL transactions and migrations
  mavi-contract/     canonical endpoint declarations and generators
  mavi-http/         request admission and API composition
  mavi-runtime/      fixed single-site runtime boundary
  mavi-application/   cross-domain use cases, plugins, Cedar and workflows
  integrations/       private Hatchet bridge adapters
  mavi-<domain>/     one application/service boundary per site feature
client/              generated-contract administrative panel and student area
wordpress-plugin/    the WordPress migration plugin (GPLv2+)

The panel is English and Turkish, via Lingui. Which
language the panel is read in has nothing to do with which languages the site
writes in.

The parts that need more than a paragraph

ports.md what this software asks a host for, and why integrations are ports
describing.md how the API describes itself, and what the panel is generated from
assistant.md what an assistant can do here, and why there is no list of tools
serving.md what a visitor sees, and why a build is a folder and going live is a row
media.md where uploaded pictures are kept
flows.md what a site does on its own when something happens
boards.md what a site works through in stages
commerce.md selling things
courses.md selling courses, and why a student is not a panel account
video.md putting a lesson’s video somewhere that is not this machine

License

MIT — see LICENSE. The WordPress plugin is GPL-2.0-or-later, as
WordPress plugins must be.

Every dependency has been checked against that, and what was deliberately not
borrowed is written down too: LICENSES.md.

What this is not

It is not a hosting business, and the parts that make one are not here:
metering what each site uses, billing for it, making and unmaking sites on a
machine, moving one between machines, a console that reads across all of them.
That is somebody’s product, and this is the CMS such a product would run.

The seam it is built on is real rather than a promise, and it points inward:
mavi-core::ports names what this asks a host for — Clock, FileStore,
Mailer, Payments, Builds, Seals — and each arrives at construction. A
host satisfies them; it does not mount anything of its own through them, and
nothing here reads the environment to find one for itself.

It is also not a plugin marketplace. What a site can be made to talk to — its
mail server, its payment provider — is a decision in the software rather than
a form somebody fills in, and adding a third is a change to this repository.

v0.3.3[beta]