SokkoSokko
← Back to blog

Deploy CrewAI Agents to Production: A Hands-On Guide

Sokko Team11 min read

You built a CrewAI crew, called crew.kickoff(), and watched agents research, write, and hand off tasks in your terminal. That is the fun part. The harder part is when a teammate asks to trigger the same crew from their app, and you realize a script on your laptop is not a service. This tutorial shows how to deploy CrewAI agents to production as a real HTTP service, with the plumbing that keeps it running when you close the lid.

We will move from a local script to a containerized API with background execution, secrets handling, health checks, and a place to run it around the clock. Every step comes with code you can adapt.

Why the local-to-prod jump breaks

A local crew script works because you are the runtime. You set the environment variables, you press enter, you watch the logs, and you restart it when it crashes. Production has none of those assumptions. Here is what actually breaks when you lift a crew.kickoff() script off your machine:

  1. No API surface. A main.py that runs on import cannot be triggered by another service. There is no endpoint, no request, no response.

  2. Secrets live in .env. That file is on your disk, not in the container, and copying it into an image leaks keys into every layer.

  3. Nothing restarts it. When the process exits, on a crash or an OOM kill, it stays dead until you notice.

  4. Long tasks block the request. A crew that takes ninety seconds will blow past most HTTP timeouts if you run it inside the request handler.

Each of these has a standard fix. Wrap the crew in an API, push long work to a background worker, containerize the whole thing, read secrets from the environment, and run the container somewhere that restarts it for you. If you are still choosing between frameworks before committing, our writeup on AI agent frameworks compared covers the tradeoffs; this guide assumes you have already picked CrewAI.

Wrap the crew behind a FastAPI service

The first move is to give your crew an HTTP surface so other systems can call it. FastAPI is a good fit: it is fast, it validates request bodies with Pydantic, and it plays well with async background work. Start by isolating your crew definition in its own module so the API layer stays thin.

# crew_app/research_crew.py
from crewai import Agent, Crew, Task, Process

def build_crew(topic: str) -> Crew:
    researcher = Agent(
        role="Research Analyst",
        goal=f"Find accurate, current facts about {topic}",
        backstory="A meticulous analyst who cites sources.",
        verbose=True,
    )
    writer = Agent(
        role="Technical Writer",
        goal="Turn research into a clear brief",
        backstory="Writes tight, concrete prose.",
        verbose=True,
    )

    research_task = Task(
        description=f"Research {topic}. List key facts with sources.",
        expected_output="A bulleted list of facts with source URLs.",
        agent=researcher,
    )
    write_task = Task(
        description="Write a 200-word brief from the research.",
        expected_output="A 200-word brief.",
        agent=writer,
    )

    return Crew(
        agents=[researcher, writer],
        tasks=[research_task, write_task],
        process=Process.sequential,
    )

Now the API. Keep the synchronous version first so you can see the shape, then we will fix the blocking problem in the next section.

# crew_app/main.py
from fastapi import FastAPI
from pydantic import BaseModel
from crew_app.research_crew import build_crew

app = FastAPI(title="Crew Service")

class RunRequest(BaseModel):
    topic: str

@app.post("/run-sync")
def run_sync(req: RunRequest):
    crew = build_crew(req.topic)
    result = crew.kickoff()
    return {"result": str(result)}

This already beats a bare script. Any service can POST a topic and get a result. The FastAPI docs at https://fastapi.tiangolo.com are worth a skim if request validation or dependency injection is new to you. But /run-sync has a real flaw: the crew can run for a minute or more, and the HTTP connection is held open the whole time. Load balancers, proxies, and browsers will cut it off.

Move long crew runs to a background worker

A crew run is a job, not a request. The clean pattern is to accept the request, hand the work to a background worker, return a job ID immediately, and let the caller poll for the result. This is how you deploy CrewAI agents to production without fighting timeouts.

For heavy or bursty traffic, use a real task queue like Celery or RQ backed by Redis. For a single service with modest volume, FastAPI's built-in BackgroundTasks plus a small status store is enough to start. Here is the lightweight version with an in-memory job table. Swap the dict for Redis or a database once you have more than one worker process.

# crew_app/main.py
import uuid
from fastapi import FastAPI, BackgroundTasks, HTTPException
from pydantic import BaseModel
from crew_app.research_crew import build_crew

app = FastAPI(title="Crew Service")
JOBS: dict[str, dict] = {}

class RunRequest(BaseModel):
    topic: str

def run_crew_job(job_id: str, topic: str) -> None:
    JOBS[job_id]["status"] = "running"
    try:
        crew = build_crew(topic)
        result = crew.kickoff()
        JOBS[job_id].update(status="done", result=str(result))
    except Exception as exc:
        JOBS[job_id].update(status="failed", error=str(exc))

@app.post("/run")
def run(req: RunRequest, background: BackgroundTasks):
    job_id = str(uuid.uuid4())
    JOBS[job_id] = {"status": "queued", "result": None}
    background.add_task(run_crew_job, job_id, req.topic)
    return {"job_id": job_id, "status": "queued"}

@app.get("/jobs/{job_id}")
def get_job(job_id: str):
    job = JOBS.get(job_id)
    if job is None:
        raise HTTPException(status_code=404, detail="job not found")
    return job

The caller now gets an instant job_id, then polls GET /jobs/{job_id} until the status flips to done or failed. The crew runs outside the request cycle, so a two-minute research run no longer trips a thirty-second proxy timeout.

One caveat about BackgroundTasks: it runs in the same process as your web server. If that process restarts mid-job, the job is lost, and an in-memory dict does not survive a restart either. That is the signal to graduate to Celery or RQ with a persistent broker, where a separate pool of workers pulls jobs and results land in Redis. Reach for the queue when you need retries, multiple workers, or durability across deploys.

Containerize with a Dockerfile

A container makes your crew run the same way on your laptop, in CI, and in production. Pin your Python version, install dependencies in their own layer so rebuilds are cheap, and run as a non-root user. Here is a Dockerfile that does that.

FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
    PYTHONDONTWRITEBYTECODE=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

# Dependencies first for layer caching
COPY requirements.txt .
RUN pip install -r requirements.txt

# App code
COPY crew_app ./crew_app

# Run as non-root
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "crew_app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Your requirements.txt pins the versions that matter:

crewai==0.80.0
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.0

Build and run it locally to confirm it starts cleanly:

docker build -t crew-service .
docker run -p 8000:8000 --env-file .env crew-service

Note the --env-file .env flag. The .env file feeds variables into the running container at start time; it never gets copied into the image. That is deliberate, and the next section explains why.

Manage secrets as environment variables

Your crew needs an LLM API key, and maybe keys for search tools or a vector store. Never bake these into the image. Anything you COPY or hardcode ends up in a layer that anyone with the image can read, and it will follow the image into every registry it touches.

The rule is simple: read every secret from the environment at runtime. CrewAI and the underlying LLM clients already look for keys like OPENAI_API_KEY in the environment, so often you just need to make sure the variable is present. For anything custom, read it explicitly and fail fast if it is missing.

# crew_app/config.py
import os

def require(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required env var: {name}")
    return value

OPENAI_API_KEY = require("OPENAI_API_KEY")
SERPER_API_KEY = os.environ.get("SERPER_API_KEY")  # optional tool key
MODEL_NAME = os.environ.get("MODEL_NAME", "gpt-4o-mini")

Locally you keep values in .env (git-ignored) and pass them with --env-file. In production you inject them through your platform's secret store, never through a committed file. On Kubernetes that means a Secret mounted as environment variables:

apiVersion: v1
kind: Secret
metadata:
  name: crew-secrets
type: Opaque
stringData:
  OPENAI_API_KEY: "sk-your-real-key"
  MODEL_NAME: "gpt-4o-mini"

The deployment then references that secret with envFrom, so the container sees the keys as ordinary environment variables without them ever landing in the image or the repo. If you are running the cluster yourself, our walkthrough on deploying an AI agent on Kubernetes covers the deployment and service manifests that go alongside this secret.

Add health checks, logging, retries, and cost controls

A production service needs to tell your orchestrator whether it is alive, and it needs to keep spending under control. These are the details that separate a demo from something you trust overnight.

Start with a health endpoint. Container platforms probe it to decide whether to route traffic or restart the pod.

@app.get("/healthz")
def healthz():
    return {"status": "ok"}

Structured logging comes next. Print JSON lines so your log aggregator can parse them, and log the job ID on every line so you can trace a single run.

import logging, json

logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger("crew")

def log_event(job_id: str, event: str, **fields):
    log.info(json.dumps({"job_id": job_id, "event": event, **fields}))

Retries matter because LLM APIs return transient errors: rate limits, timeouts, the occasional 500. Wrap the crew call so a blip does not kill the whole job. The tenacity library keeps this short.

from tenacity import retry, wait_exponential, stop_after_attempt

@retry(wait=wait_exponential(min=2, max=30), stop=stop_after_attempt(3))
def kickoff_with_retry(crew):
    return crew.kickoff()

Cost controls are the part people skip until the first surprise bill. An agent loop that reasons in circles can burn tokens fast. Two guardrails help: cap the crew's iterations and cap tokens per call. CrewAI exposes max_iter on agents and max_rpm on the crew, and the LLM config takes a token ceiling.

from crewai import Agent, LLM

llm = LLM(model="gpt-4o-mini", max_tokens=1500, temperature=0.2)

researcher = Agent(
    role="Research Analyst",
    goal="Find accurate facts",
    backstory="Meticulous and concise.",
    llm=llm,
    max_iter=5,   # cap reasoning loops
)

Set max_rpm on the Crew to throttle requests per minute, and pick the smallest model that does the job. A cheaper model for routine tasks, reserved for a stronger one only where quality demands it, is the single biggest lever on cost. The CrewAI docs at https://docs.crewai.com list every knob these objects expose.

Here is a quick reference for the production concerns and where each one lives:

ConcernMechanismWhere it lives
Trigger the crewFastAPI POST /runAPI layer
Avoid timeoutsBackground worker + job pollingWorker / queue
LivenessGET /healthz probeAPI layer
Transient errorstenacity retriesCrew call wrapper
Cost ceilingmax_iter, max_tokens, max_rpmAgent / LLM / Crew config
SecretsEnv vars from a secret storeRuntime environment

Deploy CrewAI agents to production on always-on infrastructure

You now have a container that exposes an API, runs crews in the background, reads secrets from the environment, and reports its health. The last requirement is a host that stays up and restarts the process when it dies.

A plain virtual machine is the most direct option: provision it, install Docker, run the container with --restart=unless-stopped, and point a domain at it. That works, and our notes on the best VPS for AI agents cover sizing the box for agent workloads. The tradeoff is that you own the babysitting: OS patches, restarts after a reboot, secret rotation, and log collection are all on you.

A managed runtime removes that maintenance for the always-on assistant case, and this is where Sokko fits, with one honest boundary: Sokko doesn't host your custom crew container. It deploys one of four ready-made agent runtimes (OpenClaw, Hermes, Paperclip, OpenSRE) from the dashboard in a couple of clicks, with saved files that survive restarts, secure key storage, live logs, and a private URL, and you bring your own model keys or use Sokko credits. Auto-restart is built in, so a crashed agent comes back on its own. If what you need is an always-on assistant rather than your own CrewAI codebase, that replaces the DIY build entirely; your custom crew container, on the other hand, belongs on a VPS or a container platform you run, and the checklist below is what keeps it healthy there.

Whichever host you choose, the checklist to deploy CrewAI agents to production is the same:

  1. Wrap the crew in a FastAPI service with a clear POST /run trigger.

  2. Run long crews in a background worker and poll for results.

  3. Containerize with a pinned, non-root Dockerfile.

  4. Read every secret from the environment, never from the image.

  5. Add health checks, structured logs, retries, and token or iteration caps.

  6. Run the container somewhere that restarts it automatically.

Work through that list and your crew stops being a script you babysit and becomes a service other systems can rely on. Start with the FastAPI wrapper, get the background worker right, and the rest of deploying CrewAI agents to production falls into place from there.