Beta

Run the full Manyfold stack on your own infrastructure — install, upgrades, backups, and the operational contract.

The open-source edition runs the complete stack — API, web workspace, admin console — from one Docker Compose file. Execution environments are brought by you: run mf daemon on machines you own, connect a Kubernetes cluster, or paste a sprites.dev account token in the admin.

Install

git clone https://github.com/manyfold-open/manyfold.git
cd manyfold
cp .env.selfhost.example .env
# set the two required values in .env:
#   MF_API_CRYPTO_KEY   — openssl rand -base64 32
#   MF_AUTH_SETUP_TOKEN — any one-time secret for the first-run setup
docker compose -f docker-compose.selfhost.yml up -d --build

Then open http://localhost:3001/setup, enter the setup token, create the admin account and choose the sign-in methods; the workspace is at http://localhost:3002. New accounts land on the seeded unlimited self_hosted plan.

Warning: MF_API_CRYPTO_KEY is the long-term master key that encrypts stored credentials (provider keys, tokens, login-provider secrets) at rest. Losing it makes those rows undecryptable, so keep it wherever you keep your database backups.

What runs

ServiceImageRole
postgrespostgres:16The only datastore (no Redis)
api-migratebuilt from apps/api/DockerfileOne-shot: applies database migrations, then exits
apisame image as api-migrateNestJS API on :2222, path prefix /api
webbuilt from apps/web/DockerfileUser workspace on :3002
adminbuilt from apps/admin/DockerfileAdmin console on :3001

Start order and migrations

Compose encodes the contract: api-migrate runs the migration journal to completion before the api service starts, and web/admin wait for the API health check. Migrations are forward-only and idempotent — re-running the stack re-applies nothing. You never run SQL by hand.

Health

GET /api/health returns {"status":"ok","db":"ok",...} and is what the compose health check probes. Point your own monitoring at the same URL.

Data and volumes

Everything durable lives in Postgres (pgdata volume). With the default CHAT_UPLOAD_ALLOW_DISK=true, transient chat-upload bytes may also touch the API container’s disk; configure the CHAT_UPLOAD_S3_* variables to move uploads to any S3-compatible bucket instead (required if you ever run more than one API container).

Backups and restore

Back up two things together:

docker compose -f docker-compose.selfhost.yml exec postgres \
    pg_dump -U postgres -Fc manyfold > manyfold-$(date +%Y%m%d).dump
  1. the Postgres dump, and
  2. your MF_API_CRYPTO_KEY (a dump without the key has undecryptable credential rows).

Restore into a fresh stack: start only postgres, pg_restore the dump, then bring up the rest with the same MF_API_CRYPTO_KEY.

Upgrades and downgrades

Upgrade = move the tree forward and rebuild; migrations apply automatically before the new API starts:

git pull
docker compose -f docker-compose.selfhost.yml up -d --build

Downgrade is restore-from-backup: migrations are forward-only, so going back means checking out the older code and restoring the database dump taken before the upgrade.

Plans and quotas

Every limit an account has — how many agents it can provision, how many external-API agents, concurrent sandboxes, storage, channels, automations — comes from the plan its users.plan_id points at. The compose stack sets MF_DEFAULT_PLAN_ID=self_hosted, the seeded unlimited tier, so accounts created on this stack have no practical limits.

MF_DEFAULT_PLAN_ID applies when an account is created and nowhere else. An account created before the deployment set it — the released stack that predates the unlimited plan, or a hand-written compose/Kubernetes manifest that never passed the variable — landed on the cloud free tier and stays there. The symptom is a quota error naming a plan you never chose:

External API limit reached (3 for Free plan)

The API repairs this once, on the first start after upgrading: on a deployment with no billing module and MF_DEFAULT_PLAN_ID set to something other than free, every account still on free moves to that plan. It runs exactly once and is recorded in app_settings, so a later deliberate assignment is never overwritten.

For anything the one-shot repair doesn’t cover — an account created after it ran, or a deployment that wants different tiers per user — the admin console’s user detail page has a Plan card. To check the current assignment directly:

docker compose -f docker-compose.selfhost.yml exec postgres \
  psql -U postgres -d manyfold -c \
  "select u.email, u.plan_id, p.max_agents_provisioned
     from users u join plans p on p.id = u.plan_id;"

Set MF_SELFHOST_DEFAULT_PLAN_ID in .env to put new accounts on a different seeded plan (free, hobby, plus, pro) instead.

Serving beyond localhost

Two things must change when browsers reach the stack from anywhere but the machine it runs on:

  • Baked URLs. The web and admin bundles bake the API base URL at build time. Set MF_SELFHOST_API_URL (plus the MF_SELFHOST_*_URL variables) to the URLs browsers will use, then rebuild (up -d --build).
  • CORS. With CORS_ORIGIN unset the API reflects any origin (fine on localhost). When exposing the API, set MF_SELFHOST_CORS_ORIGIN to the exact web + admin origins, e.g. https://app.example.com,https://admin.example.com.

Terminate TLS in your reverse proxy of choice and forward to the three ports; the API needs WebSocket forwarding (daemon connections and terminals ride WS).

Email (SMTP)

Email is runtime configuration, not env: Admin → Settings → Email provider takes the SMTP host, port, and TLS mode, and everything that sends mail (sign-up verification, invites) uses it. Without a provider configured the features that need mail say so instead of failing silently.

Account deletion

Deletion is admin-only: Admin → Users → user detail → Danger zone. Requesting a deletion deactivates the account immediately — every session is revoked, sign-in is blocked on all providers, automations are paused, keep-alive stops — and the user gets an email with the final deletion date.

The hard delete runs after a grace window (default 30 days, MF_DELETION_GRACE_DAYS). During the grace period an admin can restore the account: the sign-in block is lifted, but automations stay paused until re-enabled. “Execute now” skips the remaining wait behind a second confirmation.

When the deadline passes, a background sweep first tears down the user’s runtimes (sandbox VMs are deleted, Kubernetes namespaces removed; daemon machines are the user’s own — their files are untouched, only the tokens die) and channel registrations, then deletes the user row, which removes every user-owned table via ON DELETE CASCADE. Self-hosted installs run exactly that: pure cascade plus the sign-in gates, with no billing hooks. The user_deletions audit row (bare user id, no PII) survives the delete as the durable record; a failed sweep records its error there and retries automatically.

Execution environments

Agents run on computers you attach, three ways:

  • mf daemon (default) — install the CLI, then mf login --api-url https://<your-api>/api and mf setup on any machine you own. CLI and daemons on a self-hosted deployment walks the whole flow; Local daemons covers registration in detail.
  • Kubernetes — add a kubeconfig in the API env to run gateway/cronjob-class frameworks; deploy the in-cluster exec gateway with the Helm chart at apps/k8s-gateway/helm/manyfold-k8s-gateway (its README covers the MF_K8S_GATEWAY_URL / MF_K8S_GATEWAY_TOKEN wiring).
  • sprites.dev — Admin → Infrastructure → Stateful sandbox accounts: paste a sprites.dev account token to run coding agents on rented VMs; concurrency follows the account’s vendor limits.

Key rotation

Rotate MF_API_CRYPTO_KEY by moving the old key to API_CRYPTO_KEY_V0 (decrypt-only) and setting the new key as API_CRYPTO_KEY. Keep the old key configured until no stored row still records key version 0; the .env.example in the repo documents the same flow for non-compose runs.

Was this page helpful?