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
SetEnvdoes not populateos.environ, so settings that read environment variables get nothing- Apache strips the
Authorizationheader 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.
Quick answer
- Install Apache and mod_wsgi built for the Python version you will use (
libapache2-mod-wsgi-py3for the system Python, orpip install mod_wsgifor another interpreter). - Create the virtualenv with that same Python version.
- Configure
WSGIDaemonProcesswithpython-home,python-path,user,group,processes, andthreads, plusWSGIProcessGroupandWSGIApplicationGroup %{GLOBAL}. - Point
WSGIScriptAliasat your project'swsgi.py; grant access with<Directory>blocks. - Serve
/static/withAlias, outside Django. - Load secrets from a protected file inside
wsgi.py, not withSetEnv. - 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>
Directive by directive:
WSGIDaemonProcesscreates dedicated processes for Django, separate from Apache's children. They run asmyapp, with the virtualenv aspython-homeand the project directory onsys.path.WSGIProcessGroupandprocess-group=myappsend 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 Onpasses theAuthorizationheader to Django, which token and basic authentication need.request-timeoutrestarts a daemon process when requests exceed the limit;maximum-requestsrecycles processes periodically.- The
Aliaslines for/static/and/media/are listed beforeWSGIScriptAlias /, 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
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:
psshows(wsgi:myapp)processes owned bymyapp- static files return
200without touching Django - the error log has no
ImportErrororTruncated or oversized response headersmessages check --deployrun 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.
| Log message | Meaning |
|---|---|
Target WSGI script ... cannot be loaded as Python module | import error; the traceback follows in the next lines |
Timeout when reading response headers from daemon process | request exceeded request-timeout or the daemon is overloaded |
Daemon process deadlock timer expired, stopping process | Python-level deadlock, often a C extension in a sub-interpreter |
Permission denied: ... access to / denied | missing Require all granted for the script directory |
Unable to stat Python home | python-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
WSGIPythonHomeglobally 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
WSGIScriptAliaspointing at a file inside a directory served byAlias, which can expose source code. - Using
SetEnv DJANGO_SETTINGS_MODULEand expecting it to reachos.environ. - Forgetting to reload Apache after changing the virtual host, while touching
wsgi.pyonly reloads Python code.
Next steps
- Compare approaches in Deploy Django with Gunicorn and Nginx on Ubuntu.
- Review configuration patterns in Configure Django Environment Variables for Production.
- Debug server errors with Fix Django 500 Internal Server Error in Production.
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.