Skip to content

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_wsgi is compiled against Debian's system Python, so the project is stuck with it. Gunicorn runs from the project's virtual environment, so uv can 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-data access 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_wsgi only 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 Address
  • non_root: Your non-root username
  • your-name: Your name for git commits
  • your-email-id: Your email ID for git commits
  • <remote-URL>: Your GitHub repository's SSH clone URL
  • my-project: Your cloned project directory
  • my_project: Your Django project package (the folder containing wsgi.py)
  • api.example.com: Your domain name
  • info@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 ​

ParameterValue
Regionin-maa (Chennai)
OSDebian (Debian 13 as of 22-Feb-2026)
PlanNanode 1 GB (Shared CPU)
LabelGive your preferred label (Label can't have spaces)
Root PasswordCreate a Strong Password and store it somewhere safe
SSH KeysYou can add an existing SSH key or add this later when you deploy a new server
Disk EncryptionEnable
VPCSelect the VPC your other servers use (or create one if this is your first server)
SubnetSelect a public subnet since Django Server should be accessed via internet
Auto-assign a VPC IPv4Enable
Allow public IPv4 accessEnable
Network Interface TypeLinode Interfaces
VPC Interface FirewallCreate and assign a Firewall (that allows all outbound and no inbound - configured later in this guide)
BackupsDisable (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:

shell
sudo apt update && sudo apt upgrade -y

Set Timezone ​

Install all locales first to disable locale warnings:

shell
sudo apt install locales-all

All new Linode servers are set to UTC time by default. To change it to IST, use:

shell
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 PurposeLabelProtocolPortsIP / NetmaskAction
Allow ICMP (ping) traffic from other serversChoose a labelICMPLeave blankVPC subnet IP range (Ex: 10.0.0.0/16)Accept
Allow HTTP traffic from the internetChoose a labelTCPHTTP (80)All IPv4, All IPv6Accept
Allow HTTPS traffic from the internetChoose a labelTCPHTTPS (443)All IPv4, All IPv6Accept
Allow SSH connections from admin systemsChoose a labelTCPSSH (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:

shell
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:

shell
adduser non_root
# You'll be prompted to provide password

Add the new user to the sudo group for administrative privileges:

shell
adduser non_root sudo

Exit the session and SSH back into the server as your new user (from local Mac machine):

shell
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:

shell
mkdir ~/.ssh && chmod 700 ~/.ssh && vi ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys

Disable Root login and Password Authentication:

shell
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:

shell
sudo sshd -t

Confirm 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:

shell
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|addressfamily'
# Expected: permitrootlogin no, passwordauthentication no, addressfamily inet

Finally, restart the SSH service to apply the changes:

shell
sudo systemctl restart ssh

TIP

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:

shell
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:

shell
cat ~/.ssh/id_ed25519.pub

# The private IP is the 10.x.x.x address (also shown in the Linode dashboard)
ip -4 -brief addr

Setup 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 a hostssl <your-database-name> <your-db-username> <this server's private IP>/32 scram-sha-256 line, then run sudo systemctl reload postgresql.
  • SSH key - Add this server's public key (from the step above) to ~/.ssh/authorized_keys of non_root on the PostgreSQL server, using the LISH Console or an existing application server.
  • CA certificate - Restore ca.crt from your offline storage to this server, then install it with install -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:

shell
curl -LsSf https://astral.sh/uv/install.sh | sh

Load uv into your current shell:

shell
source ~/.local/bin/env
uv --version

mod_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:

shell
# 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 --version

NOTE

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:

shell
ssh-keygen -t ed25519 -C "django-server-github" -f ~/.ssh/github_deploy

Tell SSH to use this key for GitHub:

shell
cat >> ~/.ssh/config <<EOF
Host github.com
    IdentityFile ~/.ssh/github_deploy
    IdentitiesOnly yes
EOF
chmod 600 ~/.ssh/config

Grab 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:

shell
cat ~/.ssh/github_deploy.pub

TIP

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:

shell
sudo apt -y install git

Confirm git installation:

shell
git --version

Configure your git credentials locally:

shell
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:

shell
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_ENV to PROD.
  • Set POSTGRES_HOST to the PostgreSQL server's private IP (it must match the certificate's SAN exactly).
  • Set POSTGRES_SSLMODE to verify-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:

shell
python3 -c "import secrets; print(secrets.token_urlsafe(64))"

Only non_root needs to read the .env file, so lock it down:

shell
chmod 600 .env

Create 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:

shell
uv sync --locked --no-dev

# Must print the same version as `/usr/bin/python3 --version`
.venv/bin/python --version

Create 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:

shell
sudo install -d -m 750 -o non_root -g non_root /logs

Apache 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:

shell
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/django

Collect the static files. Keep passing --no-dev to uv run - without it, uv run re-syncs the environment and installs the development packages again:

shell
uv run --no-dev manage.py collectstatic

Create the Django Cache table (skip this if you use Redis for caching):

shell
uv run --no-dev manage.py createcachetable

Check the project for any configuration errors before deploying:

shell
uv run --no-dev manage.py check
uv run --no-dev manage.py check --deploy

Confirm there are no model changes without a migration (migrations should be created locally and committed, never on the server), then run the migrations:

shell
uv run --no-dev manage.py makemigrations --check --dry-run # Should print "No changes detected"
uv run --no-dev manage.py migrate

Create a Super User, if your application needs one (for example, for admin-only API endpoints):

shell
uv run --no-dev manage.py createsuperuser

NOTE

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:

shell
sudo apt -y install apache2 libapache2-mod-wsgi-py3

Start the Apache web server:

shell
sudo systemctl restart apache2

You 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:

shell
sudo chmod 710 /home/non_root
sudo chgrp www-data /home/non_root

Then give the www-data group read access to the project:

shell
# 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/.env

Set Global ServerName ​

To set the global ServerName for the Apache web server, open the primary Apache config file:

shell
sudo vi /etc/apache2/apache2.conf

Navigate to the bottom of the file and add the following line:

txt
ServerName api.example.com

Configure 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:

shell
sudo vi /etc/apache2/sites-available/000-default.conf

Overwrite its contents with the following configuration. It only serves Apache's default page (and later redirects to https); it never exposes the project directory:

apache
<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:

shell
sudo a2enmod rewrite

Check the configuration for errors, then restart the Apache server:

shell
sudo apache2ctl configtest
sudo systemctl restart apache2

Install Certbot and its Apache plugin (Debian's package also installs a systemd timer that renews certificates automatically):

shell
sudo apt -y install certbot python3-certbot-apache

At 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.

txt
api.example.com
www.api.example.com

Get 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:

shell
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:

shell
sudo certbot renew --dry-run

Now, update the newly generated SSL configuration file:

shell
sudo vi /etc/apache2/sites-available/000-default-le-ssl.conf

Most 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:

apache
<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:

shell
sudo apache2ctl configtest
sudo systemctl restart apache2

Now 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):

shell
# Both should return 403 or 404 - never the file contents
curl -sI http://<DJANGO Server IP Address>/.env
curl -sI https://api.example.com/.env

Deploy Updates ​

To deploy new code, run these commands on the server:

shell
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 rqworker

NOTE

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:

shell
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:

shell
sudo needrestart -k

You should now have a working Django server, securely connected to your database within the VPC.