Deploying to production
Bundle a Tutti score and ship it to Docker, Railway, Fly, Modal, or Daytona with one command.
Tutti ships a deploy bundler — @tuttiai/deploy — and a CLI front-end on it. One command takes a score from tutti-ai run to a running service on your platform of choice. The bundle includes whatever the target needs — a Dockerfile + docker-compose.yml for the container targets, a modal_app.py for Modal, a devcontainer + snapshot config for Daytona — generated from a DeployConfig block on your agent.
v0.26 adds two serverless targets — Modal (scale-to-zero functions) and Daytona (always-warm dev sandboxes) — alongside the original Docker / Railway / Fly. Cloudflare Workers is recognised as a DeployTarget value but no bundler ships yet; the CLI errors loudly rather than half-deploying.
Prerequisites
- A Tutti score that runs locally with
tutti-ai runortutti-ai serve - The platform CLI installed:
- Docker —
docker compose(built in) - Railway —
npm i -g @railway/cli && railway login - Fly —
curl -L https://fly.io/install.sh | sh && fly auth login - Modal —
pip install modal && modal token new - Daytona —
brew install daytonaio/cli/daytona && daytona auth login
- Docker —
Step 1: Add a deploy block to one agent
Pick the agent you want to ship and add a deploy block. Exactly one agent in the score should declare it — that’s the deploy entrypoint.
import { defineScore, AnthropicProvider } from "@tuttiai/core";
import { GitHubVoice } from "@tuttiai/github";
export default defineScore({
provider: new AnthropicProvider(),
agents: {
api: {
name: "api",
model: "claude-sonnet-4-6",
system_prompt: "You are a customer-facing API agent.",
voices: [new GitHubVoice()],
permissions: ["network"],
deploy: {
target: "fly", // optional — --target wins
region: "auto",
scale: { min: 0, max: 3, memory: "512MB" },
health: { path: "/health", interval_seconds: 30 },
env: { LOG_LEVEL: "info" }, // plaintext config
secrets: ["ANTHROPIC_API_KEY", "GITHUB_TOKEN"], // names of required env vars
},
},
},
});
The secrets array names every env var the runtime needs — the deploy bundler emits a matching .env.deploy.example next to your score so missing-vars are obvious before the platform rejects them.
Step 2: Pre-flight check
tutti-ai deploy --target railway --dry-run
The dry run prints the deploy plan without invoking the platform CLI. Two static analyses run before any I/O:
-
scanForSecrets()walks your score and every imported package’s entry file forprocess.env.Xreads. It filters Node built-ins (PATH,HOME,NODE_ENV, etc.) and emits errors for undeclared required vars. If your code readsprocess.env.STRIPE_SECRET_KEYbutdeploy.secretsdoesn’t list it, the deploy fails fast with a clear message. -
validateSecrets()warns on secret-shaped names (*_KEY,*_TOKEN,*_SECRET) sitting in plaintextdeploy.env. Plaintext API keys block the deploy fail-fast — the platform’s secret store is the right place for them, not your score file.
If the dry-run is green, drop --dry-run to deploy for real.
Step 3: Deploy
tutti-ai deploy --target railway # bundle + railway up
Tutti generates the bundle in ./tutti-deploy/, writes .env.deploy.example, and dispatches to the platform CLI. On success it prints the deploy URL; on failure it surfaces the platform’s error and exits non-zero.
Targets
| Target | Generated files | Platform command dispatched |
|---|---|---|
docker | Dockerfile, .dockerignore, docker-compose.yml, deploy.sh | docker compose build && docker compose up -d |
railway | All Docker files + railway.json | railway up |
fly | All Docker files + fly.toml | fly deploy |
modal | modal_app.py, tutti.score.ts, .env.modal.example, deploy.sh | modal deploy modal_app.py |
daytona | .devcontainer/devcontainer.json, .daytona/snapshots.yaml, .gitignore, daytona.sh | daytona create --no-ide |
The Docker bundle runs as non-root user tutti (uid 1001), exposes port 3000 (configurable), and includes a healthcheck against the health.path you set. NODE_OPTIONS --max-old-space-size is computed from scale.memory so the runtime won’t OOM under platform-imposed memory caps.
Modal (scale-to-zero)
tutti-ai deploy --target modal runs the Tutti Node server inside node:20-bookworm-slim — Debian’s apt nodejs is Node 18 and too old. The generated modal_app.py installs tutti-ai@latest + @tuttiai/cli@latest (pinnable via the CLI_VERSION constant before modal deploy), declares each manifest.secrets entry as modal.Secret.from_name("tutti-<lowercase>"), surfaces manifest.env as the function’s env= dict, and exposes the server via @modal.web_server(port=3000, startup_timeout=120). manifest.scale.memory ("512mb" / "2gb") is converted to the integer Modal expects on memory=, defaulting to 2048.
Because Modal redeploys are functions, not slot-based releases, tutti-ai deploy rollback is reported as not applicable. deploy status dispatches to modal app list; deploy logs to modal app logs <name>.
Modal is a serverless target — between invocations the function hibernates. Anything held in-process memory (a non-persistent session store, a setInterval, a long-lived voice client cache) silently evaporates. Run tutti-ai deploy verify-hibernate on your score first; it checks for stores and timers that wouldn’t survive the next cold start.
Daytona (always-warm dev environment)
The Daytona bundle stands up a persistent dev environment from mcr.microsoft.com/devcontainers/typescript-node:22, installs tutti-ai via postCreateCommand, and forwards port 3000. After daytona create finishes, daytona.sh SSHes in and starts tutti-ai serve. The emitted .daytona/snapshots.yaml requests idle-hibernation (5 min) and auto-resume — its field names are not yet verified against the current Daytona schema, so the file ships with a TODO banner; treat hibernation as best-effort until that’s confirmed.
Cloudflare Workers (recognised, not yet wired up)
DeployTarget includes "cloudflare" so a score can declare it ahead of time, but no bundler ships in v0.26. The CLI errors loudly with cloudflare deployment is not yet wired up in the CLI. Pass —target docker|railway|fly|modal|daytona to override. rather than half-deploying.
Conditional services
The bundler reads your score’s memory.provider and per-agent durable.store to decide whether to add postgres or redis services:
memory.provider: "postgres"→ adds a postgres service todocker-compose.yml, setsDATABASE_URLin env.durable.store: "redis"→ adds a redis service, setsTUTTI_REDIS_URL.
You can override either by setting the env var explicitly in deploy.env or by attaching a managed equivalent on the platform side.
Step 4: Operate
Once shipped, the same CLI manages day-to-day operations:
tutti-ai deploy status # platform-equivalent status check
tutti-ai deploy logs --tail # follow logs
tutti-ai deploy rollback # roll back to the previous release
Each subcommand dispatches to the matching platform command (fly status, railway logs, etc.) so you don’t need to remember the platform’s exact verbs.
Cost monitoring after deploy
Once your service is running, configure a RunCostStore so the cost-analysis CLI can read live data:
import { TuttiRuntime, PostgresRunCostStore } from "@tuttiai/core";
const runtime = new TuttiRuntime(score, {
runCostStore: new PostgresRunCostStore({
connection_string: process.env.DATABASE_URL!,
}),
});
Then from your laptop:
tutti-ai analyze costs --last 7d --url https://api.example.com
Top runs by cost, daily-spend sparkline, and burn-rate optimisation hints. See the CLI reference for the full command surface.
What’s not yet shipped
- Cloudflare Workers bundler. The target value is recognised but the bundler is a follow-up. Until then, the CLI rejects
--target cloudflarewith a clear error. - AWS / GCP / Azure — not yet. The bundler is structured around platforms with a single deploy verb; the cloud providers need an opinion about ECS vs Lambda vs Fargate, GKE vs Cloud Run, etc., that we haven’t picked yet. For now use the Docker bundle and ship via your existing infra-as-code.
- Multi-region —
region: "auto"picks one region. Multi-region active-active is platform-specific (Fly supports it natively, others don’t) and not abstracted yet. - Blue/green — rollback is single-step, not slot-based. We rely on the platform’s release history rather than maintaining two deployments.
- Modal rollback — Modal redeploys are functions, not slot-based releases;
tutti-ai deploy rollbackreports as not applicable on Modal.