Back to Blog
Tutorial10 min read2026-06-26

How to Deploy an aiohttp Python Service

aiohttp is a low-level async HTTP framework for Python that gives you full control. Learn how to deploy an aiohttp service to production with Gunicorn workers, cleanup contexts, and a database pool.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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. 1web.run_app() — single process, single event loop. Simple, but one core.
  2. 2Gunicorn with the aiohttp workergunicorn ... -k aiohttp.GunicornWebWorker for 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 app

The 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 4

Because 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

VariablePurpose
DATABASE_URLinjected by managed DB link
PORTbind port
WEB_CONCURRENCYGunicorn 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 main

When 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/)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also