Self-hosted CMS with a Rust backend and a Tiptap editor. Multilingual content, S3/R2 media, Postgres/MySQL/SQLite.
A self-hosted CMS with a Rust backend and a Tiptap editor. Bring your own
database — Postgres, MySQL or SQLite — and your own object storage, or none.
DATABASE_URL away from Postgres, MySQL or SQLite.llms.txt describing its own API, with a working Astro connection in it,curl -O https://raw.githubusercontent.com/productdevbook/mavicms/main/docker-compose.yml
docker compose up -d
Open http://localhost:8081 and the setup wizard takes it from there: pick a
language, point it at a database, name the site, create the first account.
The compose file runs a bundled Postgres. To use your own database instead, set
DATABASE_URL and drop the postgres service:
DATABASE_URL=postgres://user:password@your-host:5432/mavicms docker compose up -d
SQLite needs no server at all and is a reasonable choice for a small site:
DATABASE_URL=sqlite:///data/mavicms.db?mode=rwc
| Backend | ghcr.io/productdevbook/mavicms-backend |
| Frontend | ghcr.io/productdevbook/mavicms-frontend |
Both are built for linux/amd64 and linux/arm64.
The backend reads these; everything else is configured from the admin panel.
| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
— | Postgres, MySQL or SQLite. Set at first run through the wizard if absent. |
MAVICMS_DATA_DIR |
/data |
Uploaded media and the encryption key. Must be a persistent volume. |
HOST / PORT |
0.0.0.0 / 8080 |
|
RUST_LOG |
info |
The frontend is static files behind nginx, which proxies /api, /uploads and
/scalar to the backend.
A single instance can host hundreds of sites, each answering on its own
address. Add one from Sites in the admin panel, or:
curl -X POST https://your-server/api/sites -b cookies.txt \
-H 'content-type: application/json' \
-d '{"host": "example.com"}'
Point the address at the server and open it — the new site runs the setup
wizard like any fresh install.
Hosting more than one site needs Postgres. Every site gets a schema of its
own — its tables, its accounts, its own migration history — rather than
sharing tables keyed by a site id. Nothing has to remember to filter by tenant,
and a site’s connection cannot see another site’s tables: the search path holds
only its own schema, so a missing table is an error rather than a quiet read of
someone else’s. Uploads and the encryption key sit beside it in
MAVICMS_DATA_DIR/sites/<name>/. A site that outgrows the shared server can be
given a database_url of its own, with nothing else changing.
Memory is bounded by how many sites are busy, not how many exist. Sites open
on demand, at most 32 stay open, one that has served no request for ten minutes
is closed, and each holds at most two connections. Four hundred sites measure
at 98 MB resident — unchanged from two hundred — and never hold more than 67
Postgres connections, however the traffic is spread. Empty sites cost about
400 KB each in the database.
Three kinds of account, deliberately separate rather than one with flags:
| Signs in at | Sees | |
|---|---|---|
| Whoever runs the server | its own address, Sites | every site on the machine |
| An agency | /console |
its own sites, and no others |
| The people who write a site | that site’s /dashboard |
that site |
An agency opens an account at /console/register, adds sites up to its limit,
and opens any of them from the console. That last step does not make the
agency’s password work on the site: the console mints a token good for one use
and two minutes, and the site trades it for a session of its own. So the
account an agency writes as on a site has no password at all — closing the
console account closes every site with it, rather than leaving a working login
behind on fifty of them.
An administrator of a hosted site administers that site and nothing else: they
cannot list the other sites on the machine, add sites, reach the console, or
reach the database wizard — which restarts the process, and so would take every
other site down with it.
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 training company adds Course, with a price, a length and a level. A
letting agent adds Property, with rooms and a floor. What you get is not a
new half-built content system — it is a post: the same editor, the same
languages and translations, the same SEO, scheduling and digest, with the
extra fields beside them.
The fields are the ones a form already has — text, long text, number, date,
yes-or-no, one-of-a-list, email, phone, link — because describing what
something is made of is the same question a form asks.
A front end fetches ?kind=course and lays the fields out. Nothing in the CMS
knows what a course is, which is why it works for the next sector too.
A field marked as needed has to be filled in before the thing can be published.
A draft may be half-written, which is what a draft is.
Pages are built somewhere else, out of the API. What that takes is a page of
documentation, and half of any page of documentation does not apply to your
installation — so each site writes its own:
curl https://your-site/api/llms.txt
That address answers to anybody and holds the whole of it: this site’s
addresses, how to get a read-only token, what a post looks like, and a working
Astro connection to paste in — a content loader, the collection, the config and
the page. Fetched with an account it also lists this site’s languages and the
forms it is taking answers on. API in the panel has a button that copies it,
which is the short way to hand a site to an assistant.
Two things make a rebuild cheap:
digest — a fingerprint of what it renders to. ItETag. Send it back as If-None-Match and an304 with no body.The Astro connection in llms.txt uses both, and Astro 7.2’s
experimental.incrementalBuild with cacheKey on top of them. A build after a
change to one post fetches one post and regenerates one page.
Ask for status=published unless you mean not to. Without it, every draft on
the site is in the answer.
A post given a status of scheduled and a date is published when that date
arrives — the server checks every minute — and the site is asked to build, so
a post written on Friday for Monday morning is on the site on Monday morning.
A build does not have to do anything for this; it will be asked to run.
Every site answers the Model Context Protocol
at https://your-site/api/mcp, and so does an agency console at the server’s
own address. Which of the two you get is decided by the address you reach and
the token you send, the same way everything else here is.
Paste the address and nothing else:
https://your-site/api/mcp
Where it goes:
| Claude | Settings → Connectors → Add custom connector. One connector covers Claude on the web, on the desktop and on a phone. |
| ChatGPT | Settings → Connectors → Advanced settings → developer mode, then create a custom connector. Needs a paid plan. |
| Codex | codex mcp add mavicms --url https://your-site/api/mcp — it notices the sign-in and opens one. Then /mcp. |
| Claude Code | claude mcp add --transport http mavicms https://your-site/api/mcp, then /mcp to sign in. |
| Cursor | Settings → Tools & MCP → New MCP Server, or {"mcpServers":{"mavicms":{"url":"…"}}} in ~/.cursor/mcp.json. |
Claude and ChatGPT connect from their own servers, not from the machine you
are sitting at. A site on localhost, or behind a VPN, is not one they can
reach — it has to be on a public address over https. The ones that run on your
own machine, Codex and Claude Code and Cursor, have no such trouble. This is
the first thing to check when a connector will not attach.
The site is its own authorization server. Whoever is connecting is sent to
this site to sign in, asked plainly whether to allow it, and the program is
handed a credential they never see and cannot paste anywhere. API in the
panel lists what is connected, and disconnecting one stops it immediately.
In a terminal, where a header is easier than a browser:
claude mcp add --transport http mavicms https://your-site/api/mcp \
--header "Authorization: Bearer $CMS_TOKEN"
A site offers finding and reading posts and pages, writing and correcting them,
adding a category or a tag, uploading a file and listing the ones it has,
making a form and reading and triaging what has come in through it, and
building its pages.
It deliberately offers nothing for accounts, credentials, backups, how the site
is built, or sending a mailing. Those are administration rather than writing:
the consequences are ones a person should weigh, and they stay in the panel. A console offers the questions an agency has about
fifty sites at once: which built, which did not, what the failing one said,
and adding a new site.
Two rules make it safe to leave connected:
Tokens are made in API on a site, and on your account in the console.
Both are shown once and can be taken back. A connection made through the
sign-in flow needs no token at all — which is the point of it: the person
connecting an assistant to their own site should not have to handle a
credential, and an agency should not be sending one to a customer.
The protocol is spoken at revision 2026-07-28, which is stateless — no
handshake, no session. Clients that still open with initialize are answered
in the revision they ask for.
Install the plugin
on the WordPress site you are leaving, then go to Tools → Migrate to Mavi CMS,
enter this site’s address and sign in.
It moves posts with their original dates, permalinks and status, the category
tree, tags, featured images and the images inside post content — rewriting them
to point at their new copies. Polylang and WPML languages are carried across and
translations of the same post are linked together.
Nothing on the WordPress side is changed or deleted, and the migration is
resumable: a post that has already been sent is skipped, so you can stop and
continue whenever.
Requires Bun and a Rust toolchain.
bun install
bun run dev # http://localhost:5173, proxies the API to :8080
cd backend
cargo run # http://localhost:8080, API docs at /scalar
The frontend expects the backend on :8080; point it elsewhere with
VITE_API_PROXY_TARGET.
bun run build # builds, then typechecks — vite generates the route tree
bun run typecheck
bun run lint
bun run extract # pull new translatable strings into src/locales/*/messages.po
cd backend
cargo clippy --all-targets -- -D warnings
cargo test
Or run the whole thing in containers, built from your checkout:
docker compose -f docker-compose.dev.yml up --build
src/ React 19, Vite, TanStack Router, Tailwind 4, Tiptap 3
backend/api/ Axum handlers, DTOs, SeaORM entities
backend/migration/ schema migrations, run automatically at boot
wordpress-plugin/ the WordPress migration plugin (GPLv2+)
The admin interface is English and Turkish, via Lingui.
Interface language is independent of the language your content is written in.
Interactive docs are served from a running instance at /scalar, and the
OpenAPI document at /api/api-docs/openapi.json.
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.