Reflex is the "write your whole web app in Python" framework: your UI code compiles down to a JavaScript bundle, and your Python logic runs on a backend server that holds per-user state and pushes updates to the browser over a WebSocket. Locally, reflex run hides all of that behind one command. In production it stops being one thing — you are actually deploying a static frontend *and* a long-lived WebSocket backend, and they need to end up behind a single URL. Most Reflex deployment pain comes from not internalizing that split. Here's how to handle it end to end.
How Reflex actually runs in production
In development, reflex run starts two processes: a frontend dev server on port 3000 and the Python backend on port 8000. The browser loads the page from 3000 and opens a WebSocket to the backend's /_event route — every event handler you write in Python executes over that socket.
In production you don't ship the dev server. The frontend gets exported to plain static files:
reflex export --frontend-onlyThat produces a frontend.zip of static assets (add --no-zip to get the raw directory). The backend runs separately:
reflex run --env prod --backend-onlySo the deployment problem is: serve the static files, run the backend, and route the WebSocket and upload endpoints (/_event, /ping, /_upload) to the backend — all on one port. We'll solve that with a small Caddy config inside a single container.
Pointing the frontend at the backend
The frontend needs to know where the backend lives, and this gets baked in at export time, not at runtime. Reflex reads it from api_url in rxconfig.py. Since we're serving everything from one origin, api_url is just your app's public URL:
# rxconfig.py
import os
import reflex as rx
db_url = os.getenv("DATABASE_URL", "sqlite:///reflex.db")
# SQLAlchemy rejects the legacy postgres:// scheme — normalize it
if db_url.startswith("postgres://"):
db_url = db_url.replace("postgres://", "postgresql://", 1)
config = rx.Config(
app_name="myapp",
db_url=db_url,
api_url=os.getenv("API_URL", "http://localhost:8000"),
)If you forget API_URL, the exported frontend will try to open a WebSocket to localhost:8000 on your users' machines and the app will render but never respond to clicks. This is the number one Reflex deployment bug, and it's silent — no build error, no crash, just a dead UI.
Wiring PostgreSQL
Reflex ships with SQLModel on top of SQLAlchemy, so models are ordinary Python classes:
import reflex as rx
class Contact(rx.Model, table=True):
name: str
email: strAnd you query them inside state handlers with a session:
from sqlmodel import select
class State(rx.State):
contacts: list[Contact] = []
def load_contacts(self):
with rx.session() as session:
self.contacts = session.exec(select(Contact)).all()The db_url in rxconfig.py above already reads from DATABASE_URL, which is what most managed platforms inject. On PandaStack, when you attach a managed PostgreSQL instance (14.x or 16.x) to your app, DATABASE_URL shows up in the container environment automatically — no copying credentials between dashboards. The only thing you need in requirements.txt beyond reflex is the driver:
reflex
psycopg2-binaryMigrations
Reflex wraps Alembic. Once, locally, initialize the migration scaffolding and commit it:
reflex db initThen for every schema change:
reflex db makemigrations --message "add contact table"
reflex db migrateIn production, run reflex db migrate before the backend starts taking traffic. With a single container the simplest correct place is the container start command (shown in the Dockerfile below). If you ever scale the backend to multiple replicas, move migrations out of the boot path — two instances racing the same Alembic migration on startup is a bad afternoon.
One container, one port: the Caddy trick
Here's a Caddyfile that serves the static export and proxies only the backend routes:
:{$PORT}
encode gzip
@backend_routes path /_event/* /ping /_upload /_upload/*
handle @backend_routes {
reverse_proxy localhost:8000
}
root * /srv
route {
try_files {path} {path}/ /404.html
file_server
}And the Dockerfile that ties it together:
FROM python:3.12
ARG API_URL
ENV API_URL=$API_URL
ENV PORT=8080
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends unzip \
&& rm -rf /var/lib/apt/lists/*
# Caddy binary without a second base image to maintain
COPY --from=caddy:2 /usr/bin/caddy /usr/bin/caddy
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Bakes API_URL into the static frontend
RUN reflex export --frontend-only \
&& unzip -o frontend.zip -d /srv \
&& rm frontend.zip
EXPOSE 8080
CMD ["sh", "-c", "reflex db migrate && caddy start --config Caddyfile && exec reflex run --env prod --backend-only"]Note the ARG API_URL: the export happens during the image build, so the public URL has to be available as a build argument or environment variable *at build time*. Reflex downloads its own Node toolchain during export, which is why I'm using the full python:3.12 image rather than -slim — the slim image is missing pieces the bundler expects, and debugging that inside a build log is not how you want to spend an evening.
Deploying on PandaStack
- 1Provision a managed PostgreSQL instance from the dashboard and attach it to your app —
DATABASE_URLis injected automatically, and therxconfig.pyabove picks it up with the scheme fix applied. - 2Connect the Git repo as a container app. The Dockerfile is detected and built with rootless BuildKit in an ephemeral build pod — you can watch the export and pip install happen in the live build logs, which matters here because
reflex exportis the slowest step and the first place anything breaks. - 3Set
API_URLto your app's public URL (your PandaStack subdomain, or a custom domain once you've added one). Since it's consumed at build time, set it *before* the first real deploy — or redeploy after setting it. Chicken-and-egg is real here: create the app, note the URL, set the variable, push again. - 4Push to Git. Build, deploy, live.
The WebSocket runs over the same HTTPS route as everything else — no special configuration needed for /_event.
Production gotchas worth knowing upfront
One replica unless you add Redis. Reflex keeps per-client state in backend memory by default. Two backend replicas behind a load balancer means a user's WebSocket can land on a server that has never heard of them. If you need horizontal scale, set redis_url in rxconfig.py and point it at a Redis instance; until then, stay at one replica. For most Reflex apps — dashboards, internal tools, small SaaS — one appropriately sized replica is genuinely fine.
Scale-to-zero and WebSockets. On PandaStack's free tier, idle apps scale to zero and cold-start on the next request. For a Reflex app that means the WebSocket drops when the app idles out; Reflex reconnects automatically on the next interaction, but the first user after a quiet period eats the cold start. Fine for hobby projects and demos. For anything with real users, a paid compute tier keeps the backend warm and on stable (non-preemptible) nodes.
State is memory. Every connected client holds a state object on the backend. If your state stores big dataframes or images, memory usage scales with concurrent users — keep heavy data in Postgres and load it per-handler rather than parking it in rx.State.
That's the whole story: export the frontend, proxy three routes, bake in the API URL, migrate before boot. If you want to try it with a managed Postgres already wired in, it takes one repo connect on https://pandastack.io.