Skip to content

Deploy Django Project in Linode

This setup utilizes Apache and mod_wsgi to deploy a new Django Project. It uses a MacOS-based machine for local setup and a Debian-based Linode Server for production server setup. It uses Postgres database for both models and also cache and on both local and server setup.

NOTE

This setup utilizes a Public Subnet, since the Django server should be accessible via internet.

Local (Mac) Setup

In order to create a project, we first need to install uv package manager using:

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

WARNING

Make sure the Python version used in your project matches with the Linux server's Python version, else you'll run into issues with mod_wsgi.

Create a new Python project using uv:

shell
uv init my-project && cd my-project

Install required Python packages:

shell
# Django common packages
# uuid6 can be ignored if you're using python 3.14 which added built-in support for uuid7.
uv add django django-filter django-cors-headers django-redis django-storages Python-dotenv requests boto3 pillow 'qrcode[pil]' pyotp uuid6 psycopg2-binary

# User agents to record devices info
uv add pyyaml ua-parser user-agents django-user-agents

# Needed to serve S3 files via CloudFront (recommended)
uv add cryptography

Initiate a new Django project:

shell
django-admin startproject my_site .

Setup a local database

Install Postgres database locally using the instructions from this link.

After installing the database, add the bin directory to the PATH variable:

shell
export PATH="/Library/PostgreSQL/18/bin/:$PATH"

Connect to Postgres as the postgres user:

shell
psql -U postgres

Create a new non-superuser database user:

sql
-- Replace <Your Password> with a strong password of your choice
CREATE USER my_user ENCRYPTED PASSWORD '<Your Password>';

CREATE DATABASE my_database WITH OWNER=my_user;

Create an .env file

Create a new .env file in the Django project's root directory and define following variables in it:

txt
DJ_ENV=TEST
SECRET_KEY="your-secret"

POSTGRES_DATABASE=my_database
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=my_user
POSTGRES_PASSWORD=<Your Password>

[email protected]
EMAIL_HOST_PASSWORD=your-password
SERVER_EMAIL="Server Admin <[email protected]>"
ADMIN_EMAIL="Admin <[email protected]>"
MANAGER_EMAIL="Manager <[email protected]>"

Create Static & Media Directories

Create directories named static, staticfiles and media under the project root directory.

Update settings.py

Update the settings.py file to utilize the variables from .env file using python-dotenv package. Here are the changes to the settings file:

python
# settings.py
import os

from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv(os.path.join(BASE_DIR, ".env"))

# Identify test environment
is_test = os.getenv("DJ_ENV") == "TEST"

# Logging for debugging purposes
LOGGING = {
    "version": 1,
    "disable_existing_loggers": True,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
            "style": "{",
        },
        "simple": {
            "format": "{levelname} {asctime} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "file": {
            "level": "DEBUG",
            "class": "logging.handlers.RotatingFileHandler",
            "filename": os.path.join(BASE_DIR, "debug.log") if is_test else "/logs/debug.log",
            "formatter": "verbose",
            "maxBytes": 1024 * 1024 * 50,
            "backupCount": 1,
        },
        "mail_admins": {
            "level": "ERROR",
            "class": "django.utils.log.AdminEmailHandler",
            "formatter": "simple",
        },
    },
    "loggers": {
        "sin": {
            "handlers": ["file", "mail_admins"],
            "propagate": False,
            "level": "DEBUG",
        },
    },
}

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = is_test

ALLOWED_HOSTS = [
    "prod-public-ip",
    "example.com",
    "www.example.com",
    "api.example.com",
    "www.api.example.com",
]
if is_test:
    ALLOWED_HOSTS.extend(["localhost", "127.0.0.1"])

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql_psycopg2",
        "NAME": os.getenv("POSTGRES_DATABASE"),
        "HOST": os.getenv("POSTGRES_HOST"),
        "PORT": os.getenv("POSTGRES_PORT"),
        "USER": os.getenv("POSTGRES_USER"),
        "PASSWORD": os.getenv("POSTGRES_PASSWORD"),
        "CONN_MAX_AGE": 600,
        "CONN_HEALTH_CHECKS": True,
    }
}

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
        "LOCATION": "django_cache",
    }
}

TIME_ZONE = "Asia/Kolkata"

STATIC_URL = "static/"
STATICFILES_DIRS = [
  BASE_DIR / "static" # project-level assets
]
STATIC_ROOT = BASE_DIR / "staticfiles" # the output directory for collectstatic

MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"

if not is_test:
    # HTTPS handling
    SECURE_SSL_REDIRECT = True
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
    USE_X_FORWARDED_HOST = True

    # HSTS
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True

    # Others
    CSRF_COOKIE_SECURE = True
    SESSION_COOKIE_SECURE = True

# Email Configuration (User Secure Server config)
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtpout.secureserver.net"
EMAIL_PORT = 465
EMAIL_USE_SSL = True
EMAIL_USE_TLS = False
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
SERVER_EMAIL = os.getenv("SERVER_EMAIL")
DEFAULT_FROM_EMAIL = SERVER_EMAIL

ADMINS = [os.getenv("ADMIN_EMAIL"), os.getenv("MANAGER_EMAIL")]
MANAGERS = [os.getenv("ADMIN_EMAIL"), os.getenv("MANAGER_EMAIL")]

Update urls.py

Update project-level urls.py to serve static and media files from STATIC_ROOT and MEDIA_ROOT respectively locally:

python
# urls.py
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # your URL patterns
]

if settings.DEBUG:
    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Update wsgi.py

Update wsgi.py to avoid server errors in production environment:

python
import os
import sys
from pathlib import Path

from django.core.wsgi import get_wsgi_application

# To load all project-level env variables
SETTINGS_DIR = Path(__file__).resolve().parent

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

# Add following lines to the file, else you will run into _"ModuleNotFoundError: No module named ... error
# (Web page will show Internal Server Error)
sys.path.append(str(SETTINGS_DIR))

application = get_wsgi_application()

Custom Models

Create a new app for custom models:

shell
uv run manage.py startapp users

Define your custom models:

python
# users/models.py
# uuid6.uuid7 should be replaced with uuid.uuid7 later
import uuid6
from django.contrib.auth.models import AbstractUser
from django.db import models

class TimeTrackedModel(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid6.uuid7,
        editable=False,
        help_text="System-generated unique identifier (UUID).",
    )
    created_at = models.DateTimeField(
        auto_now_add=True, help_text="Date and time when this record was created."
    )
    updated_at = models.DateTimeField(
        auto_now=True, help_text="Date and time when this record was last updated."
    )

    class Meta:
        abstract = True

class User(TimeTrackedModel, AbstractUser):
    pass

Update settings.py file:

python
# settings.py
INSTALLED_APPS = [
    ...
    "users",
]

AUTH_USER_MODEL = "users.User"

Update admin.py file:

python
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from users.models import User

admin.site.register(User, UserAdmin)

Migrations

Create and run initial migrations using:

shell
uv run manage.py makemigrations users
uv run manage.py migrate

Configure CORS

Add corsheaders to INSTALLED_APPS:

python
INSTALLED_APPS = [
    # ...
    "corsheaders",
    # ...
]

Add CORS Middleware but make sure it is placed as high as possible, especially before any middleware that can generate responses such as Django's CommonMiddleware or Whitenoise's WhiteNoiseMiddleware:

python
MIDDLEWARE = [
    ...,
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    ...,
]

Configure allowed origins:

python
CORS_ALLOWED_ORIGIN_REGEXES = [
    r"^https://www.[a-zA-Z]*\.example\.com$",
    r"^https://[a-zA-Z]*\.example\.com$",
    "https://example.com",
    "https://www.example.com",
    "http://localhost:3000",
    "http://127.0.0.1:3000"
]
CORS_ALLOW_CREDENTIALS = True

Confirm Local Setup

Create a superuser user using:

shell
uv run manage.py createsuperuser

Start local server using:

shell
uv run manage.py runserver

Navigate to Admin UI (http://localhost:8000/admin) and confirm that everything works.

Initialize git and link the project to a GitHub repo:

shell
echo "# test" >> README.md
git init
git add README.md
git commit -m "first commit"
git branch -M main
git remote add origin <remote-URL> # You'll need to create a GitHub repo to grab this remote URL
git push -u origin main

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 in iCloud Passwords
SSH KeysYou can add an existing SSH key or add this later when you deploy a new server
Disk EncryptionEnable
VPCCreate and assign a VPC
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 private connections within VPC and allows HTTP traffic from internet)
BackupsDisable (The backups are useful only for databases)

Upgrade Packages

TIP

Use LISH Console to connect to the Linode server. Or if you added a SSH key above, you can login from your local machine directly.

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 date by running date command in the terminal.

Disable Root Login

NOTE

LISH Console doesn't rely on SSH, so you can still access internals of your system using it, including root login.

  • First create a limited user account using:
shell
adduser non_root
  • Add new user to the sudo group for administrative privileges:
shell
adduser non_root sudo
  • Exit and SSH as the new user using password:
shell
exit
ssh non_root@<Linode IP>
  • Add SSH Public key (of the local Mac machine that needs to SSH into this server) to authorized keys:
shell
mkdir ~/.ssh && vi ~/.ssh/authorized_keys
  • Disable Root login and Password Authentication:
sh
sudo vi /etc/ssh/sshd_config
# Set `PasswordAuthentication` to `no`
# Set `PermitRootLogin` to `no`
  • Restart SSH service:
sh
sudo systemctl restart sshd

Setup Database Server

At this point, if you don't have a Postgres Database server setup for production use, you'll need to setup one.

Setup Django Project

uv

Before you clone and setup Django project, install uv package manager using:

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

Clone the GitHub project

In order to clone the GitHub project, you'll first need to create a new SSH Key pair using:

shell
ssh-keygen -t rsa -b 4096

Grab the generated public key and add it to the GitHub account:

shell
cat ~/.ssh/id_rsa.pub

Install git to manage the GitHub repos locally:

shell
sudo apt -y install git

Confirm git installation using:

shell
git --version

Configure git locally:

shell
git config --global user.name "Your Name"
git config --global user.email "<your email ID>"

Now clone the GitHub repo in your home directory on the Linux Server using:

shell
cd ~ && git clone <remote-URL>

Setup Project on the Server

  • After cloning the project, create an .env file with variables that match the local setup, but with updated values. Make sure the DJ_ENV variable is set correctly.
    • Use this to create a new SECRET_KEY: python -c "import secrets; print(secrets.token_urlsafe(64))"
  • Activate environment and install dependencies using the command uv sync.
  • Create the log file using:
shell
sudo mkdir /logs && cd /logs && sudo touch debug.log && sudo chmod 777 /logs && sudo chmod 666 /logs/debug.log
  • If staticfiles directory is not committed to GitHub repo, collect the static files using uv run manage.py collectstatic.
  • Check for Django errors (if any) by running:
shell
uv run manage.py check
uv run manage.py check --deploy
  • Run migrations to get the database up to date:
shell
uv run manage.py makemigrations # Shouldn't create any new migrations
uv run manage.py migrate

Install Apache & mod_wsgi

To install apache2 and mod_wsgi on Debian, use following command:

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

Start the Apache web server using:

shell
sudo systemctl restart apache2

You should now have a working website at http://<DJANGO Server IP Address>.

Apache service changes for Django Logging

By default, the apache server logs the messages from Django inside a systemd-private- directory. To avoid this behavior, change this:

shell
sudo vi /lib/systemd/system/apache2.service
# Change PrivateTmp to false

Reload daemons and restart Apache server:

shell
sudo systemctl daemon-reload && sudo systemctl restart apache2

Grant Access to Home Directory

Apache Server runs with www-data user and it requires access to the home directory, in order to access the Django project contents. You can do this by running:

shell
chmod o+rx /home/non_root

Configure Apache and HTTPS

First update the following apache config file:

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

WARNING

certbot certificate installation will fail due to WSGI lines in the apache conf file, so don't include them in this file.

apache
<VirtualHost *:80>
    DocumentRoot "/home/non_root/my-project/"
    ServerName example.com
    ServerAlias www.example.com
    ServerAdmin [email protected]

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    # Allow access to static files
    <Directory /home/non_root/my-project/staticfiles>
        Require all granted
    </Directory>
    Alias /static /home/non_root/my-project/staticfiles

    # Granting access to wsgi.py
    <Directory "/home/non_root/my-project">
        Require all granted
    </Directory>

    <Directory "/home/non_root/my-project/my_site">
        <Files wsgi.py>
            Require all granted
        </Files>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    # To run WSGI daemon process (commented to avoid certbot errors)
    # WSGIDaemonProcess example.com python-home=/home/non_root/my-project/.venv python-path=/home/non_root/my-project
    # WSGIProcessGroup example.com
    # WSGIApplicationGroup %{GLOBAL}
    # WSGIScriptAlias / /home/non_root/my-project/my_site/wsgi.py
    # WSGIPassAuthorization On
</VirtualHost>

Since Header set is used, in order to make GET calls from frontend to retrieve backend static files, enable headers module using:

shell
sudo a2enmod headers

Restart apache server to check for any errors:

shell
sudo systemctl restart apache2

Remove certbot-auto and any Certbot OS packages using:

shell
sudo apt remove certbot

Install Certbot using snapd:

shell
sudo apt -y install snapd && sudo snap install --classic certbot

Prepare the Certbot command using:

shell
sudo ln -s /snap/bin/certbot /usr/bin/certbot

At this point, you'll need to point the domain to the current IP address. You can do this using Route53 or similar service and edit following A records:

txt
example.com
www.example.com

Get and install a certificate using:

shell
sudo certbot --apache -v
# Details to be filled as follows:
#   * Email: `[email protected]`
#   * Terms of Service: `Y`
#   * Share Email Address: `N`
#   * Domain Names: `example.com,www.example.com`
#   * Unable to find ServerName: `Select default/original conf file`

Now update SSL config file:

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

Most of the content will be similar to what certbot generates, except for WSGI config, Rewrite config, header sets, valid host grants and root directory access.

apache
<IfModule mod_ssl.c>
<VirtualHost *:443>
    DocumentRoot "/home/non_root/my-project/"
    ServerName example.com
    ServerAlias www.example.com
    ServerAdmin [email protected]

    SSLEngine on
    Include /etc/letsencrypt/options-ssl-apache.conf
    SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/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

    SetEnvIfNoCase Host example\.com VALID_HOST

    # Allow access to static files
    <Directory /home/non_root/my-project/static>
        Require env VALID_HOST
    </Directory>
    Alias /static /home/non_root/my-project/static

    # Granting access to wsgi.py
    <Directory "/home/non_root/my-project">
        Require env VALID_HOST
    </Directory>

    <Directory "/home/non_root/my-project/my_site">
        <Files wsgi.py>
            Require env VALID_HOST
        </Files>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require env VALID_HOST
    </Directory>

    LogLevel info

    # To run WSGI daemon process
    WSGIDaemonProcess example.com python-home=/home/non_root/my-project/venv python-path=/home/non_root/my-project
    WSGIProcessGroup example.com
    WSGIApplicationGroup %{GLOBAL}
    WSGIScriptAlias / /home/non_root/my-project/my_site/wsgi.py
    WSGIPassAuthorization On

    # To avoid invalid host addresses reach Django (such as Linode IP address)
    <Directory "/">
        #Require expr %{HTTP_HOST} == "example.com"
        SetEnvIfNoCase Host example\.com VALID_HOST
        Require env VALID_HOST
        Options
    </Directory>

</VirtualHost>
</IfModule>

Restart apache server using:

shell
sudo systemctl restart apache2

Now test the https endpoint at https://example.com/.

  • The www should be redirected to non-www as well.
  • And http traffic should be redirected to https as well.