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
| Layer | Directory | Responsibility |
|---|---|---|
| Routes | routes/ | Thin /api/* Express handlers — parse, validate, delegate. |
| Middleware | middleware/ | Session auth (admin password), role-gate, in-memory response cache. |
| Services | services/ | SSH client (ssh2), Docker client, provider API clients, git service, nginx service, workflow engine. |
| Data | db/ | 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.
| Role | Can do |
|---|---|
viewer | Read-only — see servers, deploys, runs, metrics. Cannot trigger anything. |
moderator | Everything viewer does, plus trigger workflows and approve approval-gate steps. |
admin | Everything 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
| Namespace | Streams | Drives |
|---|---|---|
/workflows | Deploy run output — stages, status, durations | The in-panel run terminal during a workflow run |
terminal | Interactive SSH shell I/O | The web terminal for a managed server |
metrics | Live host and container metrics | Live charts on the server detail view |
logs | Tailed container and service logs | The 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 themezzanine_datavolume. - 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.