This repository demonstrates how to deploy a Django application from local development to production using:
- Django
- Docker & Docker Compose
- PostgreSQL
- GitHub Actions (CI/CD)
- Linode VPS
- Nginx
- Gunicorn
- Custom Domain
- SSL (Let’s Encrypt)
You will go step-by-step from:.
Local → Docker → GitHub → Linode → Domain → HTTPS
Install the following on your system:
- Git
- Python 3.10+
- pip
- Docker Desktop
- VS Code (recommended)
git clone https://github.com/Kamakhya-s/Production-Ready-Django-React-SaaS-Platform.git
cd Production-Ready\ Django\ +\ React\ SaaS\ Platform/rm -rf .gitThis wipes your commit history & remote. Now it is just files in your local computer, not a repo.
Go to GitHub → Click New Repository → Name: django-clickmart
git init
git add .
git commit -m "Initial project setup"
git branch -M main
git remote add origin https://github.com/<YOUR-USERNAME>/<REPOSITORY-NAME>.git
git push -u origin mainNow you have the full source code in your own repo.
Create virtual environment
cd backend-drf
python3 -m venv env
source env/bin/activate # Mac / Linux
# OR
env\Scripts\activate # WindowsInstall dependencies
pip install -r requirements.txtCreate .env file
DEBUG=True
SECRET_KEY=<YOUR-SECRET-KEY>
# Database Settings
DB_NAME=<DATABASE-NAME>
DB_USER=<POSTGRES-USERNAME>
DB_PASSWORD=<YOUR-PASSWORD>
DB_HOST=localhost
DB_PORT=5432
# Email Configuration
EMAIL_HOST_USER=<YOUR-EMAIL-ADDRESS>
EMAIL_HOST_PASSWORD=<PASSWORD> # USE APP PASSWORD IF YOU ARE USING GMAILCreate database tables and run the Django server
python manage.py migrate
python manage.py runserverCreate .env file inside /frontend/ directory and write:
VITE_SERVER_BASE_URL=http://127.0.0.1:8000/api/v1And run the frontend - React
npm install
npm run devGo to http://localhost:5173/
Optional: You can now create superuser and add some products.
To learn about deployment, continue to next step...
docker --version
docker compose versionCreate a new file "Dockerfile" inside /backend-drf/ folder
# Purpose: A Dockerfile is a step-by-step instruction file that tells Docker how to build and run our application.
FROM python:3.10-slim
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
# gunicorn = production server, clickmart_main.wsgi:application = Django entry point, --bind 0.0.0.0:8000 = external traffic. Reminaing: tuning options
# A worker is just one instance of your Django app running inside Gunicorn.
CMD ["gunicorn", "clickmart_main.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3" , "--timeout", "180"]Create a new file "Dockerfile" inside /frontend/ folder
# Stage 1: Build
FROM node:18 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Build arguments for environment variables
ARG VITE_SERVER_BASE_URL
# This line passes an environment variable into the Docker container so the React app knows the backend API URL.
ENV VITE_SERVER_BASE_URL=$VITE_SERVER_BASE_URL
RUN npm run build
# Stage 2: Nginx, alpine means the lighter version of Nginx
FROM nginx:alpine
# Copy build output to Nginx html directory
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]services:
db:
image: postgres:16-alpine
env_file:
- .env.production
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: ./backend-drf
ports:
- "8000:8000"
env_file:
- ./backend-drf/.env.docker
depends_on:
- db
volumes:
- ./backend-drf/static:/app/static
- ./backend-drf/media:/app/media
command: >
sh -c "python manage.py collectstatic --noinput &&
python manage.py migrate &&
python manage.py runserver 0.0.0.0:8000"
frontend:
build:
context: ./frontend
args:
VITE_SERVER_BASE_URL: "http://backend:8000/api/v1"
ports:
- "5173:80"
depends_on:
- backend
# This creates a named Docker volume to permanently store PostgreSQL data.
# Without this:
# Database data is stored inside the container
# If container is deleted → data is lost
# With this:
# Data is stored in a Docker-managed volume
# Data persists even if container stops or restarts
volumes:
postgres_data:Make sure to create a copy of .env and name it as .env.docker
SECRET_KEY=<YOUR-DJANGO-SECRETKEY>
DEBUG=True
# Database Settings
DB_NAME=<YOUR_DOCKER-DB>
DB_USER=postgres
DB_PASSWORD=<PASSWORD>
DB_HOST=db
DB_PORT=5432
EMAIL_HOST_USER=<YOUR-EMAIL-ADDRESS>
EMAIL_HOST_PASSWORD=<YOUR-PASSWORD> # app password if you're using Gmail accountRun this command to Dockerize your project:
docker compose up --buildYour project is now Dockerized ✅
See the docker container health:
docker compose psYou can try creating superuser inside Docker container.
docker compose exec backend python manage.py createsuperuserOn your local machine:
ls ~/.ssh
ssh-keygen -t ed25519 -C "clickmart-linode"Copy the public key and add it to Linode UI:
cat ~/.ssh/linode.pub
ssh root@<LINODE_IP>Update the server:
apt update && apt upgrade -yInstall Docker:
curl -fsSL https://get.docker.com | sh
docker --versionInstall Docker Compose:
apt install docker-compose-plugin -yInstall Git:
apt install git -y
git --version✅ Docker, Docker Compose, and Git installed successfully.
Reconnect to SSH (if disconnected):
cd /opt
mkdir clickmart
cd clickmart
git clone https://github.com/your-repo.git .Repo is now cloned inside /opt/clickmart
In docker-compose.yml:
VITE_SERVER_BASE_URL="http://<LINODE_IP>:8000/api/v1"Push changes:
git push origin mainnano backend-drf/.env.production
nano backend-drf/.env.dockerAdd required environment variables inside it.
Required Ports (Initial Setup)
SSH: 22
Django Backend: 8000
React Frontend: 5173Inbound Rules:
Allow TCP 22
Allow TCP 8000
Allow TCP 5173docker compose up --build -d
docker compose psTest in browser:
Backend: http://<LINODE_IP>:8000/
Frontend: http://<LINODE_IP>:5173/
In local settings.py:
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
CORS_ALLOWED_ORIGINS = [
'http://localhost:5173',
'http://<LINODE_IP>:5173'
]In local .env.docker:
ALLOWED_HOSTS=<LINODE_IP>,localhost,127.0.0.1In linode .env.docker:
ALLOWED_HOSTS=<LINODE_IP>,localhost,127.0.0.1In docker-compose.yml:
VITE_SERVER_BASE_URL: "http://<LINODE_IP>/api/v1"Push to GitHub:
git add .
git commit -m "Allowed host & environments added"
git push origin mainThis will push the changes to GitHub.
But first...
While logged-in to Linode:
git pull origin mainRebuild containers:
docker compose down -v
docker compose up --build -d❗Never automate something you haven’t done manually.
In local project:
Create a new file:
.github/workflows/automate.ymlname: Auto Deploy to Linode
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.LINODE_HOST }}
username: ${{ secrets.LINODE_USER }}
key: ${{ secrets.LINODE_SSH_KEY }}
script: |
cd /opt/clickmart
git pull origin main
docker compose up --build -dAdd GitHub Secrets: GitHub → Your Repository → Settings → Secrets and variables → Actions → New repository secret
LINODE_HOST → <LINODE_IP>
LINODE_USER → root
LINODE_SSH_KEY → Private SSH Key
git add .
git commit -m "CI/CD Setup"
git push origin mainCheck GitHub Actions tab.
Make a small frontend change and confirm auto-deploy.
✅ Auto deploy successful.
From local project, create file:
nginx/default.confserver {
listen 80;
# Frontend (React)
location / {
proxy_pass http://frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Backend (Django)
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Django admin & static
location /admin/ {
proxy_pass http://backend:8000;
}
location /static/ {
proxy_pass http://backend:8000;
}
location /media/ {
proxy_pass http://backend:8000;
}
}
- Add nginx service
- Remove ports from backend & frontend
- Update frontend API URL:
VITE_SERVER_BASE_URL="/api/v1"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- frontend
- backend
Push changes:
git add .
git commit -m "Nginx Setup"
git push origin mainKeep:
22(SSH)80(HTTP)
Remove:
8000(Backend)5173(Frontend)
http://<LINODE_IP>/
If you get error: Add backend to allowed host in linode server manually.
Restart docker:
docker compose down -v
docker compose up --build -dAdd gunicorn inside requirements.txt:
No special change is required other than ensuring requirements.txt is installed. Gunicorn will be installed automatically via dependencies.
Replace the Django run command with Gunicorn:
command: >
gunicorn clickmart_main.wsgi:application --bind 0.0.0.0:8000 --workers 3
- clickmart_main.wsgi:application → Django entry point
- --bind 0.0.0.0:8000 → Listen on all interfaces
- --workers 3 → Run 3 Python worker processes
git add .
git commit -m "Deploy Gunicorn"
git push origin main
✅ We did not change the application code.
✅ We only changed how Python code is executed in production.
SSH into the Linode server:
ssh root@<LINODE_IP>
cd /opt/clickmart
docker compose logs backend
Purchase a domain from any provider (GoDaddy, Namecheap, etc.).
Connect Domain to Linode (DNS) Add the following A records in your domain DNS:
| Type | Host | Value |
|---|---|---|
| A | @ | <YOUR_LINODE_IP> |
| A | www | <YOUR_LINODE_IP> |
Wait for DNS propagation (usually a few minutes to a few hours).
Certbot modifies the Nginx config directly on the server, so we must remove it from Git tracking.
git rm --cached nginx/default.conf
- Removes the file from Git
Add to .gitignore:
nginx/default.conf
git add .
git commit -m "Make nginx config server-managed"
git push origin main
- Create
nginx/default.conffile - Add domain to this file:
server_name example.com www.example.com;
Restart nginx:
docker compose restart nginx
Add your domain into .env.docker
Restart backend:
docker compose restart backend
In the server root directory, create folders:
mkdir -p certbot/www
mkdir -p certbot/conf
Edit docker-compose.yml locally (nginx service):
volumes:
- ./certbot/www:/var/www/certbot
- ./certbot/conf:/etc/letsencrypt
Push to main branch.
Edit nginx/default.conf
Add this block:
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
Restart Nginx container:
docker compose restart nginx
Make sure the site with HTTP still works at this point:
apt update
apt install certbot -y
certbot certonly \
--webroot \
-w /opt/clickmart/certbot/www \
-d djangoclickmart.store \
-d www.djangoclickmart.store
Edit nginx/default.conf again:
Replace with FINAL CONFIG:
server {
listen 80;
server_name djangoclickmart.store www.djangoclickmart.store;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name djangoclickmart.store www.djangoclickmart.store;
ssl_certificate /etc/letsencrypt/live/djangoclickmart.store/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/djangoclickmart.store/privkey.pem;
location / {
proxy_pass http://frontend:80;
}
location /api/ {
proxy_pass http://backend:8000;
}
location /admin/ {
proxy_pass http://backend:8000;
}
location /static/ {
alias /static/;
}
}
docker compose restart nginx
Congratulations 🎉 You did it.
This guide explains how to fix issues where media files (uploaded images) are not loading correctly in production.
- Login to your production server.
- Open the Nginx config file:
nano nginx/default.conf- Add the following block inside the HTTPS server block:
location /media/ {
alias /media/;
}
This tells Nginx to serve uploaded media files directly. 4. Restart nginx container:
docker compose restart nginx
- Open
docker-compose.yml- in your local project - Inside the nginx service, add the media volume mapping:
nginx:
volumes:
- ./backend-drf/media:/media
This allows the Nginx container to access uploaded media files created by Django.
- Commit and push the changes:
git add .
git commit -m "Serve media files using nginx"
git push origin main
Try opening a media file directly in the browser:
https://your-domain.com/media/example.jpg
If the image loads, media serving is working correctly.
If media files load directly but still do not appear on the webpage, update the serializer to return a relative media path.
- Open
products/serializers.py - Update
ProductSerializer- or whatever serializer the image is coming from. Refer to below code:
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
image = serializers.SerializerMethodField()
class Meta:
model = Product
fields = "__all__"
def get_image(self, obj):
return obj.image.url if obj.image else None
This ensures the API returns: /media/products/image.jpg instead of Docker-internal URLs like backend:8000
- Commit and push again:
git add .
git commit -m "Fix media image URL in serializer"
git push origin main
- Test again.