Deploying a Django project to a Hetzner VPS with Ubuntu + Nginx + Gunicorn + PostgreSQL and Automate Deployment with Github

May 10, 2025

A detailed walkthrough for hosting your Django project in the cloud

I am not affiliated with Hetzner. This post is just for educational purposes.

Assumptions

  • You have a working Django project locally
  • Your project code is in a Git repository, I am using Github
  • You have registered a domain name you want to point to your server
  • You have a Hetzner Cloud account (or possibly any other VPS provider)

Stack Overview

  • Hetzner VPS: Your virtual server hardware
  • Ubuntu: A stable and widely supported Linux distribution
  • Nginx: High-performance web server acting as a reverse proxy (handles incoming HTTP/S requests, serves static files, passes dynamic requests to Gunicorn)
  • Gunicorn: A Python WSGI HTTP Server to run your Django application
  • PostgreSQL: An open-source relational database
  • Certbot: For obtaining and managing free SSL/TLS certificates from Let's Encrypt

1. Setting up Hetzner

First, log in to your Hetzner Cloud Console. If you don't already have one, create a new project. Next, select a server that meets your needs. For example, we'll use 'CX22', which offers 2 vCPUs, 4 GB of RAM, and a 40 GB NVMe SSD (you can always upgrade this later). Choose a server location that is geographically close to your target audience. During the server setup process, you can leave all settings at their defaults.

Once the server is created, you can log in. We will do this by adding your public SSH key, which is more secure than using a password. If you don't already have an SSH key, you can generate one on your local machine using the command: ssh-keygen -t ed25519. After generating the SSH key, locate the id_ed25519.pub file (this is your public key). It's typically found in the ~/.ssh/ directory on Linux/macOS or C:\Users\YourUsername\.ssh\ on Windows. Next, in your Hetzner account, navigate to Security > SSH Keys and paste your public key into the designated field.

With this done, you can securely log into your server without needing a password, if SSH has been set up correctly on the server.

2. Server Configuration

ssh root@YOUR_SERVER_IP

Update System Packages:

apt update
apt upgrade -y

Create a Non-Root User: (best practice for security).

adduser yourusername

Grant SUDO (Super User Do) Privileges:

usermod -aG sudo yourusername

/* 
usermod: Modify User Account
-a: append
-G: group(s)
 */

Set up Basic Firewall (UFW - Uncomplicated Firewall).

ufw allow OpenSSH
ufw enable
ufw status

/*
ufw allows SSH connections
ufw enable will block all connections except SSH
ufw status shows the current rules
*/

Log in as New User.

exit # Log out as root
ssh yourusername@YOUR_SERVER_IP

3. Install Required Software

Install System Dependencies.

sudo apt update
sudo apt install -y python3-pip python3-dev python3-venv libpq-dev nginx git

/*
python3-pip: Package installer for Python
python3-dev: Headers needed for building some Python packages
python3-venv: For creating virtual environments
libpq-dev: Headers needed to build the psycopg2 PostgreSQL adapter for Python
nginx: Web server
git: For cloning your project repository
*/

Install PostgresSQL Database.

sudo apt install -y postgresql postgresql-contrib

4. Set up PostgreSQL Database

Access Postgresql Shell

sudo -u postgres psql

Create a Database.

// Create Database
// Make sure the database name is the same as the database name set up in Django
CREATE DATABASE myprojectdb;

// Exit psql
\q

/*
For simplicity we will be using the default user 'postgres'
You can also set up a new user and grant this user the needed privileges
*/

5. Deploy Your Django Project

Go to your home directory and clone your github repository. I will be using HTTPS with a Personal Access Token, but you can use your own method.

  • Go to your GitHub account
  • Click on your profile picture in the top-right corner, then go to Settings
  • In the left sidebar, scroll down and click on Developer settings
  • Click on Personal access tokens, then Tokens (classic)
  • Click Generate new token, then Generate new token (classic)
  • Set an Expiration
  • Click Generate token at the bottom
  • IMPORTANT: Copy the token immediately. You won't be able to see it again!!!
git clone https://USERNAME:TOKEN@github.com/USERNAME/REPOSITORY.git

Create and Activate Virtual Environment.

python3 -m venv venv
source venv/bin/activate

Install Python Dependencies.

pip install gunicorn psycopg2-binary
pip freeze > requirements.txt

pip install -r requirements.txt

Configure Django Settings. Make sure you do NOT hardcode your secret keys!

// Create a new file inside your project root folder to save secret keys
touch .env

// Edit the file
nano env

// EXAMPLE
DB_NAME=db_name
DB_USERNAME=db_user
DB_PASSWORD=db_password
DB_HOST=db_host
DB_PORT=db_port

DJANGO_DEBUG=False
DJANGO_SECRET_KEY=fdsjf32hjklds-fsf3j20fj2-dshn210

ALLOWED_HOSTS=YOUR_SERVER_IP, YOUR_DOMAIN.com, www.YOUR_DOMAIN.com

SUPER_ADMIN_PASSWORD=SUPERPASSWORD

// Save and close (Ctrl+X, then Y, then Enter)

Add ALLOWED_HOSTS.

ALLOWED_HOSTS=['YOUR_SERVER_IP', 'your_domain.com', 'www.your_domain.com']

Database Settings.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST'),
        'PORT': os.getenv('DB_PORT'),
    }
}

Static Files.

STATIC_URL = '/static/'
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'static'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

Apply Migrations.

python manage.py migrate

Collect Static Files.

python manage.py collectstatic

Create Django Superuser.

python manage.py createsuperuser

6. Configure Gunicorn with Systemd

Systemd will manage the Gunicorn process, making sure it starts on boot and restarts if it crashes. We'll use a socket file for better management.

sudo nano /etc/systemd/system/gunicorn.socket

// Paste the following content in this file:
[Unit]
Description=gunicorn socket

[Socket]
ListenStream=/run/gunicorn.sock

[Install]
WantedBy=sockets.target

Save and close (Ctrl+X, then Y, then Enter)

Create Systemd Service File.

sudo nano /etc/systemd/system/gunicorn.service

Paste and modify the following.

[Unit]
Description=gunicorn daemon
Requires=gunicorn.socket
After=network.target

[Service]
User=USERNAME
Group=www-data
WorkingDirectory=/home/USERNAME/MY_PROJECT_DIR
ExecStart=/home/USERNAME/MY_PROJECT_DIR/venv/bin/gunicorn \
          --access-logfile - \
          --workers 3 \
          --bind unix:/run/gunicorn.sock \
          PROJECT_NAME.wsgi:application

[Install]
WantedBy=multi-user.target

Start and Enable Gunicorn.

sudo systemctl start gunicorn.socket
sudo systemctl enable gunicorn.socket // Starts socket on boot

sudo systemctl start gunicorn.service
sudo systemctl enable gunicorn.service // Starts service on boot

Check Status.

sudo systemctl status gunicorn.socket
sudo systemctl status gunicorn.service // Look for 'active (running)'

# Check for errors if it failed:
sudo journalctl -u gunicorn.service

Configure Nginx as Reverse Proxy. A reverse proxy acts as a man in the middle, between the client and the server. Nginx will sit in front of Gunicorn. So why should we use this?

  • Performance: Handles static files efficiently, reducing backend load
  • Security: Hides backend servers against DDoS attacks
  • Scalability: Easily add more backend servers
  • SSL Offloading: Nginx manages HTTPS

Create Nginx Server Block

sudo nano /etc/nginx/sites-available/MY_PROJECT

Paste and modify the following.

server {
    listen 80;
    server_name YOUR_SERVER_IP your_domain.com www.your_domain.com;

    location = /favicon.ico { access_log off; log_not_found off; }

    location /static/ {
        alias /home/USERNAME/MY_PROJECT_DIR;
    }

    location /media/ {
       root /home/USERNAME/MY_PROJECT_DIR;
    }

    location / {
        include proxy_params;
        proxy_pass http://unix:/run/gunicorn.sock;
    }
}

Why alias is often better for Django staticfiles:

Above we have used 'alias' for our static files. Django's collectstatic gathers files into a specific directory (STATIC_ROOT, staticfiles) which doesn't typically mirror the /static/ URL prefix directly under your project root. alias provides a way to map the /static/ URL directly onto that collected staticfiles directory, regardless of where it sits relative to your project's base directory.

Enable the Site.

sudo ln -s /etc/nginx/sites-available/myproject /etc/nginx/sites-enabled/

/*
ln -s  Creates a symbolic link (like a shortcut).
Source: /etc/nginx/sites-available/MY_PROJECT (your config file).
Destination: /etc/nginx/sites-enabled/ (where Nginx looks for active sites).
*/

Test Nginx Configuration.

sudo nginx -t

Allow Web Traffic Through Firewall.

sudo ufw allow 'Nginx Full' // Allows both HTTP (80) and HTTPS (443)
sudo ufw status

Let's test your website! Enter the following links in your URL bar:

  • http://YOUR_SERVER_IP
  • http://YOUR_DOMAIN.com

You should see your Django site, now served via Nginx, including static files (CSS/JS)!!

If your static files are still not loading correctly, go to your website and open the developer tools (F12). If you see a 403 Forbidden error in the console, it typically indicates a permission issue. To resolve this, run the following command on your server.

// Show ownership and permissions of each component in the full path to a file
// -o: Shows the owner of each path component.
// -m: Shows the permissions of each path component.
sudo namei -om /home/USERNAME/MY_PROJECT_DIR/staticfiles/css/main.css

/*
You might see something like:
drwxr-x--- username username username

d: It's a directory.
rwx: The owner has read, write, and execute permissions.
r-x: The group has read and execute permissions.
---: Others (everyone else on the system) have NO permissions!!
*/

This is a problem and needs to be resolved. Others currently don't have the permission to read the static files. We need to grant execute permission to "others" on your home directory.

sudo chmod o+x /home/USERNAME

/*
chmod: Command to change permissions.
o+x: For others, add execute permission.
*/

Reload Nginx.

sudo systemctl reload nginx

The 403 errors for your static files should now be resolved because www-data can now pass through /home/USERNAME to access the static directory where it does have the correct read permissions.

7. Secure with HTTPS

Before we can start with this section, your domain name must be pointing to your server's IP address via DNS 'A' records.

Install Certbot.

sudo apt install -y certbot python3-certbot-nginx

Obtain and Install Certificate.

sudo certbot --nginx -d your_domain.com -d www.your_domain.com

/*
Follow the prompts.
Redirect traffic to HTTPS.
Certbot will automatically update your Nginx configuration 
to handle HTTPS and set up automatic renewal.
*/

Verify Auto-Renewal.

// Should show 'active'
sudo systemctl status certbot.timer

Visiting https://your-domain.com should now be secured!

8. Automated Deployment using CI/CD with GitHub Actions

Automating deployments is very userful and highly recommended for frequent updates! This is called Continuous Integration/Continuous Deployment (CI/CD). This means that changes pushed to your main branch will automatically be deployed to the website. We will be using GitHub Actions for this.

Generate SSH Key for Deployment.

// Generate a new ssh key on your LOCAL MACHINE
// # -N "": No passphrase (required for automated use)
ssh-keygen -t ed25519 -f deploy_key -N ""

Add Public Key to the Server.

// Make sure the .ssh directory exists and has correct permissions:
// -p: Create parent directories as needed (won't error if directory exists)
// ~/.ssh: The .ssh directory in your home folder (default location for SSH)
mkdir -p ~/.ssh

// 700 = sets permissions so that:
// Owner can read, write, and execute (7)
// Group and others have no permissions (0 0)
chmod 700 ~/.ssh

Append the content of deploy_key.pub.

echo "PASTE_PUBLIC_KEY_CONTENT_HERE" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

Add Secrets to GitHub Repository. Now we need to store sensitive information securely in GitHub Secrets. Go to your GitHub repository -> Settings -> Secrets and variables -> Actions.

  • New repository secret: Name: SSH_PRIVATE_KEY | Value: Paste the entire content of the private key file (deploy_key). Make sure you copy everything, including -----BEGIN OPENSSH PRIVATE KEY----- and -----END OPENSSH PRIVATE KEY-----.
  • New repository secret: Name: SSH_HOST | Value: Your server's IP address (YOUR_SERVER_IP).
  • New repository secret: Name: SSH_USER | Value: The username you SSH into the server with (YOUR_USERNAME).
  • (Optional but Recommended) New repository secret: Name: SSH_PORT | Value: 22 (or your custom SSH port if you changed it).

Create the Workflow File.

  • In your local project repository, create the directory path .github/workflows/
  • Create a file named deploy.yml inside .github/workflows/

Paste the following content into deploy.yml (adjust values where needed!):

name: Deploy Django App

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Deploy to Server via SSH
      uses: appleboy/ssh-action@master
      with:
        host: ${{ secrets.SSH_HOST }}
        username: ${{ secrets.SSH_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT || 22 }}
        script: |

          cd /home/${{ secrets.SSH_USER }}/MY_PROJECT_DIR

          echo ">>> Pulling latest code..."
          git pull origin main

          echo ">>> Activating venv..."
          source venv/bin/activate

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

          echo ">>> Running migrations..."
          python manage.py migrate

          echo ">>> Collecting static files..."
          python manage.py collectstatic --noinput

          deactivate

          echo ">>> Restarting Gunicorn..."
          sudo systemctl restart gunicorn.service

          echo ">>> Checking Gunicorn status..."
          sudo systemctl status gunicorn.service --no-pager

          echo ">>> Deployment finished!"

Configure Passwordless Sudo.

Caution: This reduces security slightly.

The sudo systemctl restart gunicorn.service command in the script requires sudo. For automation, the YOUR_USERNAME user needs to be able to run this specific command without being prompted for a password.

This allows yourusername to run only the restart and status commands for the gunicorn.service unit via sudo without a password.Save and exit the editor (Ctrl+X, then Y, then Enter in nano).

// On your server, run sudo visudo. This safely edits the sudoers file
// Add the following line at the end
YOUR_USERNAME ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart gunicorn.service, /usr/bin/systemctl status gunicorn.service

/*
This allows YOUR_USERNAME to run the restart and 
status commands for the gunicorn.service without a password.
Save and exit the editor (Ctrl+X, then Y).
*/

Commit and Push the Workflow.

  1. You push changes to your main branch on GitHub
  2. GitHub detects the push and the .github/workflows/deploy.yml file
  3. It starts the "Deploy Django App" workflow
  4. The deploy job runs on an ubuntu-latest runner
  5. It checks out your code (mostly relevant for build/test steps, less so here).
  6. The appleboy/ssh-action step uses the secrets you configured to connect to your Hetzner VPS.
  7. It executes the commands listed in the script.
  8. If all commands succeed, the workflow finishes with a green checkmark. If any command fails, it stops and shows a red X.

You can monitor the progress and see the logs for each run under the "Actions" tab in your GitHub repository.

9. Troubleshooting

Here are some tips and some common commands you can run:

// See if systemd thinks the socket is active
sudo systemctl status gunicorn.socket

// If the status was failed or inactive, check its specific logs:
sudo journalctl -u gunicorn.socket --no-pager

// Verify the gunicorn.socket Configuration File exists
ls -l /etc/systemd/system/gunicorn.socket

// Double check the configuration files we created earlier
cat /etc/systemd/system/gunicorn.socket
cat /etc/systemd/system/gunicorn.service
cat /etc/nginx/sites-available/MY_PROJECT

// Make sure your settings in Django are correctly configured

// Make sure Nginx is running
sudo systemctl status nginx

// If you made changes to Gunicorn/Django code or systemd files
sudo systemctl daemon-reload
sudo systemctl restart gunicorn.service
sudo systemctl restart nginx

// Make sure your DNS records are set up and point to your SERVER_IP