Request path
PRAGMA foreign_keys=ON
for cascade deletes). There is no separate cache or job queue: Docker and
Caddy’s actual running state can, in principle, drift from what’s in the
DB (e.g. someone docker rms a container by hand), but every write path
goes DB-row-first, so a restart re-reads the DB as ground truth rather
than reconciling against live container state.
Why chi, why no cgo SQLite, why a hand-rolled frontend router
- chi: a thin, idiomatic net/http router with per-route middleware
chaining (
r.With(middleware)) — no reflection-based magic, easy to read top-to-bottom inrouter.goto see the whole API surface. - modernc.org/sqlite (no cgo): keeps the Go binary a static,
single-file artifact with no libsqlite3 dependency to install on the
target box — matters for the “small VPS” deployment story in
deployment.md, where the binary is just
scp’d over. - SvelteKit as a static SPA (
web/,@sveltejs/adapter-static,ssr = false): file-based routes inweb/src/routes/and Svelte 5 rune modules for shared state (web/src/lib/stores/*.svelte.ts) replace what was a hand-rolled React router plus contexts. There is still no Node server: the build is a plain directory of files embedded into the Go binary (internal/webui), which servesindex.htmlfor any path that isn’t a real file so client-side routing handles deep links. SvelteKit’s server-side features (+page.server.ts, form actions, SSR) are deliberately unused, so its server-only dependencies never ship. Pinned to SvelteKit 2.x — 3.0 shipped in October 2026 and hasn’t been evaluated yet. The working guide (layout, stores, polling, styling) is frontend.md.
The blue/green deploy flow
Orchestrator.Deploy (internal/orchestrator/deploy.go) is the core of the whole system. For a single-service deployment, in order:
- Admission control: sum every other deployment’s configured
memory_limit_mb(Store.SumConfiguredMemoryMB); if adding this one would exceedMANGROVE_DEPLOYMENT_MEMORY_CEILING_MB, fail before touching Docker at all. This mirrors (and is meant to stay comfortably under)mangrove-deployments.slice’s hardMemoryMax— see deployment.md. - Build: run the configured build strategy (
dockerfile,nixpacks,compose,image, orstatic) viainternal/executor, tagging the resultmangrove/<slug>-<service>:<deploy_history_id>. Animagestrategy skips building entirely and uses the given ref directly. - Port allocation (public deployments only): reserve a host port from
internal/portregistry’s range before running the new container — but deliberately not bound to the container yet (see next step). - Run the new container alongside the still-live old one, under a new per-deploy container name (
<service>-<deploy_history_id>), on the internal Docker network only. Old and new containers coexist during this window — this is the “blue/green” part: nothing public points at the new one yet, so a failed health check has zero user-facing impact. - Health-check gate: poll the new container’s configured health path. If it never turns healthy within the timeout, stop/remove the new container and return failure — the old container is left running untouched. A bad deploy never takes down a working one.
- Atomic swap: once healthy,
internal/proxyPUTs Caddy’s route for the allocated port to the new container’s internal address in one request. Caddy applies it atomically — there is no window where the port accepts a connection but nothing answers. - Tear down the old container only after the swap above succeeds — ordering matters: repoint traffic first, then remove the thing that was serving it.
- Record the deploy artifact, mark this
deploy_historyrow current, prune old images past the deployment’s configured retention count, and (if configured) send a notification email / post a GitHub commit status.
POST /api/deploy-history/{id}/rollback) re-runs this same flow, skipping the build step and reusing the previously-built artifact’s image tag/ID instead — so rollback goes through the identical health-check-gated blue/green swap as a forward deploy, not a separate “just point at the old image” shortcut.
Multi-service deployments (compose strategy, or template-installed stacks like WordPress+MySQL) go through DeployCompose (internal/orchestrator/compose_deploy.go) instead, which is compose-native rather than doing a per-service blue/green swap.
Lifecycle actions short of a full deploy
internal/orchestrator/lifecycle.go covers three actions that operate on a deployment’s already-running container(s) instead of building anything:
- Stop (
POST /api/deployments/{id}/stop) runsdocker stopon every service’s current container — it is not removed, so a later Restart is fast — deletes its Caddy route (if any), and marks the deploymentstopped. That status matters beyond display:Store.SumConfiguredMemoryMBexcludes stopped deployments from the admission-control sumDeploychecks againstMANGROVE_DEPLOYMENT_MEMORY_CEILING_MB, so stopping an idle deployment actually frees budget for others. - Restart (
POST /api/deployments/{id}/restart) runsdocker restarton the same container ID (also how a stopped deployment is started back up —docker restarton an exited container just starts it), then re-pushes the Caddy route the same waySetAccessControldoes after an access-control change: re-resolve the container’s internal address viaExec.ContainerAddrandPutRouteagain, since a restart can hand the container a new internal IP even though its ID is unchanged. UnlikeDeploy, this isn’t health-check-gated — it’s a direct cycle of the one container that’s there, not a blue/green swap between two. - Redeploy (
POST /api/deployments/{id}/redeploy) re-runs the fullDeploy/DeployCompose/DeployStaticpipeline against whatever source the deployment is already configured with, resolvinggit_urland a decrypted PAT from its linkedproject_reposrow exactly like a webhook push does (internal/api/webhook.go’shandlePushEvent) rather than requiring the caller to supply them, which plainPOST .../deploystill does. An image-strategy deployment needs no repo at all; any other strategy without a linked repo is rejected with a 422 rather than attempting agit clone "".
POST /api/deployments/{id}/scale) sets a deployment’s replica count (1-32) and triggers a redeploy so it takes effect — scaling is implemented as a blue/green swap that starts (or tears down) the requested number of containers, going through the same health-check gate as any other deploy, not a direct docker run/docker stop of the delta. Only single-service strategies (dockerfile/nixpacks/image) are scalable; compose stacks and static sites stay at 1.
A one-off command against a service’s running container (e.g. a database migration) is POST /api/services/{id}/exec, executed via executor.Executor.Exec (docker exec). It runs synchronously and buffers output in memory — a fit for a short migration command, not a long-running or high-volume process.
For anything more than a one-off command there’s a full interactive terminal: GET /api/services/{id}/terminal upgrades to a websocket (internal/api/terminal.go), backed by executor.Executor.Terminal (internal/executor/docker.go). Unlike Exec, this runs docker exec -it attached to a real host-side pty via creack/pty rather than plain pipes — that’s what lets window resizes actually reach the container’s shell: the docker CLI only forwards SIGWINCH to the exec session when its own stdin/stdout look like a terminal, and a pty (unlike a pipe) does. The websocket protocol is deliberately not JSON-wrapped: binary frames carry raw terminal bytes in both directions (keystrokes in, shell output back), and the one text frame the client ever sends is a {"type":"resize"} control message — arbitrary shell output isn’t guaranteed to be valid UTF-8, so it can’t safely share a JSON-text channel the way the control message does. The shell itself prefers bash when present, falling back to sh, through a guard that checks for bash first, since POSIX sh (what /bin/sh actually is on most base images) exits the whole script the instant a bare exec names a command that isn’t found, rather than letting || catch it.
The dashboard no longer has a UI for this (the xterm.js-based Terminal tab was removed — it didn’t work reliably in the browser). The endpoint is kept because mangrove-tui’s shell view (cmd/mangrove-tui/terminal.go) dials the exact same websocket and bridges it to a real local pty; see clients.md.
GitHub auto-deploy and staging environments
internal/api/webhook.go’s githubWebhook is Mangrove’s one always-on public endpoint besides the dashboard itself: POST /webhooks/github/{token}, outside auth (GitHub can’t log in), verifying X-Hub-Signature-256 with a constant-time HMAC compare before any other side effect, and de-duplicating by X-GitHub-Delivery so a GitHub retry doesn’t double-deploy (webhook_events.delivery_id is unique). A push event resolves the branch, looks up every deployments row matching (project_repo_id, git_branch, auto_deploy_on_push=1) — a project_repos link can and does fan out to more than one deployment, which is exactly what a staging environment is (see below) — decrypts the linked GitHub PAT once, and fires one deploy per matching deployment in the background.
Every deploy trigger (manual POST .../deploy, POST .../redeploy, a webhook push, staging creation, and promote) ends by calling Server.dispatchDeploy, the single place that picks Deploy/DeployCompose/DeployStatic based on build_strategy. This used to be a switch duplicated at each call site, and the webhook path’s copy drifted out of sync — it only special-cased compose, so a static-strategy deployment fell into Deploy() (written for container-based strategies, which tries to run a container from an image a static build never produces) and failed every time GitHub pushed to it. Centralizing the switch is what makes that class of bug structurally harder to reintroduce. The webhook path also now wraps its dispatch in Orchestrator.WithInflightDeploy, the same guard the HTTP trigger endpoints already used, so a rapid double-push can’t start two concurrent deploys of the same deployment.
Webhook health is visible in the dashboard rather than silently failing: project_repos.webhook_registered records whether GitHub-side auto-registration actually succeeded (only possible for an OAuth-sourced credential — a hand-pasted PAT’s scopes aren’t known), GET .../repo/webhook-instructions re-shows the callback URL and secret at any time (they’re encrypted at rest, not just returned once), POST .../repo/resync-webhook re-attempts registration, and GET .../repo/webhook-events lists recent deliveries — whether GitHub reached Mangrove at all, whether the signature checked out, and whether anything matched an auto-deploying deployment.
Staging environments
A staging environment is deliberately not new infrastructure: it’s just anotherdeployments row. environment (production|staging) and promotes_to_deployment_id (migration 0007) are the only additions. POST /api/deployments/{id}/staging clones a production deployment’s build config, services, and env vars (a secret env var’s ciphertext is re-sealed under the new service’s ID, since its AAD binds to the service it belongs to — see resolveEnv) onto a new deployment tracking a caller-chosen branch of the same linked repo, sets auto_deploy_on_push=1 on it, and deploys it immediately. From there it behaves exactly like any other deployment — its own slug, port and history, and the webhook fan-out above auto-deploys it independently of production whenever that branch gets pushed to.
POST /api/deployments/{id}/promote (only valid on a staging deployment) deploys production with the exact commit staging is currently running — not just production’s branch tip, which could be a different commit than whatever was actually verified in staging. This needed one executor fix: git clone --branch <ref> only resolves a branch or tag name on the remote, never a raw commit SHA, even though GitHub’s smart HTTP protocol happily serves any reachable commit by SHA. internal/executor/gitfetch.go detects a SHA-shaped GitRef and switches to git init + git fetch <sha>
git checkout FETCH_HEADinstead ofclone --branchfor that case only. If staging’s current deploy has no recorded commit (e.g. it was last redeployed manually rather than by a push, which doesn’t resolve or record a commit SHA today), promote falls back to whatevergit_refwas last recorded.
PR previews
Built on the same “just anotherdeployments row” idea as staging, but created and torn down automatically instead of by hand. pr_previews_enabled (migration 0008) opts a production deployment in; pr_number and github_pr_comment_id (same migration) mark a deployment as a preview and record which pull request and bot comment it belongs to. CreateWebhook registers pull_request alongside push, so githubWebhook gains a second event case: handlePullRequestEvent (internal/api/webhook.go) fans out to every pr_previews_enabled production deployment on the repo, same shape as the push handler.
opened/reopened/synchronize call findOrCreatePreviewDeployment, which looks up an existing preview by (promotes_to_deployment_id, pr_number) (a unique index enforces at most one) or clones a new one via cloneDeployment — the same clone-services-and-env-vars logic createStagingDeployment uses, refactored out so the two don’t drift against each other the way the dispatch switch once did. The clone is then deployed with the exact commit the webhook payload named (pull_request.head.sha), not a branch-tip fetch, so a comment posted after the deploy finishes always describes the commit that’s actually running. closed deletes the preview deployment via Orchestrator.DeleteDeployment (containers, proxy route, port, the row itself) if one exists — a PR closed without ever pushing after opt-in has none.
The PR gets one bot comment, edited in place on every update rather than reposted (internal/github/comment.go’s CommentClient.UpsertComment, POST to create then PATCH to edit using the id github_pr_comment_id records) — “building” before the deploy, then the preview URL or the error once Deploy (which blocks until build + health check settle) returns. Best-effort throughout, same convention as the existing commit-status posting: a GitHub API hiccup here never fails the underlying deploy or teardown.
Workspaces
Every project belongs to a workspace (workspace_id, defaulting to workspace 1, which always exists and can’t be deleted) — an organizational grouping between projects and the (single) organization, for separating environments or teams. Workspaces are also the access boundary: each user’s role in a workspace (workspace_members: admin, editor or viewer) decides what they can see and do in it, and a member with no row in a workspace can’t see it at all (see multi-user.md). Deleting a non-default workspace (DELETE /api/workspaces/{id}) moves its projects back to workspace 1 rather than orphaning or cascading into them.
Access control at the proxy layer
A public deployment’s optional password protection (is_public/password_protected on the deployments row) is enforced in front of the app, independent of whatever auth the deployed app has, or doesn’t have, on its own. Caddy routes a protected deployment’s traffic through Mangrove’s own gate handler (internal/proxy/caddy.go’s RouteOptions, internal/api/gate.go), which shows a password or Mangrove-account page before proxying through. See protected-deployments.md.
Roles
Session validation (internal/auth) loads the user’s role in the same query as the session lookup, so role-gating costs no extra DB round trip per request. See multi-user.md for what each role can do.
Storage/NAS shares
A drive-to-NAS deployment (internal/orchestrator/storage.go) is a normal deployments/services row like any other, but built and run outside the blue/green Deploy() flow described above — it holds an exclusive host port for its whole lifetime, which the swap’s “old and new containers briefly coexist” step can’t accommodate. It also relies on two additions this flow doesn’t otherwise use: executor.RunSpec.HostMount (a specific host directory bind-mounted in, instead of a named Docker volume) and executor.RunSpec.PublicBind (publishing a port on 0.0.0.0 instead of every other deployment’s 127.0.0.1-only, since SMB isn’t HTTP and Caddy has no way to route it). See storage.md for the full design, including why mounting the drive itself is delegated to a separate privileged process rather than done by mangrove.service.