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.