aiohttp is a foundational async library in the Python ecosystem — it powers both an HTTP client and a server, and many higher-level frameworks build on it. Deploying an aiohttp *server* to production means handling the process model, resource cleanup, and database lifecycle yourself. This guide shows how.
aiohttp's server model
aiohttp provides web.Application and web.run_app(). For production you have two choices:
- 1
web.run_app()— single process, single event loop. Simple, but one core. - 2Gunicorn with the aiohttp worker —
gunicorn ... -k aiohttp.GunicornWebWorkerfor multi-process scaling.
The Gunicorn approach is the standard production setup because it gives you multiple workers and graceful restarts.
A minimal aiohttp app
# app.py
from aiohttp import web
async def index(request):
return web.json_response({"message": "Hello from aiohttp"})
async def health(request):
return web.json_response({"status": "ok"})
def create_app():
app = web.Application()
app.add_routes([
web.get("/", index),
web.get("/health", health),
])
return app
if __name__ == "__main__":
web.run_app(create_app(), host="0.0.0.0", port=8080)The create_app() factory pattern matters because Gunicorn needs an app factory to call per worker.
Database pool with cleanup contexts
aiohttp's cleanup_ctx is the idiomatic way to manage resources that need setup and teardown — perfect for a database pool:
import os
import asyncpg
from aiohttp import web
async def pg_pool(app):
# setup
app["pool"] = await asyncpg.create_pool(
dsn=os.environ["DATABASE_URL"],
min_size=2,
max_size=10,
)
yield
# teardown
await app["pool"].close()
async def get_user(request):
user_id = int(request.match_info["id"])
async with request.app["pool"].acquire() as conn:
row = await conn.fetchrow("SELECT * FROM users WHERE id=$1", user_id)
if not row:
raise web.HTTPNotFound()
return web.json_response(dict(row))
def create_app():
app = web.Application()
app.cleanup_ctx.append(pg_pool)
app.add_routes([web.get("/users/{id}", get_user)])
return appThe code before yield runs at startup; the code after runs at shutdown. This guarantees the pool closes cleanly even on errors. With a managed PostgreSQL linked, DATABASE_URL is injected, and asyncpg accepts the postgres:// DSN as-is.
Running with Gunicorn
gunicorn app:create_app \
--bind 0.0.0.0:8080 \
--worker-class aiohttp.GunicornWebWorker \
--workers 4Because each Gunicorn worker is a separate process with its own event loop, each gets its own database pool via the cleanup_ctx. As always, total connections = workers × max_size — keep it under your DB's limit.
The Dockerfile
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD gunicorn app:create_app \
--bind 0.0.0.0:${PORT:-8080} \
--worker-class aiohttp.GunicornWebWorker \
--workers ${WEB_CONCURRENCY:-2}The shell-form CMD lets ${PORT} and ${WEB_CONCURRENCY} expand from the injected env vars.
Graceful shutdown and on_cleanup
Gunicorn sends SIGTERM on shutdown; aiohttp drains in-flight requests and runs the teardown half of every cleanup_ctx. This means rolling deploys close DB pools and other resources properly. If you open extra resources (a Redis client, an HTTP session), add them as additional cleanup contexts so they're managed identically.
A common mistake: opening a long-lived aiohttp.ClientSession in module scope. Create it in a cleanup context instead, so it binds to the running event loop and closes on shutdown:
async def http_client(app):
app["http"] = aiohttp.ClientSession()
yield
await app["http"].close()Environment variables
| Variable | Purpose |
|---|---|
DATABASE_URL | injected by managed DB link |
PORT | bind port |
WEB_CONCURRENCY | Gunicorn worker count |
Health checks
Point the platform's readiness probe at /health. Keep it lightweight; a SELECT 1 is optional if you want to gate readiness on DB connectivity.
Deploying
Commit the app and Dockerfile, push, connect the repo, link a managed PostgreSQL database, and set env vars. The platform builds and deploys, streaming live logs so you can confirm each worker created its pool and Gunicorn bound to the injected port.
git push origin mainWhen to use aiohttp
aiohttp is low-level by design. Choose it when you want fine-grained control over the request lifecycle, are building a service that's both an HTTP client and server, or want minimal abstraction. For typed, batteries-included APIs, FastAPI or Litestar are more productive — but aiohttp's transparency is a feature for systems work.
Conclusion
aiohttp deploys cleanly with Gunicorn's aiohttp worker, cleanup_ctx for database and client lifecycle, binding to 0.0.0.0 and the injected port, and connection math kept under your DB limit. Its explicit resource management makes shutdown behavior predictable.
Try aiohttp with a managed PostgreSQL on PandaStack's free tier — connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and the database wires itself in automatically.
References
- [aiohttp Server Documentation](https://docs.aiohttp.org/en/stable/web.html)
- [aiohttp: Deployment with Gunicorn](https://docs.aiohttp.org/en/stable/deployment.html)
- [aiohttp: Cleanup Context](https://docs.aiohttp.org/en/stable/web_advanced.html#cleanup-context)
- [asyncpg Documentation](https://magicstack.github.io/asyncpg/current/)
- [Gunicorn Documentation](https://docs.gunicorn.org/en/stable/)