Back to Blog
Tutorial6 min read2026-07-11

Deploy a Chainlit App to Production: Docker, Postgres, WebSockets

Ship a Chainlit chat app for real users: headless mode, WebSocket gotchas, persistent chat history on Postgres, and going live with a Git push.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

Chainlit gets you from "I have an LLM function" to "I have a chat UI" in about ten lines of Python. That speed is exactly why so many Chainlit apps never make it past localhost:8000 — the local dev experience is so smooth that the production gaps (headless mode, WebSockets, session state, chat persistence) only show up when you actually try to deploy. Here's the full path, end to end.

The app we're deploying

A minimal but realistic Chainlit app:

# app.py
import chainlit as cl
from openai import AsyncOpenAI

client = AsyncOpenAI()  # reads OPENAI_API_KEY from the environment

@cl.on_chat_start
async def start():
    cl.user_session.set("history", [])

@cl.on_message
async def main(message: cl.Message):
    history = cl.user_session.get("history")
    history.append({"role": "user", "content": message.content})

    response = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=history,
    )
    reply = response.choices[0].message.content
    history.append({"role": "assistant", "content": reply})

    await cl.Message(content=reply).send()

And a requirements.txt:

chainlit
openai
asyncpg
SQLAlchemy

Locally you run it with hot reload:

pip install -r requirements.txt
chainlit run app.py -w

First run generates a .chainlit/config.toml (UI settings, feature flags) and a chainlit.md (the welcome screen). Commit both — the config file is part of your app, not scratch output.

Production gotcha #1: run headless

chainlit run tries to open a browser tab on start. In a container there is no browser, and depending on the environment this can hang or log errors on boot. The production start command needs three flags:

chainlit run app.py --host 0.0.0.0 --port 8000 --headless
  • --host 0.0.0.0 — bind all interfaces so the platform's router can reach the process. The default localhost bind is the classic "works in Docker locally, 502 in production" bug.
  • --port 8000 — Chainlit's default; make it explicit so it matches your platform config.
  • --headless — don't try to open a browser.

Production gotcha #2: WebSockets and replicas

Chainlit's UI talks to the backend over Socket.IO — a WebSocket connection with HTTP polling fallback. Two consequences:

  1. 1Your platform must pass WebSocket upgrades through. Most modern container platforms do; classic setups with an old nginx config in front often don't. If messages send but responses never stream in, this is the first thing to check.
  2. 2Session state lives in the process. cl.user_session is in-memory. If you run two replicas behind a round-robin load balancer without sticky sessions, a user's socket can land on a pod that has never seen their session. For most Chainlit apps the honest answer is: run one replica. A single instance handles a lot of concurrent chats because the work is async I/O waiting on an LLM API, not CPU.

Persisting chat history in Postgres

Out of the box, chat history evaporates when the process restarts. Chainlit ships a data layer abstraction for exactly this, with a SQLAlchemy implementation that works against Postgres:

# add to app.py
import os
import chainlit as cl
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer

@cl.data_layer
def get_data_layer():
    # Chainlit's SQLAlchemy layer wants the async driver in the URL
    conninfo = os.environ["DATABASE_URL"].replace(
        "postgresql://", "postgresql+asyncpg://", 1
    )
    return SQLAlchemyDataLayer(conninfo=conninfo)

Two things to know before this works:

  • The data layer expects Chainlit's schema (tables for users, threads, steps, elements, feedback) to already exist. Chainlit publishes the SQL schema in its data-layer documentation — run it against your database once, as a setup step. Treat it like a migration, not something the app creates at boot.
  • Persistence pairs with authentication: threads are stored per user, so you'll want one of Chainlit's auth mechanisms enabled. Whichever you pick, Chainlit requires a signing secret:
chainlit create-secret
# → prints a CHAINLIT_AUTH_SECRET value; set it as an env var, never commit it

The Dockerfile

Chainlit apps don't need anything exotic — a slim Python base is fine:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["chainlit", "run", "app.py", "--host", "0.0.0.0", "--port", "8000", "--headless"]

One habit worth keeping: pin your Chainlit version in requirements.txt (e.g. chainlit==1.x.y at whatever you developed against). The framework moves fast, and an unpinned rebuild months later picking up a new major version is a bad way to discover API changes.

Deploying on PandaStack

This is where the pieces stop being your problem:

  1. 1Provision a managed PostgreSQL instance (14.x or 16.x) from the dashboard. This is what backs the data layer above.
  2. 2Connect your repo as a container app. PandaStack detects the Dockerfile and builds it — rootless BuildKit in an ephemeral Kubernetes Job pod, image pushed to a registry, deployed via Helm. You can watch the build logs stream live, which matters more than it sounds when a pip install fails on a system dependency.
  3. 3Attach the database to the app. DATABASE_URL is injected into the container automatically — no copying connection strings between dashboards, no credentials in your repo. The one-line .replace() in the data-layer snippet adapts it for asyncpg.
  4. 4Set your secrets as environment variables: OPENAI_API_KEY and CHAINLIT_AUTH_SECRET. Environment variables are first-class in the dashboard; the app reads them the same way it did locally.
  5. 5Run the schema SQL once against the managed database to create Chainlit's data-layer tables, then push.

After that, going live is git push. Every push builds and deploys; if a deploy goes sideways, you roll back to the previous one from the deployment history.

A note on the free tier

Free-tier apps on PandaStack scale to zero when idle and wake on the next request. For a Chainlit demo or internal tool, that's usually the right trade — you pay nothing while nobody's chatting, and the first visitor eats a cold start while the container spins up. For a customer-facing assistant where the first impression is a spinner, move it to a paid compute tier and it stays warm. The free tier also runs on preemptible nodes, which is fine for a stateless Chainlit process (state is in Postgres now) but another reason production traffic belongs on a stable tier.

Checklist before you call it done

  • --headless, --host 0.0.0.0, explicit --port in the start command
  • Chainlit version pinned in requirements.txt
  • One replica (or sticky sessions if you genuinely need more)
  • Data layer wired to Postgres, schema applied, auth secret set
  • OPENAI_API_KEY and CHAINLIT_AUTH_SECRET set as env vars, not in code
  • WebSocket traffic verified end to end — send a message, watch it stream back

None of these steps is hard; the trap is that local dev never forces you through any of them. If you want the database, build pipeline, and routing handled for you, it's a short afternoon to stand this up on https://pandastack.io.

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also