Deploy pipeline (CI/CD)
Mezzanine’s deploy pipeline is a closed loop: a developer changes code, the change lands in GitHub, a webhook wakes the workflow engine on the App box, and the engine drives a three-step deploy over SSH on a managed server — streaming every line back to an in-panel terminal while it runs. Nothing in this loop runs on the panel host; the panel orchestrates a remote Docker host and watches.
The orchestrator is the mezzanine-backend container on the App box (public IP 5.189.154.35). It holds the workflow engine, the SSH client (ssh2), and the Docker client. mezzanine-backend listens on 3001 only on the docker_default network — it is never published to the host; only mezzanine-nginx publishes 80/443. The engine reaches the target — for MYACCESS that’s the Development box (192.210.241.34), a plain Ubuntu host with system nginx + certbot — purely over SSH. See the Deploy workflows feature page for the step-type vocabulary.
The full loop — push to live
Two entry points feed the same pipeline. A normal git push to GitHub, or the Linked Folders panel — a Chromium File System Access API tool that commits gitignore-filtered, changed-only files straight to the repo through the GitHub Git Data API (blobs to tree to commit to ref). Either way the result is a commit, the commit fires the webhook, and the webhook drives the deploy.
sequenceDiagram
actor Dev as Developer
participant FS as Linked Folders panel
participant GH as GitHub repo
participant BE as App box backend engine
participant Term as Panel terminal
participant Target as Development box Docker host
alt git push
Dev->>GH: git push to main
else Linked-folder Push changes
Dev->>FS: Push changes, changed files only
FS->>GH: Git Data API blobs to tree to commit to ref
end
GH->>BE: HMAC-signed webhook POST to workflow endpoint
Note over BE: verify HMAC-SHA256, then queue a run
BE->>Term: stream run started
BE->>Target: SSH Sync code, fetch checkout pull or clone
Target-->>Term: stage output streams back
BE->>Target: SSH Build image, docker build app latest
Target-->>Term: build log streams back
BE->>Target: SSH Run container, docker run, port guard, health check
Target-->>Term: container swap, old to new
Target-->>BE: health check 2xx
BE->>Term: stream run succeeded, with durations
The webhook target is https://app.mezzanine.cloud/api/workflows/<id>/webhook. That URL resolves on the App box through mezzanine-nginx, which terminates TLS for app.mezzanine.cloud and proxies /api to mezzanine-backend:3001 on the docker_default network. The backend verifies the HMAC-SHA256 signature against the workflow’s secret before it queues anything — an unsigned or mismatched POST is rejected, so the public endpoint can’t be used to trigger arbitrary deploys. Once verified, the engine queues a run and immediately starts emitting on the /workflows Socket.IO namespace, so the in-panel terminal shows stages, per-stage status, and durations live as the SSH commands execute on the target.
The three steps and the three triggers
A workflow is an ordered list of typed steps. The deploy path is three of them — Sync code, Build image, Run container — and any of three trigger types can kick the engine off.
flowchart TD
subgraph "Triggers"
M["Manual (Run button)"]
S["Schedule (cron)"]
W["Webhook (GitHub push, HMAC-signed)"]
end
M --> ENG["Workflow engine (App box backend)"]
S --> ENG
W --> ENG
ENG -->|"SSH to target"| STEP1["1. Sync code: git fetch checkout pull, or fresh clone via SSH-alias URL"]
STEP1 --> STEP2["2. Build image: docker build -t app:latest . (repo Dockerfile, else auto-detect stack)"]
STEP2 --> STEP3["3. Run container: docker run -d, name app, restart unless-stopped, publish host to container, mount data volume"]
STEP3 --> GUARD["port-collision guard, then health check"]
GUARD -->|"healthy"| LIVE["Container swap to live"]
GUARD -->|"fails"| ABORT["abort run, report to terminal"]
STEP1 -.->|"live output"| OUT["Panel terminal (Socket.IO /workflows)"]
STEP2 -.-> OUT
STEP3 -.-> OUT
| Step | What runs on the target (over SSH) |
|---|---|
| 1. Sync code | git fetch / checkout / pull on an existing checkout, or a fresh git clone using the SSH-alias clone URL |
| 2. Build image | docker build -t <app>:latest . — uses the repo’s Dockerfile if present, otherwise auto-detects the stack |
| 3. Run container | docker run -d --name <app> --restart=unless-stopped -p <host>:<container> -v <data-volume> <app>:latest, with a port-collision guard and a health check |
A concrete example from the Development box — the Investor app:
docker run -d --name investors --restart=unless-stopped \ -p 8090:3000 -v /opt/investors-data:/app/data investors:latestSystem nginx on that box then reverse-proxies the app’s domain to its published port (8090), with a Let’s Encrypt cert. The panel doesn’t touch nginx here — it owns the container, the host’s system nginx owns the edge.
| Trigger | How it fires the engine |
|---|---|
| Manual | The Run button on the workflow detail page |
| Schedule | A cron expression evaluated by the panel-side scheduler |
| Webhook | GitHub push to https://app.mezzanine.cloud/api/workflows/<id>/webhook, HMAC-SHA256 verified |
All three can coexist on one workflow.
Per-repo deploy keys and SSH aliases
Cloning private repos on the target uses a per-repo deploy key, addressed through an SSH host alias. A single GitHub SSH key can be a deploy key on only one repo, so each app gets its own key and its own alias entry, and the clone URL embeds the alias:
git@github-mya-investors:MYA-APPS/MYA-INVESTORS.gitHere github-mya-investors is a Host alias in the target’s SSH config that maps to github.com with the right IdentityFile. The Sync-code step clones/pulls through that alias, so the correct deploy key is selected automatically and one key is never shared across repos.
Linked Folders: deploy without local git
The Linked Folders panel closes the loop for developers who don’t want a local git client. It wires a local project folder (via the browser’s File System Access API) to a repo, diffs against .gitignore, and on Push changes sends only the changed files to GitHub through the Git Data API — building blobs, a tree, a commit, and moving the ref. That commit is indistinguishable from a git push to the rest of the pipeline: it fires the same webhook, which runs the same workflow, which performs the same Sync / Build / Run on the target. No local git, no GitHub website. See Git providers for how repos and credentials are registered.
Where this sits in the system
The deploy engine is one of the App box backend’s services, alongside the SSH client, the Docker client, the provider API clients (Hostinger / Contabo / Hetzner / DigitalOcean / Cloudflare), the git service, and the nginx service — all layered as routes/ to services/ to db/ (a single better-sqlite3 file). Run state persists to that DB as a deployment_runs row plus a deployment_step_runs row per step; a failed step aborts the run unless it’s marked continueOnError. The live view is the /workflows Socket.IO namespace.
Only the App box runs the workflow engine — SaaS tenants are panels for their own operators and don’t host deploy pipelines. For the broader picture of the three servers and how they share a Cloudflare zone, see the Architecture overview and Networking & routing.