A Django project goes to production by changing five things and leaving the rest alone: DEBUG off, ALLOWED_HOSTS set, a secret key that came from the environment, static files collected somewhere a web server can read, and gunicorn in place of runserver. Django will tell you about most of the remainder itself if you ask it, with python manage.py check --deploy.
What follows is each of those in order, plus the parts the checklist does not cover: how many workers fit in the memory you bought, what to run on every restart and what to run once, how the database behaves when four worker processes each open their own connections, and the five errors that account for nearly every failed Django deployment.
Ask Django what is wrong first#
Before anything else, run the deployment check against your production settings. It reads your configuration and prints every setting that is unsafe or missing:
$ DJANGO_SETTINGS_MODULE=myproject.settings.production \ python manage.py check --deploy --fail-level WARNINGIt will complain about DEBUG, about missing HSTS settings, about cookies that are not marked secure, and about a SECRET_KEY that looks like the one startproject generated. Every warning is a real issue with a documented fix, and --fail-level WARNING makes it exit non-zero, which means you can put it in a deploy script and have it stop you.
It does not catch everything. It knows nothing about your static files being unreachable, your database connection count, or whether the proxy in front of you is setting the headers your settings expect. Those are the sections below.
Settings: DEBUG, ALLOWED_HOSTS and the secret key#
DEBUG = False is the line that matters most. With it on, an exception renders a page containing your settings, your environment and a stack trace including local variables, to whoever triggered it. It also makes Django keep every SQL query it runs in memory for the life of the process, which looks exactly like a memory leak.
Turning it off changes two other behaviours that surprise people. Django stops serving static files entirely, and it starts enforcing ALLOWED_HOSTS. A request whose Host header is not in that list gets a plain 400 and a DisallowedHost entry in the log.
import osDEBUG = os.environ.get("DJANGO_DEBUG", "false").lower() == "true"SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]ALLOWED_HOSTS = ["app.example.com", "www.example.com"]CSRF_TRUSTED_ORIGINS = ["https://app.example.com", "https://www.example.com"]Reading the secret key with os.environ[...] rather than .get(...) is deliberate: a missing key stops the process at import with a clear error instead of silently running with None and invalidating every session. CSRF_TRUSTED_ORIGINS has needed the scheme since Django 4.0, and it is the cause of the "CSRF verification failed - Origin checking failed" reports that appear the day a site moves behind HTTPS.
Keep production settings in a separate module rather than branching on an environment variable inside one file. myproject/settings/base.py with development.py and production.py importing from it is the common layout, and it makes "what is actually set in production" a question you can answer by reading one short file. The values themselves come from the environment - environment variables and secrets covers where to put them and what to do when one leaks.
Running it: gunicorn, workers and memory#
Django speaks WSGI, so gunicorn is the default answer and myproject/wsgi.py is already in your project:
$ gunicorn myproject.wsgi:application \ --bind 0.0.0.0:${SERVER_PORT:-8000} \ --workers 3 --timeout 30 --access-logfile -Binding 0.0.0.0 rather than 127.0.0.1 is what makes the app reachable from outside its container; this is the single most common "it is running but nothing connects" cause. Taking the port from the environment matters because a panel or platform allocates one for you - the name is on the Startup tab.
Each worker is a separate process holding its own copy of Django, your models, your templates and every third-party app you installed. A modest project runs 100-200 MB per worker; one with Django REST Framework, a PDF library and an image processor is heavier. Measure with ps -o rss= -p PID rather than guessing.
| Plan memory | CPU share | Workers | Notes |
|---|---|---|---|
| 1 GB | 0.5 core | 1 | Add --threads 4 instead of a second worker |
| 2 GB | 1 core | 2 | Comfortable for a small site |
| 4 GB | 1.5 cores | 3 | The usual working size |
| 6-8 GB | 2-3 cores | 4-6 | Past this, the database is the limit, not Django |
Two flags earn their place. --threads 4 with --worker-class gthread lets one worker serve four requests that are waiting on the database, for far less memory than four processes. --max-requests 1000 --max-requests-jitter 100 recycles workers periodically, which papers over a slow leak while you find it.
If you use Channels, websockets or async views seriously, run myproject.asgi:application under uvicorn instead. Everything else in this post still applies; only the server changes. The comparison between the two models is in deploy FastAPI or Flask.
Static files: collectstatic, WhiteNoise and media#
In development Django finds static files wherever they live. In production it does not look for them at all. collectstatic copies every static file from every installed app into one directory, and something else serves that directory.
STATIC_URL = "/static/"STATIC_ROOT = BASE_DIR / "staticfiles"STATICFILES_DIRS = [BASE_DIR / "assets"]MEDIA_URL = "/media/"MEDIA_ROOT = BASE_DIR / "media"$ python manage.py collectstatic --noinputThe simplest way to serve the result on a single server is WhiteNoise, which serves static files from the Django process itself with correct cache headers. It needs one dependency, one middleware line directly after SecurityMiddleware, and one storage setting:
MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",]STORAGES = { "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, "staticfiles": { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", },}The STORAGES dictionary is the Django 4.2 and later form; older projects use STATICFILES_STORAGE instead, which was removed in Django 5.1. The compressed manifest backend hashes every file name and writes a manifest, so assets can be cached forever and a deploy busts the cache by changing the names. It also fails the build if a CSS file references an image that does not exist, which feels hostile the first time and saves you the second time.
Media files are different and WhiteNoise will not serve them. User uploads go to MEDIA_ROOT on the persistent disk, they count against your plan's disk allowance, and they are not in your repository, so they are the thing people forget to back up. If uploads matter, they belong in the backup schedule alongside the database.
The database, migrations and connections#
Django opens a new database connection for every request unless you tell it otherwise. On a server where the database is on another host, that handshake is a measurable part of every response:
DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["DB_NAME"], "USER": os.environ["DB_USER"], "PASSWORD": os.environ["DB_PASSWORD"], "HOST": os.environ["DB_HOST"], "PORT": os.environ.get("DB_PORT", "5432"), "CONN_MAX_AGE": 600, "CONN_HEALTH_CHECKS": True, }}CONN_MAX_AGE keeps a connection open for ten minutes of reuse. CONN_HEALTH_CHECKS, added in Django 4.1, makes Django test a reused connection before handing it to a request, which is what stops the "server closed the connection unexpectedly" errors that persistent connections otherwise produce after an idle period.
The arithmetic to watch: persistent connections are per worker process, so three gunicorn workers hold three connections, and they hold them whether or not traffic is arriving. Add a scheduled job and a shell session and a small PostgreSQL instance is closer to its limit than you would expect. Connection pools and limits has the sums; a PostgreSQL plan on database hosting is the answer when the panel's database slot is no longer enough.
Migrations run before the new code serves traffic, not from inside it:
$ python manage.py migrate --noinputOn a single server that is one line in the start command and it is finished before gunicorn binds. Where it gets interesting is when a migration takes a lock on a busy table - adding a column with a default, or an index without CONCURRENTLY - and every request queues behind it. Migrations without downtime is the long version; the short version is that any migration on a large table should be read once before it is run.
Creating the first admin user without an interactive shell:
$ DJANGO_SUPERUSER_USERNAME=admin \ DJANGO_SUPERUSER_EMAIL=admin@example.com \ DJANGO_SUPERUSER_PASSWORD=... \ python manage.py createsuperuser --noinputHTTPS, cookies and being behind a proxy#
Your app almost certainly receives plain HTTP from a proxy that terminated TLS. Django needs telling, or it will believe every request was insecure, build http:// URLs, and fight the proxy over redirects:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")USE_X_FORWARDED_HOST = TrueSESSION_COOKIE_SECURE = TrueCSRF_COOKIE_SECURE = TrueSECURE_SSL_REDIRECT = TrueSECURE_HSTS_SECONDS = 31536000SECURE_HSTS_INCLUDE_SUBDOMAINS = TrueSECURE_HSTS_PRELOAD = TrueSet SECURE_PROXY_SSL_HEADER only when a proxy you control sets that header on every request. If the app is reachable directly as well, a client can send the header itself and Django will believe it. And enable SECURE_SSL_REDIRECT only after the proxy header works, or you get an infinite redirect loop: Django redirects to HTTPS, the proxy forwards the same plain request, Django redirects again.
HSTS deserves a moment of thought rather than a copy and paste. A year-long SECURE_HSTS_SECONDS tells every browser that has visited you to refuse plain HTTP for a year, including on subdomains if you include them. That is the correct setting for a site that is staying on HTTPS forever, and a painful one if you are still moving hostnames around. Start at a few hours, confirm nothing broke, then raise it.
On RE:NODE the proxy slot on an app plan is what sits at the top of that picture: point an A record at the address shown, the certificate is issued and renewed automatically inside a 21-day window, and the client's real address arrives in X-Forwarded-For. Git deploys come from GitHub through a GitHub App with short-lived tokens, so private repositories work without you pasting a permanent token anywhere.
The deploy: what runs on every start#
Decide what belongs in the start command and what belongs in a one-off task, because the start command runs every time the process restarts - including after a crash, at three in the morning, when the database is having a bad time.
$ pip install -r requirements.txt --no-cache-dir \ && python manage.py migrate --noinput \ && python manage.py collectstatic --noinput \ && gunicorn myproject.wsgi:application --bind 0.0.0.0:${SERVER_PORT:-8000} \ --workers 3 --access-logfile -Safe on every start: installing pinned requirements (pip skips what is already satisfied), migrate (a no-op when there is nothing to apply), and collectstatic --noinput (a few seconds, idempotent). Not safe on every start: anything that loads fixtures, anything that sends email, and any data-repair command somebody wrote once for an incident.
Pinning is what keeps this honest. If requirements.txt has ranges rather than exact versions, a restart at an unlucky moment installs a different version of something than the one you tested - see Python requirements and virtualenvs for lock files that actually lock. Add PYTHONUNBUFFERED=1 to the environment too, or the console will show nothing until the output buffer fills.
Logging should go to standard output so the console has it. Django's default configuration only mails errors to ADMINS:
LOGGING = { "version": 1, "disable_existing_loggers": False, "handlers": {"console": {"class": "logging.StreamHandler"}}, "root": {"handlers": ["console"], "level": "INFO"},}Mail is worth a warning of its own: Django sends password resets and error reports through SMTP, and a hosting plan is not a mail server. Use an external provider, set EMAIL_HOST and friends from the environment, and test a password reset before launch rather than after the first locked-out user.
Background work: schedules, commands and Celery#
Django has no built-in job queue. The options, in increasing order of machinery:
- A management command on a timer.
python manage.py clearsessionsor one of your own, fired by cron on a machine where you have one, or by a small scheduler running beside the web process. This covers nightly cleanups, report generation and digest emails, and it needs no extra service. django-q2,hueyor a similar lightweight queue with a database-backed broker. One extra process, no extra service.- Celery with a dedicated broker. Powerful, and a second service to run and pay for. Worth it when tasks are frequent, need retries, and must survive a restart.
Be honest about which of those your app needs. Most small Django sites want the first option, get sold the third, and end up with a broker nobody monitors. Background jobs on a small server and Redis when you need it both make the same argument at more length.
Whatever you pick, a worker process is a second process competing for the same CPU share and memory as gunicorn. On a 1 GB plan, one gunicorn worker plus one job worker is already the whole budget.
What goes wrong#
A plain 400 with `DisallowedHost` in the log. The Host header is not in ALLOWED_HOSTS. Add the exact hostname people use, including www if that is what the DNS points at.
The site works but has no styling. collectstatic did not run, STATIC_ROOT is wrong, or nothing is serving that directory. Check the file is physically in STATIC_ROOT, then check WhiteNoise's middleware is present and in the right position.
`collectstatic` fails with "could not be found". The manifest storage is resolving a reference inside a CSS file that points at a missing asset. Fix the reference; the alternative is turning off the manifest and losing cache busting.
CSRF verification failed on a form that worked yesterday. Usually CSRF_TRUSTED_ORIGINS missing the scheme, or a cookie marked secure being sent over a connection Django thinks is plain HTTP. Check the proxy header first.
Too many redirects. SECURE_SSL_REDIRECT without a working SECURE_PROXY_SSL_HEADER.
`OperationalError: too many connections`. Workers multiplied by persistent connections exceeded the database's limit. Reduce CONN_MAX_AGE, reduce workers, or move to a plan with a higher limit.
Memory climbs steadily until the server restarts. Check DEBUG is really False in the settings module that is actually loaded - query logging is the usual culprit. Print settings.DEBUG from manage.py shell if you are unsure.
FAQ#
Do I need nginx as well as gunicorn?
Not on a single server with WhiteNoise, if something already terminates TLS in front of you. Gunicorn serves the application and WhiteNoise serves the static files with correct caching. Add a proxy of your own when you need request size limits, rate limiting or caching that Django should not be doing.
How much RAM does a Django site need?
One gunicorn worker is typically 100-200 MB, so 1 GB runs a small site with one worker and headroom, and 2-4 GB is the comfortable range for a real one. The number grows with your dependencies, not with your traffic, so measure your own project rather than trusting a table.
Should migrations run automatically on deploy?
On a single server, yes - as a step before gunicorn starts, so no request is served by code whose schema has not arrived. Where it gets risky is a migration that locks a large table, which should be reviewed and run deliberately rather than at whatever moment a restart happens.
Where do user uploads go?
To MEDIA_ROOT on the server's disk, which is separate from static files and is not served by WhiteNoise. They count against your plan's disk, they are not in the repository, and they need to be in your backup schedule or they are not backed up at all.
Can I run Celery on the same server as the web app?
You can, and it will share the plan's CPU and memory with gunicorn. The broker is the harder question: Celery needs one, and that is another service. If your jobs are periodic rather than event-driven, a management command on a schedule does the same work with no broker at all.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.