Deployment
MangoStudio can be deployed as standalone platform-specific binaries with embedded frontend assets.
Docker
Release images are published to GitHub Container Registry. The default image is
Debian Bookworm; Alpine images use the -alpine version suffix:
docker run -p 3001:3001 \
-v mango-data:/data \
-e BETTER_AUTH_SECRET="$(openssl rand -base64 32)" \
-e GEMINI_API_KEY="your-key" \
ghcr.io/juliopolycarpo/mangostudio:0.1.0The image sets HOME=/data, so the default runtime files live under the mounted
volume:
/data/.mango/config.toml/data/.mango/.env/data/.mango/database.sqlite/data/.mango/uploads/data/.mango/images/data/.mango/tool-images/data/.mango/agents
Useful environment variables:
| Variable | Purpose |
|---|---|
BETTER_AUTH_SECRET |
Required 32+ character auth secret |
GEMINI_API_KEY |
Gemini connector key; other provider keys use the same env |
API_PORT |
Container listen port, default 3001 |
DATABASE_PATH |
Override SQLite path, default /data/.mango/database.sqlite |
UPLOADS_DIR |
Override uploaded file storage path |
IMAGES_DIR |
Override generated image storage path |
TOOL_IMAGES_DIR |
Override custom tool avatar storage path |
AGENTS_DIR |
Override agent settings storage path |
BETTER_AUTH_URL |
Public URL when deployed behind a domain |
TRUST_PROXY |
Set to true only behind a header-overwriting proxy |
ALLOWED_ORIGINS |
Comma-separated browser origins allowed when the frontend is served elsewhere |
ALLOWED_ORIGINS is only half of a split deployment. It tells this API which origin to accept
requests from; the bundle also has to be told where the API is. Unpack
mangostudio-<version>-frontend-dist.tar.gz and edit config.js beside index.html:
window.__MANGO_CONFIG__ = { apiUrl: 'https://api.example.com' };
No rebuild is needed — the file is read at page load, and it outranks anything baked in at
build time. Leave apiUrl empty (the shipped default) to use the origin the page was served
from, which is what a single-origin install and the standalone binary both want.
MANGO_API_URL sets the same value at build time, for anyone building the bundle themselves.
See docs/architecture/frontend-build.md.
Compose example:
services:
mangostudio:
image: ghcr.io/juliopolycarpo/mangostudio:0.1.0
ports:
- "3001:3001"
volumes:
- mango-data:/data
environment:
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
GEMINI_API_KEY: ${GEMINI_API_KEY}
API_PORT: "3001"
DATABASE_PATH: /data/.mango/database.sqlite
volumes:
mango-data:
The first GHCR package may need its visibility changed to public in the GitHub package settings after the first release push.
Manual binary deploy
For bare-metal installs without a package manager, download the platform archive
and SHA256SUMS from GitHub Releases,
then verify before extracting:
VERSION=0.1.0
PLATFORM=linux-x64
curl -LO "https://github.com/juliopolycarpo/mangostudio/releases/download/v${VERSION}/SHA256SUMS"
curl -LO "https://github.com/juliopolycarpo/mangostudio/releases/download/v${VERSION}/mangostudio-${VERSION}-${PLATFORM}.tar.gz"
# Linux — verify checksum
grep "mangostudio-${VERSION}-${PLATFORM}.tar.gz" SHA256SUMS | sha256sum -c -
# macOS — verify checksum
grep "mangostudio-${VERSION}-darwin-arm64.tar.gz" SHA256SUMS | shasum -a 256 -c -
# Or use the project helper (from a clone)
bun ./scripts/release/verify-checksum.ts SHA256SUMS "mangostudio-${VERSION}-${PLATFORM}.tar.gz"
tar -xzf "mangostudio-${VERSION}-${PLATFORM}.tar.gz" -C /opt/mangostudioWindows archives use .zip instead of .tar.gz (windows-x64, windows-arm64).
Asset names and archive layout are documented in
docs/reference/releasing.md.
Windows-only environment variable:
| Variable | Purpose |
|---|---|
MANGO_WSL_EXE |
Overrides which wsl.exe the hub spawns for WSL environments. Auto-detected otherwise (%ProgramFiles%\WSL\wsl.exe, then %ProgramW6432%\WSL\wsl.exe for a 32-bit host process, then %SystemRoot%\System32\wsl.exe, then PATH); mango doctor --env reports the resolved path and its source. |
Production Build
bun run build --binary --platform linux-x64 # this machine's platform
bun run build --binary --runtime-dir .mango/runtime-prebuilt # all 8, with prebuilt runtimesThis compiles the hub (with the frontend embedded) into .mango/out/<platform>/mangostudio
and places the cargo-built mangostudio-runtime beside it. Without --runtime-dir, only the
host’s own platform can be built, because only its runtime is built locally with cargo build.
Any other platform needs a prebuilt runtime at <dir>/<platform>/mangostudio-runtime[.exe]
(bun run build:runtime, or the CI artifact); see
releasing.md.
Platform Targets
| Platform | Arch | Variant |
|---|---|---|
linux-x64 |
x86_64 | glibc |
linux-x64-musl |
x86_64 | musl (Alpine) |
linux-arm64 |
ARM64 | glibc |
linux-arm64-musl |
ARM64 | musl (Alpine) |
windows-x64 |
x86_64 | — |
windows-arm64 |
ARM64 | — |
darwin-x64 |
x86_64 | — |
darwin-arm64 |
ARM64 | — |
Binary Layout
.mango/out/linux-x64/
├── mangostudio # Bun-compiled hub (frontend embedded)
├── mangostudio-runtime # cargo-built runtime; launched as Local
├── run.sh # Startup helper script
└── README.md # Platform notes
The binary serves the embedded frontend SPA. API routes are served under /api/ and SPA routes fallback to index.html.
The two binaries are one unit: the hub launches mangostudio-runtime from beside its own
executable, and the protocol handshake refuses a runtime whose version differs. Keep them
together when copying a build, and upgrade both at once.
Runtime Binary Requirements
The runtime is a native Rust binary with no interpreter or shared runtime to install:
- Linux glibc (
linux-x64,linux-arm64): glibc 2.17 or newer, the same floor as the hub. - Linux musl (
*-musl): the runtime is statically linked and needs no extra packages. The hub is a Bun-compiled binary that linkslibstdc++dynamically, so a bare Alpine needsapk add libstdc++(plusbashandcurl, which the installer uses) beforeinstall.sh. Without it the installer’s version check fails; it prints the loader’s own message and thatapkhint. - Windows: the MSVC C runtime is linked statically, so no Visual C++ Redistributable is needed.
- macOS: one binary per architecture (
darwin-x64,darwin-arm64).
mangostudio-runtime --version prints the bare release version; mangostudio doctor and
WSL/SSH provisioning compare it with the hub’s. Every release asset, raw runtime binaries
included, is listed in SHA256SUMS, and SHA256SUMS carries a build provenance attestation:
gh attestation verify SHA256SUMS --repo juliopolycarpo/mangostudioSee releasing.md for how each target is built and checked.
Configuration
Production configuration uses ~/.mango/config.toml and ~/.mango/.env for the process user:
[server]
host = "0.0.0.0"
port = 3001
# How peers outside this process reach the hub, which is a different question
# from how it binds. Required for paired WebSocket runtimes: the pairing card
# has no other way to print the address they dial, and a request header would
# be a spoofable guess. Env override: PUBLIC_URL. Direct URL environments do
# not need this — the hub dials the runtime's baseUrl instead.
publicUrl = "https://your-domain.com"
# Only for a split deployment, where the frontend bundle is served from a
# different origin than this API. Added to the server's own origins, never
# substituted for them. Each entry must be a bare scheme://host[:port] — a path,
# a trailing slash, or an explicit default port fails at startup, because every
# gate compares the browser's Origin header by exact string. Env override:
# ALLOWED_ORIGINS (comma-separated), which replaces this list.
allowedOrigins = ["https://studio.example.com"]
[database]
path = "/var/lib/mangostudio/database.sqlite"
[uploads]
dir = "/var/lib/mangostudio/uploads"
[images]
dir = "/var/lib/mangostudio/images"
[agents]
dir = "/var/lib/mangostudio/agents"
[auth]
secret = "your-64-char-random-secret"
url = "https://your-domain.com"
Required for production:
- Set
auth.secretto a strong random string (32+ characters). - Set
auth.urlto your public-facing URL. - Use a reverse proxy for TLS termination.
Database
The SQLite database defaults to ~/.mango/database.sqlite. For production, configure a persistent path:
[database]
path = "/var/lib/mangostudio/database.sqlite"
SQLite with WAL mode is suitable for single-server deployments. The database file should be backed up regularly.
Reverse Proxy
nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /etc/ssl/certs/your-cert.pem;
ssl_certificate_key /etc/ssl/private/your-key.pem;
# Compress the frontend bundle and JSON. text/html is always included once
# gzip is on; text/event-stream is left out, so SSE stays uncompressed.
gzip on;
gzip_vary on;
gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE streaming
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
}
Caddy
your-domain.com {
# Compress the frontend bundle and JSON. The list is explicit because the
# default one matches text/*, which also compresses SSE (text/event-stream)
# once an event is larger than 512 bytes.
encode zstd gzip {
match {
header Content-Type text/html*
header Content-Type text/css*
header Content-Type text/javascript*
header Content-Type application/javascript*
header Content-Type application/json*
header Content-Type image/svg+xml*
}
}
reverse_proxy 127.0.0.1:3001
}
Compression
The hub serves the frontend bundle uncompressed and leaves compression to the proxy. Both examples above compress it: the main JavaScript file drops from about 1.8 MB to about 0.6 MB on the wire, which matters for remote access (a browser on the same machine gains little). Fonts and images are already compressed and are not listed.
SSE responses (text/event-stream) are deliberately left uncompressed, so each event reaches
the browser as soon as the hub sends it. If you extend either list, keep text/event-stream
out of it, and keep proxy_buffering off in nginx.
If another proxy, load balancer or CDN sits in front of nginx and adds a Via request header,
nginx treats the request as proxied and skips gzip by default. Add gzip_proxied any; beside
gzip on; in that setup.
To check a deployment, request a hashed asset from the page with a GET and read the headers
(curl -I sends HEAD, which the hub’s asset routes do not answer):
curl -sS -o /dev/null -D - -H 'Accept-Encoding: gzip' \
https://your-domain.com/assets/main-<hash>.js | grep -i '^content-encoding:'A compressed response prints its Content-Encoding: gzip header (the case varies by proxy).
No output (and grep exiting with 1) means the proxy sent the file uncompressed. To check
Caddy’s zstd path, send Accept-Encoding: zstd instead and expect zstd.
Trusting proxy headers
Behind a reverse proxy (including Docker behind nginx/Caddy/a load balancer) the
socket peer is the proxy, not the client, so by default every caller collapses
to one rate-limit counter. Set TRUST_PROXY=true (env) or trustProxy = true
under [security] in config.toml so the limiter resolves the real client IP
from the X-Forwarded-For / X-Real-IP / CF-Connecting-IP headers the proxy
sets.
The same setting decides the local-surface guard that gates environment installs
and the machine actions (/api/machine/restart, /api/machine/service,
/api/machine/logs). That guard asks whether the browser is at this machine’s
keyboard, and the socket peer cannot answer it behind a proxy — every caller
arrives from loopback. With TRUST_PROXY=true the forwarded client answers
instead, so a remote signed-in session is correctly refused.
The two read different hops of X-Forwarded-For, because they are asking
different questions. The header is a list, and a proxy appends the address it
saw rather than replacing the list — both $proxy_add_x_forwarded_for above and
Caddy’s reverse_proxy do. The limiter takes the first hop, the origin
client, which is what a counter should be keyed on. The guard takes the last
hop, the one your proxy wrote, because every earlier entry is whatever the
caller sent — a remote browser can put X-Forwarded-For: 127.0.0.1 on a
same-origin request, and the guard must not read that as your keyboard.
Only enable this behind a proxy that actually sets those headers. With direct internet exposure, or behind something that forwards a caller’s
X-Forwarded-Forverbatim without appending to it, a trusted header lets any client spoof its IP — evading rate limiting, and passing the local-surface guard.
When the proxy sets no forwarded header
A proxy that forwards without X-Forwarded-For — an nginx location block with
no proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; — leaves the
guard with only the socket peer to read, and that peer is the proxy, on
loopback. Every remote browser then passes a check that means “at this machine’s
keyboard”.
ALLOW_DIRECT_LOOPBACK (env) or allowDirectLoopback under [security]
decides what happens there. It defaults to true: an unforwarded request is
judged by its socket peer, exactly as it was before TRUST_PROXY was set, so a
browser that reaches the hub directly — bypassing the proxy on localhost:3001
— keeps the machine page and its installs.
[security]
trustProxy = true
allowDirectLoopback = false
Set it to false when the proxy in front of this hub does not set
X-Forwarded-For and you cannot change that. An unforwarded request is then
refused as client-unverified rather than accepted as local — a distinct reason
from client-not-loopback, so the page says the address could not be
established instead of claiming the caller is somewhere else. Direct requests to
the hub’s own port are refused with it, and the CLI (mangostudio restart,
mangostudio service install) remains the way in.
Systemd Service
[Unit]
Description=MangoStudio
After=network.target
[Service]
Type=simple
User=mangostudio
WorkingDirectory=/opt/mangostudio
ExecStart=/opt/mangostudio/mangostudio serve
Restart=on-failure
RestartSec=5
Environment="HOME=/var/lib/mangostudio"
[Install]
WantedBy=multi-user.target
Smoke Testing
Validate the binary before deployment:
PLATFORM=linux-x64 bun run scripts/test-build.tsThis verifies:
- Binary exists and is executable.
- Frontend assets are present (
index.html, JS, CSS). - Health endpoint responds (
GET /health). - SPA fallback serves
index.htmlfor non-API routes. - API routes are not intercepted by SPA fallback.
- Auth routes return expected responses.