Deploy Django with Apache and mod_wsgi

Deploy Django on Apache with mod_wsgi daemon mode, a matching virtualenv, static file aliases, secret loading, HTTPS, and code reloads without restarting Apache.

Problem statement

Apache with mod_wsgi is one of Django's officially documented deployment options, and it remains common on servers that already run Apache for other sites, in shared corporate environments, and on hosts where a separate application server process is not allowed.

mod_wsgi runs Python inside Apache, which introduces constraints the Gunicorn-and-Nginx stack does not have:

  • mod_wsgi is compiled against one specific Python version, and your virtualenv must match it
  • embedded mode runs Django inside every Apache child process, mixing it with static file serving
  • SetEnv does not populate os.environ, so settings that read environment variables get nothing
  • Apache strips the Authorization header by default, breaking token authentication
  • code changes are not picked up until something reloads the Python processes

This guide deploys Django on Ubuntu with Apache, mod_wsgi in daemon mode, a virtualenv, and HTTPS.

Apache with mod_wsgi daemon modeClientHTTPS requestApacheTLS, static aliasesDaemon processesown user, virtualenvDjangowsgi.py applicationDaemon mode isolates Django in its own processes; touching wsgi.py reloads them.

Quick answer

  1. Install Apache and mod_wsgi built for the Python version you will use (libapache2-mod-wsgi-py3 for the system Python, or pip install mod_wsgi for another interpreter).
  2. Create the virtualenv with that same Python version.
  3. Configure WSGIDaemonProcess with python-home, python-path, user, group, processes, and threads, plus WSGIProcessGroup and WSGIApplicationGroup %{GLOBAL}.
  4. Point WSGIScriptAlias at your project's wsgi.py; grant access with <Directory> blocks.
  5. Serve /static/ with Alias, outside Django.
  6. Load secrets from a protected file inside wsgi.py, not with SetEnv.
  7. Enable HTTPS with Certbot, and reload code by touching wsgi.py.

Step-by-step solution

1. Install Apache and mod_wsgi

Using the distribution package, which is built against the system Python 3:

sudo apt update
sudo apt install apache2 libapache2-mod-wsgi-py3 python3-venv
python3 --version

Create the virtualenv with the same interpreter:

sudo adduser --system --group --home /srv/myapp myapp
sudo -u myapp python3 -m venv /srv/myapp/venv
sudo -u myapp git clone https://github.com/example/myapp.git /srv/myapp/app
sudo -u myapp /srv/myapp/venv/bin/pip install -r /srv/myapp/app/requirements.txt

If you need a different Python version than the system one, install mod_wsgi into that interpreter's virtualenv with pip install mod_wsgi, then generate the loader lines:

/srv/myapp/venv/bin/mod_wsgi-express module-config

Put the printed LoadModule and WSGIPythonHome lines in /etc/apache2/mods-available/wsgi_custom.load, and do not enable the distribution module at the same time.

2. Load settings in wsgi.py

Apache's SetEnv only adds values to the per-request WSGI environment, not to os.environ, so os.environ["DJANGO_SECRET_KEY"] in settings would fail. Load a protected environment file before Django starts:

# /srv/myapp/app/config/wsgi.py
import os
from pathlib import Path

ENV_FILE = Path("/etc/myapp/env")

for line in ENV_FILE.read_text().splitlines():
    line = line.strip()
    if line and not line.startswith("#") and "=" in line:
        key, value = line.split("=", 1)
        os.environ.setdefault(key.strip(), value.strip())

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")

from django.core.wsgi import get_wsgi_application

application = get_wsgi_application()

Create the file readable only by the app user:

sudo install -d -m 750 -o root -g myapp /etc/myapp
sudo tee /etc/myapp/env >/dev/null <<'EOF'
DJANGO_SECRET_KEY=generated-secret
DJANGO_ALLOWED_HOSTS=www.example.com
DATABASE_URL=postgres://myapp:password@127.0.0.1:5432/myapp
EOF
sudo chown root:myapp /etc/myapp/env
sudo chmod 640 /etc/myapp/env

In daemon mode the Python code runs as the myapp user, so this file never needs to be readable by the Apache user.

3. Collect static files and migrate

cd /srv/myapp/app
sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; \
  /srv/myapp/venv/bin/python manage.py migrate --noinput && \
  /srv/myapp/venv/bin/python manage.py collectstatic --noinput'

4. Configure the virtual host

/etc/apache2/sites-available/myapp.conf:

<VirtualHost *:80>
    ServerName www.example.com

    Alias /static/ /srv/myapp/app/staticfiles/
    <Directory /srv/myapp/app/staticfiles>
        Require all granted
    </Directory>

    Alias /media/ /srv/myapp/media/
    <Directory /srv/myapp/media>
        Require all granted
    </Directory>

    WSGIDaemonProcess myapp \
        python-home=/srv/myapp/venv \
        python-path=/srv/myapp/app \
        user=myapp group=myapp \
        processes=2 threads=15 \
        display-name=%{GROUP} \
        request-timeout=60 \
        maximum-requests=5000
    WSGIProcessGroup myapp
    WSGIApplicationGroup %{GLOBAL}
    WSGIPassAuthorization On

    WSGIScriptAlias / /srv/myapp/app/config/wsgi.py process-group=myapp

    <Directory /srv/myapp/app/config>
        <Files wsgi.py>
            Require all granted
        </Files>
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/myapp-error.log
    CustomLog ${APACHE_LOG_DIR}/myapp-access.log combined
</VirtualHost>
daemon mode, line by lineWSGIDaemonProcess myapp \python-home=/srv/myapp/venv \processes=2 threads=15\request-timeout=60WSGIApplicationGroup%{GLOBAL}WSGIScriptAlias / /srv/myapp/app/config/wsgi.py \process-group=myapp12341The virtualenv; the wrong one gives ModuleNotFoundError.2Capacity is processes × threads requests at once.3Main interpreter: avoids hangs in C extensions such as psycopg.4Run Django in the daemon, not inside Apache workers.Daemon mode keeps Django out of the Apache worker processesdjango-deployment.com
Embedded mode is the default without these lines, and it is the source of most mod_wsgi surprises.

Directive by directive:

  • WSGIDaemonProcess creates dedicated processes for Django, separate from Apache's children. They run as myapp, with the virtualenv as python-home and the project directory on sys.path.
  • WSGIProcessGroup and process-group=myapp send requests to those daemon processes.
  • WSGIApplicationGroup %{GLOBAL} runs Django in the main Python interpreter, which avoids problems with C extensions that do not support sub-interpreters.
  • WSGIPassAuthorization On passes the Authorization header to Django, which token and basic authentication need.
  • request-timeout restarts a daemon process when requests exceed the limit; maximum-requests recycles processes periodically.
  • The Alias lines for /static/ and /media/ are listed before WSGIScriptAlias /, following the convention of declaring more specific URL prefixes first, so Apache serves those files directly instead of passing them to Django.

Apache also needs execute (traverse) permission on every parent directory of the static path. Check with namei -l /srv/myapp/app/staticfiles.

5. Enable the site

sudo a2enmod wsgi
sudo a2ensite myapp
sudo a2dissite 000-default
sudo apache2ctl configtest
sudo systemctl reload apache2

6. Enable HTTPS

sudo apt install certbot python3-certbot-apache
sudo certbot --apache -d www.example.com

Certbot creates an SSL virtual host from your configuration and sets up renewal. Apache terminates TLS itself here, so Django sees wsgi.url_scheme as https directly and request.is_secure() works without SECURE_PROXY_SSL_HEADER. Only set that setting if another proxy sits in front of Apache.

Enable the secure settings in Django:

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 3600

Start with a small SECURE_HSTS_SECONDS value and increase it once HTTPS is confirmed stable.

7. Reload code after a deploy

In daemon mode, mod_wsgi restarts the daemon processes when the WSGI script file's modification time changes:

cd /srv/myapp/app
sudo -u myapp git pull
sudo -u myapp /srv/myapp/venv/bin/pip install -r requirements.txt
# migrate and collectstatic as in step 3
sudo touch /srv/myapp/app/config/wsgi.py
what each reload actually restartsRestartsDropped requeststouch wsgi.pythe daemon processes✓none, if daemon modeapachectl gracefulall of Apache, gently✓nonesystemctl restartall of Apache, at once!in-flight requestsTouching wsgi.py only works in daemon modedjango-deployment.com
Changes to the Apache configuration itself still need a graceful restart.

This reloads only the Django processes, not Apache. Changes to the Apache configuration still need systemctl reload apache2.

8. Verify

curl -I https://www.example.com/
curl -I https://www.example.com/static/admin/css/base.css
ps -eo user,pid,cmd | grep '(wsgi:myapp)'
sudo tail -n 50 /var/log/apache2/myapp-error.log

Verification check:

  • ps shows (wsgi:myapp) processes owned by myapp
  • static files return 200 without touching Django
  • the error log has no ImportError or Truncated or oversized response headers messages
  • check --deploy run with the environment file loaded shows no unexpected warnings

Rollback

Keep the previous release in a separate directory and switch a symlink that python-path and WSGIScriptAlias point at, then reload Apache so the paths are re-resolved. Reverse migrations only if they are safe; otherwise restore the database from backup.

Explanation

mod_wsgi has two modes. Embedded mode runs Python in every Apache child process: memory usage scales with Apache's process count, and code reloads require restarting Apache. Daemon mode, used here, runs a fixed pool of Python processes owned by a dedicated user and treats Apache purely as the front end. That gives you the same separation as Nginx plus Gunicorn, with Apache handling TLS and static files.

Edge cases / notes

ModuleNotFoundError for packages you installed

The virtualenv was created with a different Python version from mod_wsgi's. Compare python3 --version for the system interpreter with /srv/myapp/venv/bin/python --version, or check mod_wsgi-express module-config output.

Truncated or oversized response headers

The daemon process crashed, often due to a C extension that does not work in a sub-interpreter. Confirm WSGIApplicationGroup %{GLOBAL} is set.

403 Forbidden on the whole site

The <Directory> block granting access to wsgi.py is missing or points at the wrong path.

Troubleshooting mod_wsgi

Confirm what mod_wsgi is using

Add a temporary diagnostic view or run a check from inside the daemon process to see the interpreter and paths actually in use. The simplest route is a small WSGI test script served under a separate alias during debugging:

# /srv/myapp/debug/wsgi_check.py
import sys


def application(environ, start_response):
    body = "\n".join([
        f"executable: {sys.executable}",
        f"version: {sys.version}",
        f"prefix: {sys.prefix}",
        f"process group: {environ.get('mod_wsgi.process_group')}",
        f"application group: {environ.get('mod_wsgi.application_group')}",
    ]).encode()
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [body]

sys.prefix should be your virtualenv, the process group should be myapp, and the application group should be empty (which is what %{GLOBAL} looks like). Remove the script and its alias once you are done; it discloses server details.

Log locations and messages

Apache writes mod_wsgi errors into the virtual host's ErrorLog. Increase detail temporarily with LogLevel info in the virtual host.

reading the Apache error logSymptomUsual causeModuleNotFoundErrorpython-home or python-pathpoints at the wrong place403 Forbidden everywhere<Directory> grant missing,or no traverse permissionRequest hangs, then times outC extension in asub-interpreter; set %{GLOBAL}KeyError on a settingSetEnv is not os.environ;load an env file in wsgi.pyOld code still servedembedded mode, or wsgi.py not touchedThe virtual host error log names the daemon process that faileddjango-deployment.com
Most of these trace back to a directive in the virtual host rather than to Django.
Log messageMeaning
Target WSGI script ... cannot be loaded as Python moduleimport error; the traceback follows in the next lines
Timeout when reading response headers from daemon processrequest exceeded request-timeout or the daemon is overloaded
Daemon process deadlock timer expired, stopping processPython-level deadlock, often a C extension in a sub-interpreter
Permission denied: ... access to / deniedmissing Require all granted for the script directory
Unable to stat Python homepython-home path wrong or not traversable by Apache

Sizing daemon processes

processes=2 threads=15 allows up to 30 concurrent requests in Python. Threads work well for I/O-bound Django views (database and HTTP calls). For CPU-heavy views, prefer more processes and fewer threads, because of Python's global interpreter lock. Each process loads the full application, so memory use scales with processes, not with threads.

Apache's MPM also matters. With the event or worker MPM, Apache threads handle static files and proxying efficiently while Django runs in the daemon processes. The prefork MPM (often enabled when mod_php is installed) uses many more resources; avoid mixing it with a high-traffic Django site.

Common mistakes

  • Configuring WSGIPythonHome globally for one site while another site on the same Apache uses a different Python version; one mod_wsgi build supports only one Python version.
  • Leaving WSGIScriptAlias pointing at a file inside a directory served by Alias, which can expose source code.
  • Using SetEnv DJANGO_SETTINGS_MODULE and expecting it to reach os.environ.
  • Forgetting to reload Apache after changing the virtual host, while touching wsgi.py only reloads Python code.

Next steps

FAQ

Should I use mod_wsgi daemon mode or embedded mode for Django?

Daemon mode. It isolates Django in dedicated processes running as their own user, keeps Apache children lightweight, and lets you reload code by touching the WSGI file.

Why does os.environ not contain my Apache SetEnv values?

SetEnv values are passed in the per-request WSGI environ dictionary, not in the process environment. Django settings are loaded once at import time, so load configuration from a file or systemd environment instead.

How do I reload Django code under mod_wsgi without restarting Apache?

In daemon mode, update the modification time of the WSGI script with touch. mod_wsgi detects the change and restarts the daemon processes on the next request.

Why does Django REST Framework token authentication fail behind Apache?

Apache does not pass the Authorization header to WSGI applications by default. Add WSGIPassAuthorization On to the virtual host.

Keep going