Implement Django continuous deployment with Git hooks and automated testing

Intermediate 45 min May 16, 2026 72 views
Ubuntu 24.04 Debian 12 AlmaLinux 9 Rocky Linux 9

Set up automated Django deployment with Git hooks, testing pipelines, and production rollbacks. Configure continuous integration with database migrations, static file management, and zero-downtime deployments.

Prerequisites

  • Django application with requirements.txt
  • PostgreSQL database configured
  • sudo access on target server
  • Git repository with Django project

What this solves

Continuous deployment automates your Django application releases, reducing manual errors and deployment time. This tutorial sets up Git hooks that automatically test, build, and deploy your Django application when code is pushed to your repository, including database migrations and static file collection.

Step-by-step configuration

Create deployment user and directories

Set up a dedicated user for deployments and create the necessary directory structure.

sudo adduser --system --group --home /home/deploy deploy
sudo mkdir -p /var/www/myapp/{releases,shared/{logs,media,static}}
sudo chown -R deploy:deploy /var/www/myapp
sudo chmod 755 /var/www/myapp
sudo useradd -r -s /bin/bash -d /home/deploy deploy
sudo mkdir -p /var/www/myapp/{releases,shared/{logs,media,static}}
sudo chown -R deploy:deploy /var/www/myapp
sudo chmod 755 /var/www/myapp

Install required packages

Install Python, Git, and other dependencies needed for Django deployment.

sudo apt update
sudo apt install -y python3 python3-pip python3-venv git nginx postgresql-client redis-tools
pip3 install virtualenv gunicorn
sudo dnf update -y
sudo dnf install -y python3 python3-pip git nginx postgresql redis
pip3 install virtualenv gunicorn

Initialize bare Git repository

Create a bare Git repository that will receive pushes and trigger deployments.

sudo -u deploy git init --bare /home/deploy/myapp.git
sudo -u deploy mkdir -p /home/deploy/myapp.git/hooks
sudo chmod 755 /home/deploy/myapp.git/hooks

Create deployment script

Set up the main deployment script that handles testing, building, and deploying your Django application.

#!/bin/bash

set -e

Configuration

APP_NAME="myapp" APP_DIR="/var/www/$APP_NAME" RELEASE_DIR="$APP_DIR/releases/$(date +%Y%m%d_%H%M%S)" SHARED_DIR="$APP_DIR/shared" CURRENT_DIR="$APP_DIR/current" REPO_DIR="/home/deploy/$APP_NAME.git" BRANCH="main" echo "Starting deployment of $APP_NAME..."

Create release directory

mkdir -p $RELEASE_DIR cd $RELEASE_DIR

Clone the repository

git clone $REPO_DIR . git checkout $BRANCH

Create virtual environment

python3 -m venv venv source venv/bin/activate

Install dependencies

pip install -r requirements.txt

Run tests

echo "Running tests..." python manage.py test --settings=myapp.settings.test if [ $? -ne 0 ]; then echo "Tests failed. Aborting deployment." rm -rf $RELEASE_DIR exit 1 fi

Link shared directories

ln -s $SHARED_DIR/logs logs ln -s $SHARED_DIR/media media ln -s $SHARED_DIR/static staticfiles

Collect static files

echo "Collecting static files..." python manage.py collectstatic --noinput --settings=myapp.settings.production

Run database migrations

echo "Running database migrations..." python manage.py migrate --settings=myapp.settings.production

Update current symlink atomically

ln -sfn $RELEASE_DIR $CURRENT_DIR

Restart application server

sudo systemctl restart gunicorn sudo systemctl reload nginx

Keep only last 5 releases

cd $APP_DIR/releases ls -1t | tail -n +6 | xargs -d '\n' rm -rf -- echo "Deployment completed successfully!" echo "Release: $(basename $RELEASE_DIR)"

Set deployment script permissions

Make the deployment script executable and set correct ownership.

sudo chown deploy:deploy /home/deploy/deploy.sh
sudo chmod 755 /home/deploy/deploy.sh

Create post-receive Git hook

Set up the Git hook that automatically triggers deployment when code is pushed.

#!/bin/bash

set -e

echo "Received push to repository. Starting deployment..."

Change to deploy user

sudo -u deploy /home/deploy/deploy.sh echo "Deployment hook completed."

Set hook permissions

Make the Git hook executable.

sudo chown deploy:deploy /home/deploy/myapp.git/hooks/post-receive
sudo chmod 755 /home/deploy/myapp.git/hooks/post-receive

Configure Django test settings

Create a separate settings file for running tests during deployment.

from .base import *

Use SQLite for tests

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', } }

Disable migrations during tests

class DisableMigrations: def __contains__(self, item): return True def __getitem__(self, item): return None MIGRATION_MODULES = DisableMigrations()

Speed up tests

PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ]

Disable logging during tests

LOGGING_CONFIG = None

Test-specific settings

DEBUG = False TEMPLATE_DEBUG = False

Create production settings

Configure Django settings for production deployment with proper security and performance settings.

from .base import *
import os

Security settings

DEBUG = False ALLOWED_HOSTS = ['example.com', 'www.example.com']

Database

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': os.environ.get('DB_NAME', 'myapp_prod'), 'USER': os.environ.get('DB_USER', 'myapp'), 'PASSWORD': os.environ.get('DB_PASSWORD'), 'HOST': os.environ.get('DB_HOST', 'localhost'), 'PORT': os.environ.get('DB_PORT', '5432'), } }

Cache configuration with Redis

CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', } } }

Session storage

SESSION_ENGINE = 'django.contrib.sessions.backends.cache' SESSION_CACHE_ALIAS = 'default'

Static files

STATIC_URL = '/static/' STATIC_ROOT = '/var/www/myapp/shared/static' MEDIA_URL = '/media/' MEDIA_ROOT = '/var/www/myapp/shared/media'

Logging

LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { 'file': { 'level': 'INFO', 'class': 'logging.FileHandler', 'filename': '/var/www/myapp/shared/logs/django.log', }, }, 'loggers': { 'django': { 'handlers': ['file'], 'level': 'INFO', 'propagate': True, }, }, }

Configure Gunicorn service

Set up systemd service for running Django with Gunicorn.

[Unit]
Description=Gunicorn daemon for Django myapp
Requires=gunicorn.socket
After=network.target

[Service]
User=deploy
Group=deploy
WorkingDirectory=/var/www/myapp/current
Environment="DJANGO_SETTINGS_MODULE=myapp.settings.production"
ExecStart=/var/www/myapp/current/venv/bin/gunicorn \
          --access-logfile /var/www/myapp/shared/logs/gunicorn-access.log \
          --error-logfile /var/www/myapp/shared/logs/gunicorn-error.log \
          --workers 3 \
          --bind unix:/run/gunicorn.sock \
          myapp.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Create Gunicorn socket

Configure systemd socket for Gunicorn.

[Unit]
Description=Gunicorn socket

[Socket]
ListenStream=/run/gunicorn.sock
SocketUser=www-data
SocketGroup=www-data
SocketMode=0660

[Install]
WantedBy=sockets.target

Configure Nginx

Set up Nginx to serve your Django application with proper static file handling.

upstream myapp_server {
    server unix:/run/gunicorn.sock fail_timeout=0;
}

server {
    listen 80;
    server_name example.com www.example.com;
    
    client_max_body_size 50M;
    keepalive_timeout 5;
    
    # Static files
    location /static/ {
        alias /var/www/myapp/shared/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }
    
    # Media files
    location /media/ {
        alias /var/www/myapp/shared/media/;
        expires 1y;
        add_header Cache-Control "public";
    }
    
    # Application
    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_buffering off;
        
        proxy_pass http://myapp_server;
    }
    
    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    
    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

Enable and start services

Enable the site configuration and start all required services.

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl daemon-reload
sudo systemctl enable gunicorn.socket
sudo systemctl start gunicorn.socket
sudo systemctl enable nginx
sudo systemctl restart nginx

Create rollback script

Set up a script to quickly rollback to the previous release if needed.

#!/bin/bash

set -e

APP_DIR="/var/www/myapp"
CURRENT_DIR="$APP_DIR/current"
RELEASES_DIR="$APP_DIR/releases"

Get current and previous releases

CURRENT_RELEASE=$(readlink $CURRENT_DIR) PREVIOUS_RELEASE=$(ls -1t $RELEASES_DIR | sed -n '2p') if [ -z "$PREVIOUS_RELEASE" ]; then echo "No previous release found. Cannot rollback." exit 1 fi echo "Rolling back from $(basename $CURRENT_RELEASE) to $PREVIOUS_RELEASE"

Switch to previous release

ln -sfn $RELEASES_DIR/$PREVIOUS_RELEASE $CURRENT_DIR

Restart services

sudo systemctl restart gunicorn sudo systemctl reload nginx echo "Rollback completed successfully!" echo "Current release: $PREVIOUS_RELEASE"

Set rollback script permissions

Make the rollback script executable.

sudo chown deploy:deploy /home/deploy/rollback.sh
sudo chmod 755 /home/deploy/rollback.sh

Configure sudoers for deployment

Allow the deploy user to restart services without password.

sudo visudo -f /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart gunicorn
deploy ALL=(ALL) NOPASSWD: /bin/systemctl reload nginx
deploy ALL=(ALL) NOPASSWD: /bin/systemctl status gunicorn
deploy ALL=(ALL) NOPASSWD: /bin/systemctl status nginx

Add deployment remote to your project

Configure your local Git repository to push to the deployment server.

cd /path/to/your/django/project
git remote add production deploy@your-server:/home/deploy/myapp.git
git push production main
Never use chmod 777. It gives every user on the system full access to your files. Instead, fix ownership with chown and use minimal permissions like 755 for directories and 644 for files.

Configure automated testing pipeline

Create comprehensive test configuration

Set up Django tests to run automatically during deployment with proper database and cache configuration.

from .base import *
import tempfile

Use in-memory SQLite for speed

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': ':memory:', 'TEST': { 'NAME': ':memory:', }, } }

Disable cache during tests

CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', } }

Use temporary directory for media

MEDIA_ROOT = tempfile.mkdtemp()

Fast password hashing

PASSWORD_HASHERS = [ 'django.contrib.auth.hashers.MD5PasswordHasher', ]

Disable migrations

class DisableMigrations: def __contains__(self, item): return True def __getitem__(self, item): return None MIGRATION_MODULES = DisableMigrations()

Test settings

DEBUG = False SECRET_KEY = 'test-secret-key-not-for-production' EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' LOGGING_CONFIG = None

Add pre-deployment checks

Enhance the deployment script with comprehensive checks before deployment.

#!/bin/bash

set -e

echo "Running pre-deployment checks..."

Check if virtual environment exists and activate it

if [ ! -d "venv" ]; then echo "Creating virtual environment..." python3 -m venv venv fi source venv/bin/activate

Install dependencies

echo "Installing dependencies..." pip install -r requirements.txt

Check Django configuration

echo "Checking Django configuration..." python manage.py check --settings=myapp.settings.production

Run Django system checks

echo "Running Django system checks..." python manage.py check --deploy --settings=myapp.settings.production

Test database connection

echo "Testing database connection..." python manage.py dbshell --settings=myapp.settings.production <<< "\q" > /dev/null 2>&1 if [ $? -ne 0 ]; then echo "Database connection failed!" exit 1 fi

Run tests

echo "Running test suite..." python manage.py test --settings=myapp.settings.test --verbosity=2 if [ $? -ne 0 ]; then echo "Tests failed!" exit 1 fi

Check for pending migrations

echo "Checking for pending migrations..." MIGRATIONS_OUTPUT=$(python manage.py showmigrations --plan --settings=myapp.settings.production | grep "\[ \]") if [ ! -z "$MIGRATIONS_OUTPUT" ]; then echo "Pending migrations found:" echo "$MIGRATIONS_OUTPUT" fi

Run collectstatic dry-run

echo "Testing static file collection..." python manage.py collectstatic --noinput --dry-run --settings=myapp.settings.production > /dev/null echo "All pre-deployment checks passed!"

Update main deployment script

Integrate the pre-deployment checks into the main deployment script.

#!/bin/bash

set -e

Configuration

APP_NAME="myapp" APP_DIR="/var/www/$APP_NAME" RELEASE_DIR="$APP_DIR/releases/$(date +%Y%m%d_%H%M%S)" SHARED_DIR="$APP_DIR/shared" CURRENT_DIR="$APP_DIR/current" REPO_DIR="/home/deploy/$APP_NAME.git" BRANCH="main" echo "Starting deployment of $APP_NAME at $(date)..."

Create release directory

mkdir -p $RELEASE_DIR cd $RELEASE_DIR

Clone the repository

git clone $REPO_DIR . git checkout $BRANCH echo "Repository cloned successfully. Commit: $(git rev-parse --short HEAD)"

Run pre-deployment checks

/home/deploy/pre_deploy_checks.sh

Link shared directories

echo "Linking shared directories..." ln -s $SHARED_DIR/logs logs ln -s $SHARED_DIR/media media ln -s $SHARED_DIR/static staticfiles

Collect static files

echo "Collecting static files..." source venv/bin/activate python manage.py collectstatic --noinput --settings=myapp.settings.production

Run database migrations

echo "Running database migrations..." python manage.py migrate --settings=myapp.settings.production

Warm up the application

echo "Warming up application..." python manage.py check --settings=myapp.settings.production

Update current symlink atomically

echo "Switching to new release..." ln -sfn $RELEASE_DIR $CURRENT_DIR

Restart application server

echo "Restarting services..." sudo systemctl restart gunicorn sleep 2

Verify deployment

echo "Verifying deployment..." if ! sudo systemctl is-active --quiet gunicorn; then echo "Gunicorn failed to start! Rolling back..." /home/deploy/rollback.sh exit 1 fi sudo systemctl reload nginx

Health check

echo "Performing health check..." sleep 5 if ! curl -f -s http://localhost/health/ > /dev/null; then echo "Health check failed! Application may not be responding correctly." echo "Manual intervention may be required." fi

Keep only last 5 releases

echo "Cleaning up old releases..." cd $APP_DIR/releases ls -1t | tail -n +6 | xargs -d '\n' rm -rf -- 2>/dev/null || true echo "Deployment completed successfully at $(date)!" echo "Release: $(basename $RELEASE_DIR)" echo "Commit: $(cd $RELEASE_DIR && git rev-parse --short HEAD)"

Set script permissions

Make all deployment scripts executable.

sudo chown deploy:deploy /home/deploy/pre_deploy_checks.sh
sudo chmod 755 /home/deploy/pre_deploy_checks.sh
sudo chmod 755 /home/deploy/deploy.sh

Configure Redis for caching and sessions

For optimal Django performance, integrate Redis for caching and session storage. You can follow our detailed guide on configuring Django Redis caching and session storage for complete setup instructions.

Install Redis

Install Redis server for caching and session management.

sudo apt install -y redis-server
sudo systemctl enable --now redis-server
sudo dnf install -y redis
sudo systemctl enable --now redis

Add Redis to requirements

Include Redis client in your Django project dependencies.

Django>=4.2,<5.0
psycopg2-binary>=2.9.0
django-redis>=5.2.0
gunicorn>=20.1.0
whitenoise>=6.4.0
django-environ>=0.10.0

Verify your setup

# Check Git repository status
sudo -u deploy git --git-dir=/home/deploy/myapp.git --work-tree=/var/www/myapp/current status

Verify deployment structure

ls -la /var/www/myapp/ ls -la /var/www/myapp/releases/

Check services status

sudo systemctl status gunicorn sudo systemctl status nginx sudo systemctl status redis-server

Test deployment by pushing code

cd /path/to/your/project git add . git commit -m "Test deployment" git push production main

Check deployment logs

sudo journalctl -u gunicorn -n 50 tail -f /var/www/myapp/shared/logs/django.log

Common issues

Symptom Cause Fix
Tests fail during deployment Missing test dependencies or incorrect settings Check /var/www/myapp/current/logs/test.log and verify test settings
Static files not loading collectstatic failed or incorrect permissions Run python manage.py collectstatic --settings=myapp.settings.production manually
Database migration errors Migration conflicts or database permissions Check migration status with python manage.py showmigrations
Gunicorn fails to restart Python environment issues or code errors Check sudo journalctl -u gunicorn for detailed error logs
Permission denied errors Incorrect file ownership or permissions Fix with sudo chown -R deploy:deploy /var/www/myapp and chmod 755
Git hook not executing Hook script not executable or incorrect permissions Ensure chmod 755 /home/deploy/myapp.git/hooks/post-receive

Next steps

Running this in production?

Want this handled for you? Setting this up once is straightforward. Keeping it patched, monitored, backed up and performant across environments is the harder part. See how we run infrastructure like this for European teams.

Automated install script

Run this to automate the entire setup

Need help?

Don't want to manage this yourself?

We handle managed devops services for businesses that depend on uptime. From initial setup to ongoing operations.