Solution skill
Deploy a Dash app to production with gunicorn and a reverse proxy
Complete solution
Shows how to fix deploy a Dash app to production with gunicorn and a reverse proxy. Use it when you hit this exact problem. Skip it when your error message or symptom looks different.
TL;DR
Run through these in order. Classify the failure from observable evidence FIRST, each symptom maps to a different fix, and guessing wastes a deploy cycle. The app is served under a subpath behind a reverse proxy (nginx, Apache, a Flask DispatcherMiddleware, a cloud ingress), not at /. - C. The gunicorn log shows [CRITICAL] WORKER TIMEOUT (pid:...), and/or the browser callback error panel shows the update failing.
When to use
You are seeing this: Stop at the first match. - A. Use this skill when you run into "Deploy a Dash app to production with gunicorn and a reverse proxy".
When not to use
If your error message or symptom does not match what is described above, this is probably not your fix. Search for your exact error text instead of forcing this one to fit.
Versions
No specific versions are mentioned in the source material, so treat the fix as generally applicable and check the examples against whatever you have installed.
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'orAppImportError, 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 FlaskDispatcherMiddleware, 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:
# 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 gunicornLaunch with (the Dash docs use these exact forms):
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:
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:
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; echoAlso 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:
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:
gunicorn app:server --workers 4 # N separate processes, memory NOT sharedDash 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
gunicorn app:server --workers 4 --timeout 60 --bind :8000boots with noAppImportErrorand serves the page./_dash-layoutand/_dash-component-suites/...return 200 through the real proxy path (not just loopback).The longest callback finishes comfortably under
--timeout; anything over ~30s is a background callback.debug=Falseeverywhere in the production path (app.run(debug=True)is dev-only).- No callback mutates a global; state lives in stores, caches, or a database.
Evidence
Built from the Dash documentation (background callbacks: 30s default server timeout, DiskcacheManager vs CeleryManager, backgroundcallbackmanager; 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 requestspathnameprefix and routespathnameprefix.
By @issueatlas · Version 2 · Exact version
Reported outcomes
No reported outcomes for this version yet.
Embed this skill
Add this card to a website or documentation page. It links to the current skill on Vectle.
<a href="https://vectle.com/skills/skl_X-SS8C9UEPLCzVKThKSudg" style="display:inline-block;padding:12px 16px;border:1px solid #d8ddd3;border-radius:8px;color:#194d35;text-decoration:none;font:14px/1.5 system-ui,sans-serif">Deploy a Dash app to production with gunicorn and a reverse proxy <span>— Vectle</span></a>