Django with Apache & mod_wsgi on Debian 13 (Linode)
This setup uses Apache and mod_wsgi to deploy a Django Project. It uses PostgreSQL for the data models, and either PostgreSQL (database cache) or Redis for caching. It assumes that the local environment uses a similar setup.
TIP
For new deployments, prefer the Nginx & Gunicorn guide. It has several advantages over mod_wsgi:
- Any Python version -
mod_wsgiis compiled against Debian's system Python, so the project is stuck with it. Gunicorn runs from the project's virtual environment, souvcan use whatever Python the project needs. - Separate processes - Nginx and Django run as separate services. You can restart Django on each deploy without touching the web server, and requests wait in the socket instead of failing while it restarts.
- Lower memory usage - Nginx handles many slow or idle connections with little memory, which matters on a 1 GB Nanode, and it serves static files efficiently.
- Simpler configuration - No WSGI lines to keep out of Certbot's way, and no
www-dataaccess to the project code (Nginx only reads the static files). - A path to ASGI - You can add Uvicorn for WebSockets (e.g., a chat app) next to Gunicorn later.
mod_wsgionly supports WSGI.
INFO
This setup uses a Public Subnet, so the Django server can be accessed over the internet.
NOTE
You can follow the steps in this guide as written, but replace the following placeholders with your own names:
<DJANGO Server IP Address>: Your Linode's Public IP Addressnon_root: Your non-root usernameyour-name: Your name for git commitsyour-email-id: Your email ID for git commits<remote-URL>: Your GitHub repository's SSH clone URLmy-project: Your cloned project directorymy_project: Your Django project package (the folder containingwsgi.py)api.example.com: Your domain nameinfo@example.com: Your admin email address
You should also update the IP addresses and VPC CIDR blocks, to match your VPC settings.
(Optional) Create and set up new Django Project
If you don't already have a Django project created, you can use this guide to create and configure one.
Setup Linode (Debian) for Django
Launch a Linode
| Parameter | Value |
|---|---|
| Region | in-maa (Chennai) |
| OS | Debian (Debian 13 as of 22-Feb-2026) |
| Plan | Nanode 1 GB (Shared CPU) |
| Label | Give your preferred label (Label can't have spaces) |
| Root Password | Create a Strong Password and store it somewhere safe |
| SSH Keys | You can add an existing SSH key or add this later when you deploy a new server |
| Disk Encryption | Enable |
| VPC | Select the VPC your other servers use (or create one if this is your first server) |
| Subnet | Select a public subnet since Django Server should be accessed via internet |
| Auto-assign a VPC IPv4 | Enable |
| Allow public IPv4 access | Enable |
| Network Interface Type | Linode Interfaces |
| VPC Interface Firewall | Create and assign a Firewall (that allows all outbound and no inbound - configured later in this guide) |
| Backups | Disable (The backups are useful only for databases) |
Upgrade Packages
TIP
Use the LISH Console to connect to the Linode server. The firewall blocks all inbound traffic until you configure it, so you can't SSH in from your local machine yet.
Upgrade the packages on the server:
sudo apt update && sudo apt upgrade -ySet Timezone
Install all locales first to disable locale warnings:
sudo apt install locales-allAll new Linode servers are set to UTC time by default. To change it to IST, use:
timedatectl set-timezone 'Asia/Kolkata'Confirm the date by running the date command in the terminal.
Configure Firewall
Add the following inbound rules to the Django Firewall:
| Rule Purpose | Label | Protocol | Ports | IP / Netmask | Action |
|---|---|---|---|---|---|
| Allow ICMP (ping) traffic from other servers | Choose a label | ICMP | Leave blank | VPC subnet IP range (Ex: 10.0.0.0/16) | Accept |
| Allow HTTP traffic from the internet | Choose a label | TCP | HTTP (80) | All IPv4, All IPv6 | Accept |
| Allow HTTPS traffic from the internet | Choose a label | TCP | HTTPS (443) | All IPv4, All IPv6 | Accept |
| Allow SSH connections from admin systems | Choose a label | TCP | SSH (22) | Admin system's IP address (use /32) | Accept |
NOTE
Port 80 must stay open even after HTTPS is set up: Certbot uses it to renew certificates, and Apache uses it to redirect http to https.
TIP
If your admin IP address changes (common with home internet connections), SSH will stop connecting. Log in through the LISH Console instead, and update the SSH rule with your new IP address.
Allow Outgoing Email
New Linode accounts block outgoing traffic on the SMTP ports (25, 465, and 587). Django sends error reports to ADMINS and any other emails (such as password resets) over SMTP, so without this step every email fails.
Open a support ticket in the Linode Cloud Manager asking to lift the SMTP restrictions for this Linode. Once support confirms, check that the port your email provider uses (465 in the project settings) is reachable:
timeout 5 bash -c '</dev/tcp/smtpout.secureserver.net/465' && echo "SMTP port open"Disable Root Login
IMPORTANT
The LISH Console does not use SSH, so PermitRootLogin no and PasswordAuthentication no do not apply to it. Anyone with access to your Linode account can reach a root login prompt through LISH using the root password. Your Linode account credentials and 2FA are therefore the real security perimeter for this server - enable 2FA and store the root password in a password manager.
First, create a limited user account:
adduser non_root
# You'll be prompted to provide passwordAdd the new user to the sudo group for administrative privileges:
adduser non_root sudoExit the session and SSH back into the server as your new user (from local Mac machine):
exit
ssh non_root@<DJANGO Server IP Address>Create an SSH directory and add the public key of your local Mac machine to the authorized keys file:
mkdir ~/.ssh && chmod 700 ~/.ssh && vi ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keysDisable Root login and Password Authentication:
sudo vi /etc/ssh/sshd_config
# Set `PermitRootLogin` to `no`
# Set `PasswordAuthentication` to `no`
# Set `AddressFamily` to `inet` (to disable IPv6 connections)Validate the configuration before restarting, and keep your current session open in case something is wrong:
sudo sshd -tConfirm the settings sshd will actually use. Files in /etc/ssh/sshd_config.d/ are included at the top of sshd_config, and the first value read wins, so a drop-in file can silently override your edits:
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|addressfamily'
# Expected: permitrootlogin no, passwordauthentication no, addressfamily inetFinally, restart the SSH service to apply the changes:
sudo systemctl restart sshTIP
On Debian 13, SSH is socket-activated and the unit is named ssh. If you changed the listening address or port, also restart ssh.socket.
Create SSH Key for the VPC
The PostgreSQL (and Redis) server has no public IP, so you'll administer it over SSH from this server. Create a key pair for it:
ssh-keygen -t ed25519 -C "django-server"Note down this server's public key and its private IP address. The PostgreSQL guide asks for both - the key goes into the database server's authorized_keys, and the IP goes into its firewall rules and pg_hba.conf:
cat ~/.ssh/id_ed25519.pub
# The private IP is the 10.x.x.x address (also shown in the Linode dashboard)
ip -4 -brief addrSetup Database Server
At this point, if you don't have a Postgres Database server ready for production use, you'll need to set one up. You can follow this guide to configure it. In that guide, this Django server is the application server (the "other Linode").
IMPORTANT
When the PostgreSQL guide asks you to install ca.crt as the user your application runs as, run it as non_root on this server. The Apache configuration below runs Django as non_root, so PostgreSQL clients will find the certificate at /home/non_root/.postgresql/root.crt.
If the PostgreSQL server already exists, give this new server access to it instead:
- Firewall - In the PostgreSQL server's firewall, add this server's private IP (use /32) to the SSH (
22) and PostgreSQL (5432) rules. If it's in a different subnet, add that subnet to the ICMP rule too. pg_hba.conf- Add ahostssl <your-database-name> <your-db-username> <this server's private IP>/32 scram-sha-256line, then runsudo systemctl reload postgresql.- SSH key - Add this server's public key (from the step above) to
~/.ssh/authorized_keysofnon_rooton the PostgreSQL server, using the LISH Console or an existing application server. - CA certificate - Restore
ca.crtfrom your offline storage to this server, then install it withinstall -D -m 644 ca.crt ~/.postgresql/root.crt && rm ca.crt.
(Optional) Redis for Caching
By default, the project uses PostgreSQL as the Django cache (DatabaseCache). If you'd rather use Redis, install it on this server with this guide once the rest of this guide is complete. Redis listens on localhost only, so it needs no firewall rule, TLS certificate, or access from the PostgreSQL server. The guide also covers the Django settings, the .env values, and a connection check.
Setup Django Project
uv
First, install the uv package manager:
curl -LsSf https://astral.sh/uv/install.sh | shLoad uv into your current shell:
source ~/.local/bin/env
uv --versionmod_wsgi from the Debian package is built against Debian's system Python (/usr/bin/python3), so the project's virtual environment must use that same Python. By default, uv may download its own Python build (depending on your project's .python-version and requires-python), which mod_wsgi can't load. Make uv always use the system Python for this user:
# Added at the top of ~/.bashrc - Debian's ~/.bashrc stops early for non-interactive shells
# (such as `ssh server 'command'`), so lines at the end would be skipped there
sed -i '1i export UV_PYTHON=/usr/bin/python3' ~/.bashrc
sed -i '1i . "$HOME/.local/bin/env"' ~/.bashrc
source ~/.bashrc
/usr/bin/python3 --versionNOTE
Your project's requires-python (in pyproject.toml) must allow the system Python version shown above (Python 3.13 on Debian 13).
Clone the GitHub project
Create a separate key pair for GitHub:
ssh-keygen -t ed25519 -C "django-server-github" -f ~/.ssh/github_deployTell SSH to use this key for GitHub:
cat >> ~/.ssh/config <<EOF
Host github.com
IdentityFile ~/.ssh/github_deploy
IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/configGrab the generated public key and add it as a Deploy key in your GitHub repository's settings (Settings → Deploy keys → Add deploy key), leaving "Allow write access" unchecked:
cat ~/.ssh/github_deploy.pubTIP
A deploy key only grants read access to a single repository. Don't add this key to your GitHub account settings - an account key gives this server access to every repository you own, so a compromised server would expose all of them.
Install git to manage the GitHub repositories locally:
sudo apt -y install gitConfirm git installation:
git --versionConfigure your git credentials locally:
git config --global user.name "your-name"
git config --global user.email "your-email-id"Clone the GitHub repository in your home directory on the Linux Server:
cd ~ && git clone <remote-URL>Setup Project on the Server
Navigate into your cloned project directory (e.g., cd ~/my-project). Create an .env file with variables that match your local setup but with updated production values:
- Set
DJ_ENVtoPROD. - Set
POSTGRES_HOSTto the PostgreSQL server's private IP (it must match the certificate's SAN exactly). - Set
POSTGRES_SSLMODEtoverify-full, so Django checks the database server's certificate against~/.postgresql/root.crt. - Add the AWS S3 and CloudFront values from this guide. In production, uploaded files (such as profile pictures) are stored in S3, so uploads fail without them.
- If you use Redis for caching, add the Redis values from the Redis guide.
Generate a new secure SECRET_KEY using the following command and add it to your .env file:
python3 -c "import secrets; print(secrets.token_urlsafe(64))"Only non_root needs to read the .env file, so lock it down:
chmod 600 .envCreate the virtual environment and install dependencies. --locked installs exactly what's in uv.lock (and fails if it's missing or out of date - so make sure uv.lock is committed to the repository), and --no-dev skips development-only packages:
uv sync --locked --no-dev
# Must print the same version as `/usr/bin/python3 --version`
.venv/bin/python --versionCreate the log directory. Django runs as non_root (both under Apache and for management commands), so it needs to own the directory to create debug.log:
sudo install -d -m 750 -o non_root -g non_root /logsApache and management commands write to the same log file from different processes, so rotating it from inside Django is unsafe. In production, the project logs with WatchedFileHandler, and logrotate rotates the file instead:
sudo tee /etc/logrotate.d/django > /dev/null <<'EOF'
/logs/debug.log {
su non_root non_root
create 0640 non_root non_root
weekly
rotate 8
compress
delaycompress
missingok
notifempty
}
EOF
# Check the configuration (dry run)
sudo logrotate -d /etc/logrotate.d/djangoCollect the static files. Keep passing --no-dev to uv run - without it, uv run re-syncs the environment and installs the development packages again:
uv run --no-dev manage.py collectstaticCreate the Django Cache table (skip this if you use Redis for caching):
uv run --no-dev manage.py createcachetableCheck the project for any configuration errors before deploying:
uv run --no-dev manage.py check
uv run --no-dev manage.py check --deployConfirm there are no model changes without a migration (migrations should be created locally and committed, never on the server), then run the migrations:
uv run --no-dev manage.py makemigrations --check --dry-run # Should print "No changes detected"
uv run --no-dev manage.py migrateCreate a Super User, if your application needs one (for example, for admin-only API endpoints):
uv run --no-dev manage.py createsuperuserNOTE
The project's urls.py only enables the Django admin site when DEBUG is on, so there's no admin UI in production. The superuser logs in through your API's JWT endpoints like any other user.
Install Apache & mod_wsgi
Install apache2 and mod_wsgi on Debian, using the following command:
sudo apt -y install apache2 libapache2-mod-wsgi-py3Start the Apache web server:
sudo systemctl restart apache2You should now see the default website at http://<DJANGO Server IP Address>.
TIP
By default, systemd gives apache2 a private /tmp directory. This setup logs to /logs, so it isn't affected. If your project writes files to /tmp that you need to read outside Apache, see this guide.
Grant Access to Home Directory
Django itself runs as non_root, but Apache (running as the www-data user) still needs to reach the project directory to serve static files and locate wsgi.py.
Debian 13 creates home directories with mode 700, so first let the www-data group traverse (but not list) your home directory:
sudo chmod 710 /home/non_root
sudo chgrp www-data /home/non_rootThen give the www-data group read access to the project:
# Set the group of the entire project to www-data
sudo chown -R non_root:www-data /home/non_root/my-project
# You get full access; the group gets read access (and execute on directories and executables); others get nothing
chmod -R u=rwX,g=rX,o= /home/non_root/my-project
# New files (from `git pull`, `collectstatic`, etc.) inherit the www-data group
find /home/non_root/my-project -type d -exec chmod g+s {} +
# The .env file stays private to non_root
chmod 600 /home/non_root/my-project/.envSet Global ServerName
To set the global ServerName for the Apache web server, open the primary Apache config file:
sudo vi /etc/apache2/apache2.confNavigate to the bottom of the file and add the following line:
ServerName api.example.comConfigure Apache and HTTPS
WARNING
The certbot certificate installation will fail if the port 80 Virtual Host contains WSGI lines (Certbot copies them into the SSL file, creating a duplicate WSGIDaemonProcess). So the WSGI lines only go in the SSL Virtual Host, after Certbot has run.
Open the default Virtual Host configuration file:
sudo vi /etc/apache2/sites-available/000-default.confOverwrite its contents with the following configuration. It only serves Apache's default page (and later redirects to https); it never exposes the project directory:
<VirtualHost *:80>
DocumentRoot /var/www/html
ServerName api.example.com
ServerAlias www.api.example.com
ServerAdmin info@example.com
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>WARNING
Never set DocumentRoot to the project directory or grant Require all granted on it. Any URL that isn't handled by WSGI would then serve raw files - including .env, .git, and settings.py.
Since the SSL configuration will use RewriteEngine to redirect www to non-www, enable the rewrite module:
sudo a2enmod rewriteCheck the configuration for errors, then restart the Apache server:
sudo apache2ctl configtest
sudo systemctl restart apache2Install Certbot and its Apache plugin (Debian's package also installs a systemd timer that renews certificates automatically):
sudo apt -y install certbot python3-certbot-apacheAt this point, you must point your domain to the current IP address using Route53 or a similar DNS service. Edit the A records for both domains.
api.example.com
www.api.example.comGet and install the SSL certificate. The -d options pass both domain names, and --redirect makes Certbot add an http to https redirect to the port 80 Virtual Host:
sudo certbot --apache --redirect -v -d api.example.com -d www.api.example.com
# Details to be filled as follows:
# * Email: `info@example.com`
# * Terms of Service: `Y`
# * Share Email Address: `N`Confirm that automatic renewal works:
sudo certbot renew --dry-runNow, update the newly generated SSL configuration file:
sudo vi /etc/apache2/sites-available/000-default-le-ssl.confMost of the content will be similar to what Certbot generated, but you must manually update the WSGI config, Rewrite config, and valid host grants as shown below:
<IfModule mod_ssl.c>
<VirtualHost *:443>
DocumentRoot /var/www/html
ServerName api.example.com
ServerAlias www.api.example.com
ServerAdmin info@example.com
SSLEngine on
Include /etc/letsencrypt/options-ssl-apache.conf
SSLCertificateFile /etc/letsencrypt/live/api.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/api.example.com/privkey.pem
# BEGIN: Enable www to non-www redirection
RewriteEngine On
RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC]
RewriteCond %{HTTP_HOST} !^localhost
RewriteCond %{HTTP_HOST} !^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(:[0-9]+)?$
RewriteCond %{REQUEST_URI} !^/\.well-known
RewriteRule ^(.*)$ https://%1$1 [R=permanent,L]
# END: Enable www to non-www redirection
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
# Only requests for your own domain are allowed (anchored, so look-alike hosts don't match).
# Requests using any other host (such as the Linode IP address) get a 403 and never reach Django.
SetEnvIfNoCase Host ^(www\.)?api\.example\.com(:443)?$ VALID_HOST
# Allow access to static files
<Directory /home/non_root/my-project/staticfiles>
Require env VALID_HOST
</Directory>
Alias /static /home/non_root/my-project/staticfiles
# Grant access to wsgi.py only (not the rest of the project)
<Directory /home/non_root/my-project/my_project>
<Files wsgi.py>
Require env VALID_HOST
</Files>
</Directory>
LogLevel info
# To run WSGI daemon process (as non_root, so Django can read .env, the CA certificate, and write to /logs)
WSGIDaemonProcess api.example.com user=non_root group=www-data python-home=/home/non_root/my-project/.venv python-path=/home/non_root/my-project
WSGIProcessGroup api.example.com
WSGIApplicationGroup %{GLOBAL}
WSGIScriptAlias / /home/non_root/my-project/my_project/wsgi.py
WSGIPassAuthorization On
</VirtualHost>
</IfModule>Check the configuration for errors, then restart the Apache server one final time:
sudo apache2ctl configtest
sudo systemctl restart apache2Now test the secure endpoint at https://api.example.com/. The www subdomain should correctly redirect to non-www, and all http traffic should force an upgrade to https.
Confirm that project files are not reachable from the internet (run from your local machine):
# Both should return 403 or 404 - never the file contents
curl -sI http://<DJANGO Server IP Address>/.env
curl -sI https://api.example.com/.envDeploy Updates
To deploy new code, run these commands on the server:
cd ~/my-project
git pull
uv sync --locked --no-dev
uv run --no-dev manage.py migrate
uv run --no-dev manage.py collectstatic --noinput
# Reload the Django (WSGI daemon) processes without restarting Apache
touch my_project/wsgi.py
# If you run the RQ worker from the Redis guide, restart it too
sudo systemctl restart rqworkerNOTE
Touching wsgi.py fully restarts the Django processes, so changes to .env are picked up too. Only restart Apache (sudo systemctl restart apache2) if you changed the Apache configuration.
Keep the Server Patched
This server is exposed to the internet, so install security updates automatically:
sudo apt install -y unattended-upgrades needrestart
sudo dpkg-reconfigure -plow unattended-upgrades # Choose "Yes"unattended-upgrades restarts most services itself, but a new kernel only takes effect after a reboot. Check periodically, and reboot if it reports an outdated kernel:
sudo needrestart -kYou should now have a working Django server, securely connected to your database within the VPC.
