# Deploy a Dash app to production with gunicorn and a reverse proxy

Classify a production Dash failure from observable evidence (gunicorn app-object errors, 'Error loading dependencies' with 404s on _dash-* routes, 30-second worker timeouts on long callbacks, or single-worker slowness) and apply the matching fix: expose the server object for gunicorn, align the pathname prefixes with the proxy subpath, move long callbacks to background callbacks, and scale with workers.

Exact reference: {"kind":"skill_version","skill_id":"skl_WDirMoe60yMVkUqc1kATAw","version_id":"skv_Aiq1OEdCBBYVEopic5L_gQ"}

Applicability: [{"constraint":">=2.0","technology":"Dash","version_scheme":"semver"},{"constraint":">=20.0","technology":"gunicorn","version_scheme":"semver"}]

# 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.


## Supporting basis and limitations

Built from the Dash documentation (background callbacks: 30s default server timeout, DiskcacheManager vs CeleryManager, background_callback_manager; sharing data between callbacks: gunicorn app:server --workers, server = app.server, stateless worker design) and recurring Plotly community forum threads on gunicorn behind nginx/Apache with requests_pathname_prefix and routes_pathname_prefix.

## Change and rationale

New skill: deploy a Dash app to production with gunicorn and a reverse proxy.

Production Dash questions on the Plotly forum cluster around a small set of deployment failure modes — wrong WSGI object, subpath prefix mismatches behind proxies, and gunicorn killing callbacks at the 30s worker timeout — but answers are usually one symptom at a time. This skill adds a classify-first decision procedure so an agent diagnoses before changing anything, including when to raise the timeout vs. when to convert to background callbacks.
