Architecture
Mezzanine is a single SQLite-backed Express + Next.js panel that opens SSH connections out to your servers. Nothing about it is special-cased to a particular cloud — every provider integration is a thin API wrapper on top of the same primitives.
System architecture
Three containers on a single Docker network:
| Service | What it does | Image |
|---|---|---|
mezzanine-nginx | TLS terminator + routing | nginx:1.27-alpine |
mezzanine-frontend | Next.js SSR + static assets | Local build (Dockerfile.frontend) |
mezzanine-backend | Express + Socket.IO + SSH/Docker services | Local build (Dockerfile.backend) |
State lives in one named volume (mezzanine_data) mounted at /data inside the backend container. That’s the only thing you need to back up — everything else is reproducible from git + a fresh docker compose build.
Out to your fleet
For each adopted server, the backend opens one SSH connection on demand (pooled, idle-timed) and runs commands as the configured user. For each provider, it holds OAuth2 / API-token credentials in the integrations table and makes signed HTTPS calls. The control plane never sits in the data path — it configures and observes, but in-pool traffic flows browser → LB → members directly.
Production topology — two servers, one domain
In our own production we run Mezzanine on two servers behind one Cloudflare zone. To make it easy to follow, here’s each server in depth first, then the two clubbed together.
Server A — the control panel (single-tenant)
The box most self-hosters run: one Docker host where nginx terminates TLS and proxies two containers, and the backend reaches out to your fleet over SSH and to provider APIs over HTTPS.
- nginx (
:443, Let’s Encrypt) routes/api+/socket.io→ the backend (Express + Socket.IO), everything else → the frontend (Next.js panel UI). - The backend holds all logic: thin
/api/*routes, the SSH pool + dockerode + provider clients, andauth/roleGate/cachemiddleware. State is the singlemezzanine_dataSQLite volume. - Outbound is the whole point: SSH (ssh2, one pooled connection per server) to run commands/deploys, and HTTPS to provider APIs (Hostinger, Contabo, DigitalOcean, Hetzner, Cloudflare). The panel configures + observes — it’s never in your apps’ data path.
Server B — Mezzanine Cloud (multi-tenant SaaS)
The hosted product. Traefik fronts everything with a wildcard cert; the control-plane runs signups + billing and provisions a fully isolated Mezzanine stack per customer.
- Traefik (
:443, DNS-01 wildcard for*.mezzanine.cloud) host-routes:try.→ control-plane;<slug>.→ that tenant’s frontend/backend; unknown/asleep → the wake-fallback. - The control-plane (signup · billing · superadmin · wake-fallback) owns only a registry DB (
cp_data: accounts, users, instances, subscriptions, billing_events, audit_log) — never tenant data. - The provisioner (dockerode, via the Docker socket) creates a volume + backend + frontend per signup, attaches Traefik labels, and seeds the owner admin.
- Lifecycle crons hibernate idle free tenants, back up volumes nightly, and tail Traefik’s access-log to track activity. Each tenant stack is the same image as Server A.
Both together
Cloudflare DNS is the splitter that ties the two boxes into one domain:
- Explicit
app/docs/@records point at Server A; the*wildcard (coveringtry.mezzanine.cloudand every tenant slug) points at Server B. Explicit records win over the wildcard, so adding the SaaS box never disturbs the panel. - The two boxes share no database — fully independent. The same Mezzanine image runs in all three contexts (your panel, a self-host, every hosted tenant). See Self-host vs Hosted for the product side.
Code layout
mezzanine/├── backend/src/│ ├── routes/ # /api/* — thin Express handlers│ ├── services/ # business logic, SSH/Docker/provider clients│ ├── websockets/ # Socket.IO namespaces (terminal, metrics, logs)│ ├── middleware/ # auth, role-gate, response cache│ └── db/index.ts # SQLite schema + migrations├── frontend/│ ├── app/ # Next.js App Router pages│ ├── components/ # shared React components│ ├── api/client.ts # axios client + per-resource API namespaces│ └── lib/ # helpers (dialogs, workflow templates, transformers)├── samples/ # reference apps + this docs site + landing page└── deploy/ # Production artifacts (docker-compose, nginx.conf, INSTALL.md)Why SQLite?
Single-file storage trades horizontal scalability for operational simplicity:
- One file to back up.
cp mezzanine.db /backup/is your DR plan. - No DB server to provision, monitor, or upgrade
better-sqlite3is synchronous → no async ceremony around every query- Easily handles 100K runs + 10K servers on a $5 VPS
If you outgrow it (>100 concurrent users, multi-region setup), swap for Postgres — the schema is portable.