Skip to content

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

ContainerImageHost portsInternal portVolume
mezzanine-nginxnginx:1.27-alpine80, 443TLS certs mounted from host (certbot)
mezzanine-frontendmezzanine-frontend:latestnone (not published)3000
mezzanine-backendmezzanine-backend:latestnone (not published)3001mezzanine_data at /data

nginx vhosts on this box

vhostKindWhat nginx does
app.mezzanine.cloudDynamic (the panel)Proxies / to frontend:3000; proxies /api and the Socket.IO websockets to backend:3001
mezzanine.cloud + www.mezzanine.cloudStaticServes files from /var/www/mezzanine.cloud (marketing landing page)
docs.mezzanine.cloudStaticServes 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:

MiddlewareRole
Session authValidates the admin session (admin password login)
Role-gateEnforces the caller’s role — admin / moderator / viewer
Response cacheIn-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.