How to Deploy an AI Agent: Background Worker vs Web Service (With Working Code)#
An agent that answers in four seconds on a laptop behaves very differently once real users arrive. Picture a support agent that reads a ticket, searches a knowledge base, calls two internal APIs, and drafts a reply. That chain can easily run for ninety seconds. Somewhere between the browser and your code, a gateway gives up at thirty or sixty, the user sees an error, and the agent keeps going anyway, burning tokens for nobody.
Most "how to deploy an AI agent" advice stops at a Dockerfile and a prayer. This guide goes after the part that actually breaks in production: choosing between a web service and a background worker, wiring a queue between them, and putting the whole thing live from a GitHub repo in a handful of clicks on Antryk.
Two ways to run an agent in production#
Every deployed agent ends up as one of two shapes, and sometimes both at once.
A web service listens for HTTP requests and answers them. It gets a public URL, which makes it the natural home for chat endpoints, webhook receivers, and anything a browser or another system needs to call directly. On Antryk, a web service can have a free domain attached, so a working endpoint takes no DNS work.
A background worker has no public address at all. It runs continuously, pulls jobs from a queue, a database, or a scheduler, and works through them at its own pace. Antryk's worker service supports Redis queues, RabbitMQ, PostgreSQL queues, Amazon SQS, and Kafka consumers, plus custom queue code.
| Web service | Background worker | |
|---|---|---|
| Public URL | Yes (free domain available) | No |
| Run longer than a minute | Fragile | Built for it |
| Triggered by | An HTTP request | A queue message, a schedule, a handoff |
| Retries after a failure | You write them | Natural fit with queue retries |
| Good for | Chat endpoints, streaming, webhooks | Research agents, report builders, data pipelines, monitoring agents |
Most production agents use both. A thin web service accepts the request and drops a job onto a queue; the worker does the thinking; the web service hands back the result when it's ready.
Why a long-running agent breaks behind an HTTP request#
Request timeouts are the obvious problem, but the nastier ones hide behind them.
When a proxy cuts the connection at its limit, the client receives a 504 while your process carries on. Nobody reads the output, yet every LLM call is still billed. Worse, a client that retries after the error starts a second copy of the same run. If the agent sends emails, creates tickets, or charges cards, those side effects now happen twice.
Moving the work off the request path fixes all three. The caller gets an immediate acknowledgement with a job ID, the worker owns the run from start to finish, and retries become a deliberate queue feature instead of an accident.
The pattern: accept, queue, work, report#
Four moving parts, none of them exotic:
- 1
The API receives a task and writes a job to the queue with a unique ID.
- 2
The API answers right away with
202 Acceptedand that ID. - 3
The worker pulls the job, runs the agent, and stores the result under the same ID.
- 4
The client polls (or you push a notification) until the status flips to done.
Here is a minimal worker. It uses a plain Redis list so the logic stays readable; swap in your own agent call where the placeholder sits.
# worker.py
import json
import os
import signal
import time
import redis
r = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
QUEUE = "agent:jobs"
DEAD = "agent:dead"
MAX_ATTEMPTS = 3
running = True
def stop(signum, frame):
global running
running = False
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
def run_agent(task: str) -> str:
# Call your LangGraph graph, CrewAI crew, or plain LLM loop here.
# Enforce your own step limit and timeout inside this function.
return f"processed: {task}"
def save(job_id: str, payload: dict) -> None:
r.set(f"agent:result:{job_id}", json.dumps(payload), ex=86400)
while running:
# TTL must be longer than your slowest agent run, or the heartbeat
# will expire mid-job and trigger a false alarm.
r.set("agent:heartbeat", int(time.time()), ex=900)
item = r.blpop(QUEUE, timeout=5)
if item is None:
continue
job = json.loads(item[1])
try:
save(job["id"], {"status": "running"})
output = run_agent(job["input"])
save(job["id"], {"status": "done", "output": output})
except Exception as exc:
job["attempts"] = job.get("attempts", 0) + 1
if job["attempts"] < MAX_ATTEMPTS:
r.rpush(QUEUE, json.dumps(job))
else:
r.rpush(DEAD, json.dumps({**job, "error": str(exc)}))
save(job["id"], {"status": "failed", "error": str(exc)})
And the API that feeds it:
# api.py
import json
import os
import uuid
import redis
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
r = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
class RunRequest(BaseModel):
input: str
@app.post("/runs", status_code=202)
def create_run(body: RunRequest):
job_id = str(uuid.uuid4())
r.rpush("agent:jobs", json.dumps({"id": job_id, "input": body.input}))
return {"id": job_id, "status": "queued"}
@app.get("/runs/{job_id}")
def get_run(job_id: str):
raw = r.get(f"agent:result:{job_id}")
if raw is None:
return {"id": job_id, "status": "queued"}
return json.loads(raw)
@app.get("/health")
def health():
if r.get("agent:heartbeat") is None:
raise HTTPException(status_code=503, detail="worker heartbeat missing")
return {"status": "ok"}
Your requirements.txt needs three lines: redis, fastapi, and uvicorn[standard].
One honest caveat about the worker above. A job popped from a Redis list disappears from the queue the instant it is read, so a worker that crashes mid-run loses that job. For low-stakes work that is acceptable. For anything where a lost job costs money or trust, use a queue that supports acknowledgements (RabbitMQ, SQS, or Redis Streams) and make each run idempotent, meaning a second delivery of the same job ID produces no second side effect.
Deploying the worker on Antryk, step by step#
With the repo pushed to GitHub, GitLab, or Bitbucket, the worker goes live from the dashboard. No Dockerfile appears anywhere in the flow.
- 1
Open Background Workers in the sidebar and choose Deploy Worker.
- 2
Name the service and pick your Git provider, repository, and branch.
- 3
Fill in the build settings. For the Python worker above: install command
pip install -r requirements.txt, start commandpython worker.py, runtime version3.11. The framework is detected automatically. - 4
Choose a region (USA, Europe, or Asia).
- 5
Add environment variables:
REDIS_URLplus your model provider's API key. A.envfile can be imported in one go, and secrets stay out of the repository. - 6
Click Deploy.
From then on, every push to the connected branch triggers a fresh deployment, and a failed deployment never replaces the one that's currently running. Live logs stream in the dashboard with filters for the last hour, six hours, or 24 hours, which is where you'll watch jobs move through retries. Failed workers restart automatically.
For traffic spikes, the scaling settings take a minimum and maximum instance count, queue thresholds, concurrency limits, and scale-down delays. Several workers can consume the same Redis list at once, so the code above scales out without changes. Full details live in the Background Workers documentation.
Adding the web service when you need a public URL#
If the agent is triggered by a schedule or by other backend code, the worker alone is enough. When a browser, a mobile app, or a third-party webhook needs to reach it, deploy api.py as a web service from the same repository (or a second one), point it at the same REDIS_URL, and use the free domain Antryk attaches to get a working HTTPS endpoint. Start commands and ports follow your framework's usual setup.
Keep that API boring. Its entire job is validating input, enqueueing, and reporting status; the thinking stays in the worker.
Which worker plan fits which agent#
Agents that call hosted model APIs spend most of their time waiting on the network, so memory tends to matter more than CPU. These are the worker plans listed on the pricing page at the time of writing:
| Plan | RAM | CPU | Price | Reasonable fit |
|---|---|---|---|---|
| Starter | 512 MB | 0.5 | $4.99/mo | Prototypes, simple single-tool agents |
| Starter Plus | 1 GB | 1 | $7.99/mo | Light agents with a few integrations |
| Standard | 2 GB | 1 | $15.99/mo | First production agent for a small app |
| Pro | 4 GB | 2 | $31.99/mo | Several concurrent runs, heavier frameworks |
| Pro Plus | 8 GB | 2 | $61.99/mo | In-memory document processing, larger contexts |
| Pro Max | 16 GB | 4 | $120.99/mo | High-compute AI workloads, browser automation |
Treat the "reasonable fit" column as a starting point rather than a rule. Begin one tier lower than you expect to need, watch the logs for out-of-memory restarts, and move up when they appear. Running a model locally on the worker rather than calling an API is a different story entirely, since that wants GPU hardware instead of a CPU plan.
One more number deserves daylight: for chatty agents, the model provider's bill usually dwarfs the hosting bill. Log token usage per job from day one.
Giving the agent memory#
Agents that answer from your own documents need somewhere to keep embeddings. Running the vector store next to the worker keeps retrieval latency low and the architecture in one place. Antryk offers managed Qdrant, Milvus, Weaviate, and Chroma deployments, so the worker can reach its retrieval layer without a separate vendor account.
Production checklist for a deployed AI agent#
[ ] Every run has a hard step limit and a wall-clock timeout inside
run_agent[ ] Jobs carry a unique ID, and side effects are idempotent
[ ] Retries are capped, with a dead-letter list for jobs that never succeed
[ ] API keys and credentials live in environment variables, never in the repo
[ ] The worker handles
SIGTERMso a redeploy doesn't cut a run in half[ ] A heartbeat key proves the worker is alive, and
/healthreads it[ ] An uptime monitor watches
/healthand alerts by email, SMS, or webhook[ ] Token usage and cost are logged per job
[ ] Logs are structured JSON so failed runs can be searched quickly
The heartbeat item earns its place. A common failure looks like this: the web service answers every health check with a cheerful 200 while the worker behind it has quietly stopped consuming jobs. Users submit tasks, nothing completes, and no alert fires. Tying /health to the worker's heartbeat, as in the code above, turns that silent stall into a loud one. Antryk's free uptime monitors can watch that endpoint over HTTP and notify you the moment it flips to a 503.
Frequently asked questions#
Can an AI agent run as a background worker? Yes, and for anything slower than a typical request it's the better home. The worker pulls tasks from a queue, runs the agent, and stores the result, with no HTTP timeout hanging over the run.
Do I need Docker or Kubernetes to deploy an AI agent? Not on a platform that builds from your repository. Point it at the repo, set install and start commands, add environment variables, and deploy.
Can one agent be deployed as a web service instead? Certainly, and that works well for agents that answer within seconds or stream tokens to a chat window. Once runs stretch past a minute, move the heavy lifting to a worker behind a queue.
Which frameworks work? Anything that runs as a normal Python, Node.js, Go, Ruby, or Java process: LangGraph, CrewAI, the OpenAI Agents SDK, or a hand-rolled loop around a model API. The platform runs your start command; it doesn't care which library sits inside.
The short version#
Agents fail in production for architectural reasons far more often than model reasons. Keep the request path thin, put long work behind a queue, cap retries and runtime, and watch the worker, not just the web endpoint.
To put your own agent live, the Background Workers docs walk through the dashboard flow, and the worker pricing page shows which plan to start on. Push the repo, set the variables, and click deploy.
