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:
curl -LsSf https://astral.sh/uv/install.sh | shWARNING
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:
uv init my-project && cd my-projectInstall required Python packages:
# 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 cryptographyInitiate a new Django project:
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:
export PATH="/Library/PostgreSQL/18/bin/:$PATH"Connect to Postgres as the postgres user:
psql -U postgresCreate a new non-superuser database user:
-- 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:
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:
# 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:
# 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:
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:
uv run manage.py startapp usersDefine your custom models:
# 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):
passUpdate settings.py file:
# settings.py
INSTALLED_APPS = [
...
"users",
]
AUTH_USER_MODEL = "users.User"Update admin.py file:
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:
uv run manage.py makemigrations users
uv run manage.py migrateConfigure CORS
Add corsheaders to INSTALLED_APPS:
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:
MIDDLEWARE = [
...,
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
...,
]Configure allowed origins:
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 = TrueConfirm Local Setup
Create a superuser user using:
uv run manage.py createsuperuserStart local server using:
uv run manage.py runserverNavigate to Admin UI (http://localhost:8000/admin) and confirm that everything works.
Link to GitHub repo
Initialize git and link the project to a GitHub repo:
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 mainSetup 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 in iCloud Passwords |
| SSH Keys | You can add an existing SSH key or add this later when you deploy a new server |
| Disk Encryption | Enable |
| VPC | Create and assign a VPC |
| 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 private connections within VPC and allows HTTP traffic from internet) |
| Backups | Disable (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:
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 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:
adduser non_root- Add new user to the
sudogroup for administrative privileges:
adduser non_root sudo- Exit and SSH as the new user using password:
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:
mkdir ~/.ssh && vi ~/.ssh/authorized_keys- Disable Root login and Password Authentication:
sudo vi /etc/ssh/sshd_config
# Set `PasswordAuthentication` to `no`
# Set `PermitRootLogin` to `no`- Restart SSH service:
sudo systemctl restart sshdSetup 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:
curl -LsSf https://astral.sh/uv/install.sh | shClone the GitHub project
In order to clone the GitHub project, you'll first need to create a new SSH Key pair using:
ssh-keygen -t rsa -b 4096Grab the generated public key and add it to the GitHub account:
cat ~/.ssh/id_rsa.pubInstall git to manage the GitHub repos locally:
sudo apt -y install gitConfirm git installation using:
git --versionConfigure git locally:
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:
cd ~ && git clone <remote-URL>Setup Project on the Server
- After cloning the project, create an
.envfile with variables that match the local setup, but with updated values. Make sure theDJ_ENVvariable is set correctly.- Use this to create a new
SECRET_KEY:python -c "import secrets; print(secrets.token_urlsafe(64))"
- Use this to create a new
- Activate environment and install dependencies using the command
uv sync. - Create the log file using:
sudo mkdir /logs && cd /logs && sudo touch debug.log && sudo chmod 777 /logs && sudo chmod 666 /logs/debug.log- If
staticfilesdirectory is not committed to GitHub repo, collect the static files usinguv run manage.py collectstatic. - Check for Django errors (if any) by running:
uv run manage.py check
uv run manage.py check --deploy- Run migrations to get the database up to date:
uv run manage.py makemigrations # Shouldn't create any new migrations
uv run manage.py migrateInstall Apache & mod_wsgi
To install apache2 and mod_wsgi on Debian, use following command:
sudo apt -y install apache2 libapache2-mod-wsgi-py3Start the Apache web server using:
sudo systemctl restart apache2You 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:
sudo vi /lib/systemd/system/apache2.service
# Change PrivateTmp to falseReload daemons and restart Apache server:
sudo systemctl daemon-reload && sudo systemctl restart apache2Grant 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:
chmod o+rx /home/non_rootConfigure Apache and HTTPS
First update the following apache config file:
sudo vi /etc/apache2/sites-available/000-default.confWARNING
certbot certificate installation will fail due to WSGI lines in the apache conf file, so don't include them in this file.
<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:
sudo a2enmod headersRestart apache server to check for any errors:
sudo systemctl restart apache2Remove certbot-auto and any Certbot OS packages using:
sudo apt remove certbotInstall Certbot using snapd:
sudo apt -y install snapd && sudo snap install --classic certbotPrepare the Certbot command using:
sudo ln -s /snap/bin/certbot /usr/bin/certbotAt 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:
example.com
www.example.comGet and install a certificate using:
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:
sudo vi /etc/apache2/sites-enabled/000-default-le-ssl.confMost 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.
<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:
sudo systemctl restart apache2Now test the https endpoint at https://example.com/.
- The
wwwshould be redirected tonon-wwwas well. - And
httptraffic should be redirected tohttpsas well.
