Back to Blog
Tutorial10 min read2026-07-05

How to Deploy a Tornado Python App

Tornado is a battle-tested async Python framework built for long-lived connections and WebSockets. Here's how to deploy it to production with the right process model and a clean container.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Tornado predates the ASGI era and has its own async I/O loop, which makes deploying it a little different from FastAPI or Flask. It excels at long-lived connections, WebSockets, and long-polling — workloads where its non-blocking model shines. This guide covers deploying a Tornado app to production correctly.

Tornado's process model

Tornado is both a web framework and a non-blocking web server. Unlike WSGI/ASGI frameworks that rely on Gunicorn or Uvicorn, Tornado runs its own server (tornado.httpserver / IOLoop). You have two deployment styles:

  1. 1Single process, single IOLoop — simplest, but uses one CPU core.
  2. 2Multiple processes — fork the server or run several instances behind a load balancer to use all cores.

Because Tornado is single-threaded per process (cooperative async), you scale across cores with multiple processes, not threads.

A minimal Tornado app

# app.py
import os
import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write({"message": "Hello from Tornado"})

class HealthHandler(tornado.web.RequestHandler):
    def get(self):
        self.set_status(200)
        self.write("ok")

def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
        (r"/health", HealthHandler),
    ])

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8888))
    app = make_app()
    app.listen(port, address="0.0.0.0")
    tornado.ioloop.IOLoop.current().start()

The address="0.0.0.0" is essential in a container, and PORT comes from the platform.

Scaling across cores

For production, fork one process per core using Tornado's bind/start pattern:

import tornado.httpserver
import tornado.netutil

if __name__ == "__main__":
    port = int(os.environ.get("PORT", 8888))
    sockets = tornado.netutil.bind_sockets(port, address="0.0.0.0")
    tornado.process.fork_processes(0)  # 0 = one per CPU
    server = tornado.httpserver.HTTPServer(make_app())
    server.add_sockets(sockets)
    tornado.ioloop.IOLoop.current().start()

A caveat: fork_processes after bind_sockets shares the listening socket across forks, which load-balances connections at the kernel level. This works well on a single container with multiple cores. If your platform tier has a fraction of a CPU (like a free tier), a single process is fine — forking buys you nothing below one full core.

WebSockets behind an ingress

Tornado's killer feature is WebSockets. If you use them, ensure your ingress forwards the Upgrade and Connection headers. Most modern ingresses (including Kong) support WebSocket upgrades by default, but confirm your route allows long-lived connections and the idle timeout is generous enough. Tornado handlers look like:

class EchoSocket(tornado.websocket.WebSocketHandler):
    def on_message(self, message):
        self.write_message(f"echo: {message}")

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 8888
CMD ["python", "app.py"]

Tornado has no heavy native dependencies, so the image stays small. PYTHONUNBUFFERED=1 ensures logs flush immediately to the platform's log collector.

Health checks and readiness

The /health handler gives the platform a cheap readiness probe. Because Tornado handles its own server, make sure the health endpoint never blocks on the event loop — keep it a trivial synchronous response.

Avoiding blocking the event loop

The number-one Tornado production bug is blocking the IOLoop with synchronous I/O. A single blocking database call or requests.get() freezes the entire process for all connections. Always use:

  • await with async clients (e.g. asyncpg, aiohttp, tornado.httpclient.AsyncHTTPClient).
  • IOLoop.run_in_executor() to offload unavoidable blocking calls to a thread pool.

If you're connecting to a managed Postgres, use asyncpg and await your queries:

import asyncpg, os
pool = await asyncpg.create_pool(os.environ["DATABASE_URL"])

Environment variables

VariablePurpose
PORTlisten port, injected
DATABASE_URLinjected if you link a managed DB
TORNADO_ENVyour own flag for prod behavior

Deploying

Commit your app and Dockerfile, push, and connect the repo. The platform builds and deploys, giving you live logs to confirm the IOLoop started and bound to the injected port. If you use WebSockets, test a connection through your custom domain to confirm the upgrade works end to end.

git push origin main

When to choose Tornado

For pure request/response APIs, an ASGI framework like FastAPI or Litestar is more ergonomic today. Tornado earns its place for long-lived connections, server-push, and WebSocket-heavy apps where its mature, self-contained async server is a real asset.

Conclusion

Tornado deploys cleanly once you respect its model: bind to 0.0.0.0 and the injected PORT, fork per core only when you have full cores, never block the IOLoop, and forward WebSocket upgrade headers at the ingress.

Try Tornado on PandaStack's free tier — connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and it builds and deploys with one push, with live logs and a managed database a click away.

References

  • [Tornado Documentation](https://www.tornadoweb.org/en/stable/)
  • [Tornado: Running and Deploying](https://www.tornadoweb.org/en/stable/guide/running.html)
  • [Tornado WebSocket Handler](https://www.tornadoweb.org/en/stable/websocket.html)
  • [asyncpg Documentation](https://magicstack.github.io/asyncpg/current/)
  • [Python Docker Images](https://hub.docker.com/_/python)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also