Skip to content

Backend internals

The mezzanine-backend image is one Express app that runs identically on the App box panel and inside every hosted tenant on the SaaS box. It is layered top-to-bottom — thin /api/* routes call into services/, which own all the real logic, and everything persists to a single better-sqlite3 file. Outbound, the same services reach your managed servers over SSH and cloud providers over their HTTPS REST APIs.

Request layering

The container exposes port 3001 only on the Docker network — it is never published to the host. On the App box, mezzanine-nginx terminates TLS and proxies /api plus the Socket.IO upgrade to mezzanine-backend:3001 (and / to mezzanine-frontend:3000). On a tenant, Traefik does the equivalent. From the backend’s port 3001 a request passes through middleware, into a thin route handler, down into a service, and finally to the SQLite file.

flowchart TD
  BR["Browser (panel UI)"] --> NG["nginx or traefik (TLS, reverse proxy)"]
  NG --> RT["routes/ (thin /api/* handlers, port 3001)"]
  RT --> MW1
  subgraph "middleware/"
    MW1["session auth (admin password)"]
    MW2["role-gate (admin, moderator, viewer)"]
    MW3["in-memory response cache"]
  end
  MW1 --> MW2
  MW2 --> MW3
  MW3 --> SV["delegate to a service"]
  subgraph "services/"
    S1["ssh client (ssh2)"]
    S2["docker client"]
    S3["provider API clients"]
    S4["git service"]
    S5["nginx service"]
    S6["workflow engine"]
  end
  SV --> S1
  SV --> S2
  SV --> S3
  SV --> S4
  SV --> S5
  SV --> S6
  S1 --> DB["db/ (better-sqlite3, single file)"]
  S6 --> DB
  S1 -. "SSH" .-> MS["Managed servers (e.g. Development box)"]
  S2 -. "SSH" .-> MS
  S5 -. "SSH" .-> MS
  S3 -. "HTTPS REST" .-> CP["Cloud providers (Hostinger, Contabo, Hetzner, DigitalOcean, Cloudflare)"]

The split is deliberate: routes do almost nothing but validate input and delegate. All business logic — SSH command execution, container orchestration, provider calls, the deploy pipeline — lives in services/ so it can be reused across routes, websockets, the scheduler, and the workflow engine without going back through HTTP.

What each layer owns

LayerDirectoryResponsibility
Routesroutes/Thin /api/* Express handlers — parse, validate, delegate.
Middlewaremiddleware/Session auth (admin password), role-gate, in-memory response cache.
Servicesservices/SSH client (ssh2), Docker client, provider API clients, git service, nginx service, workflow engine.
Datadb/better-sqlite3 over a single SQLite file.

Role tiers

The role-gate middleware enforces the role hierarchy on every request. The UI also hides affordances a user cannot use, but the backend gate is the real protection — see Multi-user roles for the full model.

RoleCan do
viewerRead-only — see servers, deploys, runs, metrics. Cannot trigger anything.
moderatorEverything viewer does, plus trigger workflows and approve approval-gate steps.
adminEverything moderator does, plus add, edit, and delete servers, workflows, and integrations.

Session auth runs first (is this request authenticated at all?), then the role-gate (does this authenticated user meet the minimum role for this method and path?). Only then does the response cache or the route handler run.

Real-time streams over Socket.IO

Beyond /api, the backend exposes Socket.IO namespaces for live data. The reverse proxy forwards the websocket upgrade to backend port 3001; the browser subscribes to the namespace it needs.

graph LR
  BE["mezzanine-backend (Socket.IO, 3001)"] --> NS1["/workflows"]
  BE --> NS2["terminal"]
  BE --> NS3["metrics"]
  BE --> NS4["logs"]
  NS1 --> BR["Browser (panel UI)"]
  NS2 --> BR
  NS3 --> BR
  NS4 --> BR
NamespaceStreamsDrives
/workflowsDeploy run output — stages, status, durationsThe in-panel run terminal during a workflow run
terminalInteractive SSH shell I/OThe web terminal for a managed server
metricsLive host and container metricsLive charts on the server detail view
logsTailed container and service logsThe log viewer

The /workflows namespace is what makes a deploy feel live: when a webhook (a GitHub push to a linked folder or a normal push) fires a workflow, the engine emits each stage’s start, status, and duration over /workflows, and the panel renders them in real time.

Request lifecycle

A single authenticated, role-passing request that does real work — say, listing containers on a managed server — flows straight down the layers and back.

sequenceDiagram
  participant BR as "Browser (axios)"
  participant NG as "nginx or traefik"
  participant MW as "middleware"
  participant RT as "route handler"
  participant SV as "service (ssh or docker)"
  participant DB as "db (better-sqlite3)"
  participant MS as "Managed server"
  BR->>NG: "GET /api/... (session cookie)"
  NG->>MW: "proxy to backend 3001"
  MW->>MW: "session auth, role-gate, cache check"
  MW->>RT: "authorized, cache miss"
  RT->>SV: "delegate"
  SV->>DB: "read server credentials"
  DB-->>SV: "row"
  SV->>MS: "run command over SSH"
  MS-->>SV: "stdout"
  SV-->>RT: "result"
  RT-->>BR: "JSON response (cached)"

On a cache hit the middleware short-circuits and returns the stored response before the route handler runs — the in-memory cache lives in the middleware layer, so reads never reach a service or the DB.

Why this shape

  • One SQLite file via better-sqlite3 — synchronous queries, no separate DB server, the whole panel’s state in a single file inside the mezzanine_data volume.
  • Services as the only path to side effects — SSH, Docker, provider, git, nginx, and the workflow engine all sit behind services/, so routes, the scheduler, and websockets share one implementation.
  • Same image everywhere — this exact backend runs the App box panel and every hosted tenant on the SaaS box. See the Architecture overview for how the three servers fit together.