Deployment: WSGI, Static & Settings

Reviewed & published by Brayan K

Deployment is the process of configuring and running your Django project on a production server safely, with debugging off, secrets in the environment, static files collected, and a real WSGI server handling requests.

Part of the free Django course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

In this lesson you'll harden settings with DEBUG=False, ALLOWED_HOSTS, and an environment-driven SECRET_KEY, collect static files with collectstatic and WhiteNoise, serve the app through gunicorn and WSGI, parse a database URL, and run the deploy checklist.

What You'll Learn in This Lesson

1 Production Settings from the Environment

The most important production change is turning DEBUG off and listing your real domains in ALLOWED_HOSTS. Secrets like SECRET_KEY must come from the environment, never from a file in your repository. You read them with os.environ at startup.

# settings.py
import os

# Secrets come from the environment — raise loudly if missing
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]

# Env vars are strings, so compare against "True"
DEBUG = os.environ.get("DJANGO_DEBUG", "False") == "True"

# Comma-separated domains, split into a list
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
# In production you read secrets from the environment, never hard-code them.
# os.environ.get returns None if missing, so we provide defaults and raise
# loudly when a REQUIRED setting like SECRET_KEY is absent.

def get_env(environ, key, default=None, required=False):
    value = environ.get(key, default)
    if required and value is None:
        raise RuntimeError(f"Missing required env var: {key}")
    return value

# Pretend this is the server's real environment
environ = {
    "DJANGO_SECRET_KEY": "super-secret-from-vault",
    "DJANGO_DEBUG": "False",
    "DJANGO_ALLOWED_HOSTS": "example.com,www.example.com",
}

SECRET_KEY = get_env(environ, "DJANGO_SECRET_KEY", required=True)
DEBUG = get_env(environ, "DJANGO_DEBUG", "False") == "True"
ALLOWED_HOSTS = get_env(environ, "DJANGO_ALLOWED_HOSTS", "").split(",")

print("SECRET_KEY loaded:", SECRET_KEY[:6] + "...")
print("DEBUG:", DEBUG)
print("ALLOWED_HOSTS:", ALLOWED_HOSTS)

# EXPECTED:
# SECRET_KEY loaded: super-...
# DEBUG: False
# ALLOWED_HOSTS: ['example.com', 'www.example.com']

# ✅ Expected output:
# SECRET_KEY loaded: super-...
# DEBUG: False
# ALLOWED_HOSTS: ['example.com', 'www.example.com']

2 Static Files, the Database URL, and gunicorn

In production the dev server doesn't serve static files, so you run collectstatic to gather them into STATIC_ROOT and let WhiteNoise serve them. Your database connection arrives as a single DATABASE_URL, and you run the app through gunicorn, which loads your project's WSGI callable.

# settings.py — static files served by WhiteNoise
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")  # collectstatic target

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",   # right after security
    # ... the rest ...
]
# Build and run on the server
python manage.py collectstatic --noinput   # gather static files
python manage.py migrate                    # apply migrations

# Start the WSGI server (project/wsgi.py exposes "application")
gunicorn project.wsgi:application --bind 0.0.0.0:8000 --workers 3
# A database URL packs the whole connection into one string, e.g.
# postgres://user:pass@host:5432/dbname. We parse it into the dict Django's
# DATABASES setting expects — the same job dj-database-url does.

from urllib.parse import urlparse

def parse_database_url(url):
    parts = urlparse(url)
    engines = {
        "postgres": "django.db.backends.postgresql",
        "mysql": "django.db.backends.mysql",
        "sqlite": "django.db.backends.sqlite3",
    }
    return {
        "ENGINE": engines[parts.scheme],
        "NAME": parts.path.lstrip("/"),
        "USER": parts.username,
        "PASSWORD": parts.password,
        "HOST": parts.hostname,
        "PORT": parts.port,
    }

config = parse_database_url("postgres://app:s3cret@db.example.com:5432/shop")
for key in ["ENGINE", "NAME", "USER", "HOST", "PORT"]:
    print(f"{key}: {config[key]}")

# EXPECTED:
# ENGINE: django.db.backends.postgresql
# NAME: shop
# USER: app
# HOST: db.example.com
# PORT: 5432

# ✅ Expected output:
# ENGINE: django.db.backends.postgresql
# NAME: shop
# USER: app
# HOST: db.example.com
# PORT: 5432

3 The Deploy Checklist (check --deploy)

Django ships a built-in safety audit: python manage.py check --deploy. It inspects your settings and warns about anything unsafe for production — DEBUG left on, an empty ALLOWED_HOSTS, a weak SECRET_KEY, or missing SSL settings. Run it before every release.

# Run the production audit
python manage.py check --deploy

# Example warnings it raises:
# W008  SECURE_SSL_REDIRECT should be True
# W009  SECRET_KEY has less than 50 characters / low entropy
# W018  DEBUG should not be True in deployment
# W020  ALLOWED_HOSTS must not be empty in deployment

# Recommended production security settings:
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
# manage.py check --deploy runs a checklist of production safety rules.
# Here we model the core checks: DEBUG must be off, ALLOWED_HOSTS must be set,
# SECRET_KEY must be strong, and SSL redirect should be on. Each failure is a
# warning, exactly like Django's deploy check output.

def deploy_check(settings):
    warnings = []
    if settings.get("DEBUG"):
        warnings.append("W018: DEBUG should be False in production")
    if not settings.get("ALLOWED_HOSTS"):
        warnings.append("W020: ALLOWED_HOSTS must not be empty")
    if len(settings.get("SECRET_KEY", "")) < 50:
        warnings.append("W009: SECRET_KEY is too short / weak")
    if not settings.get("SECURE_SSL_REDIRECT"):
        warnings.append("W008: SECURE_SSL_REDIRECT should be True")
    return warnings

bad = {"DEBUG": True, "ALLOWED_HOSTS": [], "SECRET_KEY": "x", "SECURE_SSL_REDIRECT": False}
good = {
    "DEBUG": False,
    "ALLOWED_HOSTS": ["example.com"],
    "SECRET_KEY": "k" * 50,
    "SECURE_SSL_REDIRECT": True,
}

print("BAD settings:")
for w in deploy_check(bad):
    print("  " + w)

print("GOOD settings:", deploy_check(good) or "0 issues — ready to deploy")

# EXPECTED:
# BAD settings:
#   W018: DEBUG should be False in production
#   W020: ALLOWED_HOSTS must not be empty
#   W009: SECRET_KEY is too short / weak
#   W008: SECURE_SSL_REDIRECT should be True
# GOOD settings: 0 issues — ready to deploy

# ✅ Expected output:
# BAD settings:
#   W018: DEBUG should be False in production
#   W020: ALLOWED_HOSTS must not be empty
#   W009: SECRET_KEY is too short / weak
#   W008: SECURE_SSL_REDIRECT should be True
# GOOD settings: 0 issues — ready to deploy

🎯 Your Turn: Read DEBUG from the Environment

Environment variables are strings, so "False" is truthy. Fill in the blank so DEBUG becomes the boolean False only when the variable is exactly "True".

# 🎯 YOUR TURN: DEBUG must be False in production. The env var arrives as the
# STRING "False", so a plain bool("False") would wrongly be True.
# Replace ___ so DEBUG becomes the boolean False.

raw = "False"   # value from os.environ["DJANGO_DEBUG"]
DEBUG = raw == ___
print("DEBUG:", DEBUG)
print("type:", type(DEBUG).__name__)

# EXPECTED OUTPUT:
# DEBUG: False
# type: bool

Common Errors (And How to Fix Them)

❌ DisallowedHost / Bad Request (400) after deploy

With DEBUG=False, Django rejects any host not listed in ALLOWED_HOSTS.

✅ Fix: add your domain(s) to ALLOWED_HOSTS, e.g. ["example.com", "www.example.com"].

❌ CSS and JS 404 in production

You never collected static files, or nothing is serving them.

✅ Fix: run collectstatic and add WhiteNoise middleware (or a CDN) to serve STATIC_ROOT.

❌ Secret key committed to the repository

A hard-coded SECRET_KEY in settings.py ends up in version control.

✅ Fix: read it from os.environ and store the value in your platform's secret manager.

📋 Quick Reference

Setting / CommandWhy It Matters
DEBUG = FalseHide tracebacks; enforce ALLOWED_HOSTS
ALLOWED_HOSTSDomains the site is allowed to serve
collectstaticGather static files into STATIC_ROOT
gunicorn project.wsgiRun the app as a WSGI server
check --deployAudit settings for production safety

🏆 Mini Challenge: A Deploy-Readiness Gate

Build a gate that loads settings from the environment, runs the production checklist, and only "deploys" when there are zero issues — otherwise it reports the blocking problems.

# 🏆 Mini Challenge: a deploy-readiness gate. Load settings from the
# environment, run the production checklist, and only "deploy" when there
# are zero issues. Otherwise print the blocking problems.

def load_settings(environ):
    return {
        "DEBUG": environ.get("DJANGO_DEBUG", "False") == "True",
        "ALLOWED_HOSTS": [h for h in environ.get("DJANGO_ALLOWED_HOSTS", "").split(",") if h],
        "SECRET_KEY": environ.get("DJANGO_SECRET_KEY", ""),
    }

def readiness(settings):
    issues = []
    if settings["DEBUG"]:
        issues.append("DEBUG is True")
    if not settings["ALLOWED_HOSTS"]:
        issues.append("ALLOWED_HOSTS is empty")
    if len(settings["SECRET_KEY"]) < 50:
        issues.append("SECRET_KEY is weak")
    return issues

def deploy(environ):
    settings = load_settings(environ)
    issues = readiness(settings)
    if issues:
        return "BLOCKED: " + "; ".join(issues)
    return "Deploying to production..."

dev_env = {"DJANGO_DEBUG": "True"}
prod_env = {
    "DJANGO_DEBUG": "False",
    "DJANGO_ALLOWED_HOSTS": "example.com",
    "DJANGO_SECRET_KEY": "z" * 50,
}

print(deploy(dev_env))
print(deploy(prod_env))

# EXPECTED:
# BLOCKED: DEBUG is True; ALLOWED_HOSTS is empty; SECRET_KEY is weak
# Deploying to production...

❓ Frequently Asked Questions

Lesson complete — you can ship to production!

You can harden settings with DEBUG=False, ALLOWED_HOSTS, and an env-driven SECRET_KEY, collect static files with collectstatic and WhiteNoise, run the app through gunicorn and WSGI, parse a database URL, and audit with check --deploy.

Practice quiz

What must DEBUG be set to in production?

  • True
  • False
  • 1
  • 'on'

Answer: False. DEBUG=False hides tracebacks, enforces ALLOWED_HOSTS, and serves a generic 500 page.

Why is DEBUG=True dangerous in production?

  • It makes the site slower only
  • It disables the database
  • It exposes code, settings, and environment in error pages
  • It blocks all requests

Answer: It exposes code, settings, and environment in error pages. The debug error page leaks code, settings, and env vars to anyone who triggers an error.

What does collectstatic do?

  • Compiles your Python code
  • Gathers static files into STATIC_ROOT
  • Minifies JavaScript only
  • Starts the web server

Answer: Gathers static files into STATIC_ROOT. collectstatic gathers static files from all apps into the single STATIC_ROOT directory.

What is WSGI?

  • A database driver
  • A template engine
  • A caching backend
  • The standard interface between a Python web app and a server

Answer: The standard interface between a Python web app and a server. WSGI is the standard interface; project/wsgi.py exposes the 'application' callable.

Which command starts a production WSGI server for project/wsgi.py?

  • gunicorn project.wsgi:application
  • python manage.py runserver
  • django-admin serve
  • wsgi start project

Answer: gunicorn project.wsgi:application. gunicorn imports the WSGI application callable and serves it with worker processes.

What does WhiteNoise let you do?

  • Encrypt the database
  • Serve collected static files efficiently from the app server
  • Run background tasks
  • Manage migrations

Answer: Serve collected static files efficiently from the app server. WhiteNoise serves the collected static files with caching headers, the simplest production option without a CDN.

Why must ALLOWED_HOSTS be set when DEBUG=False?

  • It speeds up rendering
  • It configures static files
  • Django rejects any host not listed, raising DisallowedHost
  • It enables the admin

Answer: Django rejects any host not listed, raising DisallowedHost. With DEBUG off Django enforces ALLOWED_HOSTS and returns 400 for unlisted hosts.

How should SECRET_KEY be supplied in production?

  • Hard-coded in settings.py
  • Committed to the repository
  • Generated fresh on every request
  • Read from an environment variable, never committed

Answer: Read from an environment variable, never committed. Read SECRET_KEY from os.environ and keep it out of source control.

Which command audits settings for production safety?

  • python manage.py check --deploy
  • python manage.py audit
  • python manage.py secure
  • python manage.py validate

Answer: python manage.py check --deploy. check --deploy warns about DEBUG, empty ALLOWED_HOSTS, weak SECRET_KEY, and missing SSL settings.

Why compare an env var explicitly with == 'True' for DEBUG?

  • It is faster than bool()
  • Env vars are strings, so even 'False' is truthy
  • Django requires the string form
  • It avoids importing os

Answer: Env vars are strings, so even 'False' is truthy. Environment values arrive as strings; any non-empty string is truthy, so compare to 'True'.

Continue this course

Frequently asked questions

Why must DEBUG be False in production?

With DEBUG set to True, Django shows a detailed error page that exposes your code, settings, and environment variables whenever something goes wrong. That is invaluable while developing but a serious security leak in production, where it would hand attackers a map of your application. In production you set DEBUG to False, which also means Django enforces ALLOWED_HOSTS and serves a generic 500 page instead of the debug traceback.

What is WSGI and where does gunicorn fit in?

WSGI is the standard interface between a Python web application and a web server. Your project ships with a wsgi.py file that exposes an application callable. In production you do not use the development server; instead you run a WSGI server like gunicorn that imports that callable and serves it across multiple worker processes. A front web server such as nginx usually sits in front of gunicorn to handle TLS and static files.

What does collectstatic do and why do I need WhiteNoise?

collectstatic gathers every static file from your apps and third-party packages into the single STATIC_ROOT directory so one server can serve them all. In production the development server does not serve static files, so you need something that does. WhiteNoise is a small library that lets gunicorn serve those collected files efficiently with correct caching headers, which is the simplest production option when you are not using a separate CDN.

How should I manage SECRET_KEY and other secrets?

Never commit SECRET_KEY, database passwords, or API keys to source control. Read them from environment variables at startup using os.environ, and store the actual values in your hosting platform's secret manager or a local untracked .env file. This keeps secrets out of your repository, lets each environment use different values, and makes rotating a leaked key a configuration change rather than a code change.