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.
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.
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
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
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
*/
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.
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
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?
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:
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.
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!
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.
Create the Workflow File.
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.
You can monitor the progress and see the logs for each run under the "Actions" tab in your GitHub repository.
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