# Deploy a Dash app to production with gunicorn and a reverse proxy
Use this when a Dash app works locally with `app.run(debug=True)` but breaks in
production: blank pages, "Error loading dependencies" toasts, callbacks that die
after ~30 seconds, or gunicorn not serving the app at all. Classify the failure
from observable evidence FIRST — each symptom maps to a different fix, and
guessing wastes a deploy cycle.
## 1. Classify the failure mode
Run through these in order. Stop at the first match.
- **A. Gunicorn starts but the app object is wrong.** The gunicorn log says
`Failed to find application object 'server'` or `AppImportError`, or the
process exits immediately.
- **B. Page loads but shows "Error loading dependencies"** (bottom-right toast),
or browser dev tools show 404s for `/_dash-layout`, `/_dash-dependencies`, or
`/_dash-component-suites/...`. The app is served under a subpath behind a
reverse proxy (nginx, Apache, a Flask `DispatcherMiddleware`, a cloud
ingress), not at `/`.
- **C. A callback that works locally dies in production after about 30 seconds.**
The gunicorn log shows `[CRITICAL] WORKER TIMEOUT (pid:...)`, and/or the
browser callback error panel shows the update failing. Dash's own docs note
most web servers default to a 30-second timeout.
- **D. The app is slow or unresponsive under a few concurrent users, but each
request works.** You are running one sync worker (gunicorn's default), or
globals are being mutated in callbacks (each worker has its own memory; see
the separate topic on sharing data between callbacks).
## 2. Fix A: expose the WSGI callable and launch gunicorn correctly
Dash wraps a Flask server. The object gunicorn must serve is `app.server`, and
`app.run()`/`app.run_server()` must never be called in production:
```python
# app.py
from dash import Dash
app = Dash(__name__)
server = app.server # <- the WSGI callable gunicorn serves
app.layout = ...
if __name__ == "__main__":
app.run(debug=True) # dev only; never reached under gunicorn
```
Launch with (the Dash docs use these exact forms):
```bash
gunicorn app:server --workers 4 # app.py -> module `app`, object `server`
gunicorn app:server --workers 8 # more concurrent callback capacity
```
`--workers N` starts N independent processes; each callback request is routed
to a free worker. Do not rely on the dev server for anything user-facing.
## 3. Fix B: make Dash's base path agree with the proxy
Dash's frontend requests its own API routes (`/_dash-layout`,
`/_dash-dependencies`) and its JS bundles (`/_dash-component-suites/...`)
relative to the base path it was configured with. When the app lives under a
subpath, both ends must agree on it:
```python
app = Dash(
__name__,
routes_pathname_prefix="/dashboard/", # what the server serves
requests_pathname_prefix="/dashboard/", # what the browser requests
)
```
Set both to the same subpath (trailing slash included). Then verify with curl
— you should get 200, not 404:
```bash
curl -s -o /dev/null -w '%{http_code}' https://your.host/dashboard/_dash-layout; echo
curl -s -o /dev/null -w '%{http_code}' https://your.host/dashboard/_dash-dependencies; echo
```
Also confirm the proxy passes the prefix through instead of stripping it (in
nginx, `proxy_pass` to the backend service on port 8000 with no trailing slash keeps the
prefix; a trailing slash strips it). If you mount Dash inside a larger Flask
app with Werkzeug's `DispatcherMiddleware`, set `requests_pathname_prefix` on
the Dash app to match the mount point.
## 4. Fix C: stop long callbacks from being killed by the worker timeout
Gunicorn's default worker timeout is 30 seconds (`--timeout 30`). A callback
that runs longer gets its worker killed: `[CRITICAL] WORKER TIMEOUT`. You have
two fixes; pick based on whether the job genuinely takes that long.
- **The timeout is just slightly too short and the callback is quick to
optimize:** raise it, e.g. `gunicorn app:server --workers 4 --timeout 120`,
and raise the proxy's read timeout to match (nginx: `proxy_read_timeout 120s;`).
Only do this if the callback is CPU-light; a stuck worker pool blocks every
other request while busy.
- **The callback is genuinely long (seconds to minutes):** move it out of the
request workers with a background callback (`background=True`), backed by a
job queue:
```python
from dash import Dash, DiskcacheManager, CeleryManager, Input, Output, callback
import diskcache, os
if "REDIS_URL" in os.environ:
# production: Celery queue workers, Redis broker
from celery import Celery
celery_app = Celery(__name__,
broker=os.environ["REDIS_URL"],
backend=os.environ["REDIS_URL"])
manager = CeleryManager(celery_app)
else:
# local dev only — the docs explicitly say diskcache is not for production
cache = diskcache.Cache("./cache")
manager = DiskcacheManager(cache)
app = Dash(__name__, background_callback_manager=manager)
@callback(
Output("result", "children"),
Input("run", "n_clicks"),
background=True,
running=[(Output("run", "disabled"), True, False)],
cancel=[Input("cancel", "n_clicks")],
progress=[Output("progress", "children")],
prevent_initial_call=True,
)
def long_job(set_progress, n_clicks):
for i in range(10):
set_progress(f"step {i + 1}/10")
do_work() # your long computation
return "done"
```
`background=True` requires the manager (passed to `Dash(...)` as
`background_callback_manager=` or per-callback as `manager=`). The
`running`, `cancel`, and `progress` arguments are optional UX niceties on top
of the same mechanism.
## 5. Fix D: add workers, never share state in globals
Scale with processes, not threads-of-hope:
```bash
gunicorn app:server --workers 4 # N separate processes, memory NOT shared
```
Dash callbacks must never mutate module-level variables: with `--workers 4`,
one user's request can land on any worker, and a global mutated on worker 1 is
invisible to worker 2. Keep per-request state in `dcc.Store`, a database, or a
cache (Flask-Cache/Redis) keyed by session; read-only globals loaded at import
time are fine.
## 6. Pre-flight checklist before declaring production healthy
1. `gunicorn app:server --workers 4 --timeout 60 --bind :8000` boots
with no `AppImportError` and serves the page.
2. `/_dash-layout` and `/_dash-component-suites/...` return 200 through the
real proxy path (not just loopback).
3. The longest callback finishes comfortably under `--timeout`; anything over
~30s is a background callback.
4. `debug=False` everywhere in the production path (`app.run(debug=True)` is
dev-only).
5. No callback mutates a global; state lives in stores, caches, or a database.