App box architecture
The App box (public IP 5.189.154.35) runs MYACCESS’s own Mezzanine panel as three cooperating containers plus two static sites, all on a single Docker bridge network. nginx is the only thing the host exposes; everything behind it talks over the internal Docker network.
Container topology
Every container lives on the docker_default bridge network. Only mezzanine-nginx publishes ports to the host (80 and 443); the frontend and backend expose their ports internally and are reachable only by name on the Docker network. The backend owns the single mezzanine_data volume, and nginx additionally serves two static document roots straight off the host’s /var/www.
graph TD
Internet["Internet (clients)"] -->|"80 and 443"| NGINX["mezzanine-nginx nginx:1.27-alpine host ports 80 and 443"]
subgraph docker_default["Docker network: docker_default (bridge)"]
NGINX -->|"vhost app.mezzanine.cloud route /"| FE["mezzanine-frontend Next.js App Router internal 3000"]
NGINX -->|"vhost app.mezzanine.cloud route /api and Socket.IO"| BE["mezzanine-backend Express better-sqlite3 Socket.IO internal 3001"]
BE -->|"reads and writes SQLite"| VOL["volume mezzanine_data at /data (mezzanine.db plus uploads)"]
end
NGINX -->|"vhost mezzanine.cloud and www static"| WWW1["/var/www/mezzanine.cloud marketing landing page"]
NGINX -->|"vhost docs.mezzanine.cloud static"| WWW2["/var/www/docs.mezzanine.cloud this Astro docs site"]
Certbot["certbot Let's Encrypt on host"] -.->|"certs mounted into container"| NGINX
The split is the key fact to internalize: app.mezzanine.cloud is a dynamic vhost (nginx reverse-proxies it to live containers), while mezzanine.cloud, www.mezzanine.cloud, and docs.mezzanine.cloud are static vhosts (nginx serves flat files from /var/www, no app process involved).
Containers, images, ports, and volume
| Container | Image | Host ports | Internal port | Volume |
|---|---|---|---|---|
mezzanine-nginx | nginx:1.27-alpine | 80, 443 | — | TLS certs mounted from host (certbot) |
mezzanine-frontend | mezzanine-frontend:latest | none (not published) | 3000 | — |
mezzanine-backend | mezzanine-backend:latest | none (not published) | 3001 | mezzanine_data at /data |
nginx vhosts on this box
| vhost | Kind | What nginx does |
|---|---|---|
app.mezzanine.cloud | Dynamic (the panel) | Proxies / to frontend:3000; proxies /api and the Socket.IO websockets to backend:3001 |
mezzanine.cloud + www.mezzanine.cloud | Static | Serves files from /var/www/mezzanine.cloud (marketing landing page) |
docs.mezzanine.cloud | Static | Serves files from /var/www/docs.mezzanine.cloud (the Astro docs site — this site) |
An authenticated panel API call
Here’s what happens when the panel UI makes an authenticated request — TLS terminates at nginx, the request is proxied to the backend on the internal network, the backend hits SQLite synchronously (better-sqlite3), and the response flows back. The same vhost also handles the Socket.IO websocket upgrade used for live streams (deploy runs, terminal, metrics, logs).
sequenceDiagram participant B as Browser participant N as nginx 443 participant BE as backend 3001 participant DB as SQLite mezzanine_data Note over B,DB: Authenticated API request on app.mezzanine.cloud B->>N: HTTPS GET /api/servers with session cookie N->>BE: proxy /api to backend on docker_default Note over BE: middleware session auth then role-gate then cache BE->>DB: synchronous query via better-sqlite3 DB-->>BE: rows BE-->>N: JSON response N-->>B: HTTPS 200 JSON Note over B,BE: Live streams use a websocket upgrade on the same vhost B->>N: HTTPS Upgrade to websocket for /socket.io N->>BE: proxy upgrade to backend Socket.IO BE-->>B: streamed events workflows terminal metrics logs
The request never leaves the box’s internal network after nginx: TLS is terminated once at the edge, and the /api and websocket traffic ride the Docker bridge to backend:3001. Because better-sqlite3 is synchronous, each query is a direct in-process read/write against the file on the mezzanine_data volume — no separate database server sits in the path.
Middleware on the request path
Every /api/* call passes through the backend’s middleware stack before reaching a route handler:
| Middleware | Role |
|---|---|
| Session auth | Validates the admin session (admin password login) |
| Role-gate | Enforces the caller’s role — admin / moderator / viewer |
| Response cache | In-memory cache for cacheable reads |
The handler itself is thin: routes/ delegates to services/ (SSH client, Docker client, provider API clients, the git and nginx services, the workflow engine), which read and write through db/ — the single better-sqlite3 file.
See Multi-user roles for how the role-gate maps to UI capabilities.
Where the backend’s traffic actually goes
Inbound is only half the picture. Everything the panel does is outbound, and none of it touches the public internet on the inbound side. The backend reaches managed servers over SSH (via ssh2, one pooled connection per server) to run deploys and commands, and reaches cloud providers over their HTTPS REST APIs (Hostinger, Contabo, Hetzner, DigitalOcean, Cloudflare) for DNS and infrastructure.
graph LR BE["mezzanine-backend Express on docker_default"] -->|"SSH via ssh2 pooled per server"| DEV["Development box 192.210.241.34 managed over SSH"] BE -->|"HTTPS REST API"| PROV["Cloud providers Hostinger Contabo Hetzner DigitalOcean Cloudflare"] BE -->|"Socket.IO workflows namespace"| TERM["In-panel deploy terminal live run output"]
A deploy workflow runs from this box’s backend, SSHes into the target (for example the Development box), and streams its stage-by-stage output back to the panel over the Socket.IO /workflows namespace.
How this fits the bigger picture
This box is the single-tenant control panel — the same mezzanine-frontend and mezzanine-backend images also run per tenant on the multi-tenant SaaS box, where Traefik replaces nginx as the edge. For the end-to-end operator walkthrough of this box, see the App box guide; for how the two production servers share one Cloudflare domain, see the Architecture overview.