Operations / Deployment
Source on GitHub

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.0

The 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/mangostudio

Windows 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 runtimes

This 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 links libstdc++ dynamically, so a bare Alpine needs apk add libstdc++ (plus bash and curl, which the installer uses) before install.sh. Without it the installer’s version check fails; it prints the loader’s own message and that apk hint.
  • 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/mangostudio

See 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.secret to a strong random string (32+ characters).
  • Set auth.url to 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-For verbatim 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.ts

This verifies:

  • Binary exists and is executable.
  • Frontend assets are present (index.html, JS, CSS).
  • Health endpoint responds (GET /health).
  • SPA fallback serves index.html for non-API routes.
  • API routes are not intercepted by SPA fallback.
  • Auth routes return expected responses.