Skip to content

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

Mezzanine system architecture

Three containers on a single Docker network:

ServiceWhat it doesImage
mezzanine-nginxTLS terminator + routingnginx:1.27-alpine
mezzanine-frontendNext.js SSR + static assetsLocal build (Dockerfile.frontend)
mezzanine-backendExpress + Socket.IO + SSH/Docker servicesLocal 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

Mezzanine fleet topology

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.

Server A — the single-tenant control panel in depth

  • 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, and auth/roleGate/cache middleware. State is the single mezzanine_data SQLite 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.

Server B — the multi-tenant SaaS host in depth

  • 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:

Mezzanine production topology across two servers

  • Explicit app/docs/@ records point at Server A; the * wildcard (covering try.mezzanine.cloud and 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-sqlite3 is 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.