Deploy Django on a Hetzner Cloud Server
Provision a Hetzner Cloud server with the hcloud CLI, cloud firewall, and cloud-init, then run Django with Gunicorn, Nginx, PostgreSQL, and backups.
Problem statement
Hetzner Cloud is a popular choice for Django projects that have outgrown a free platform tier but do not need a hyperscaler: small shared vCPU servers are inexpensive, the API is simple, and there is a first-party cloud firewall.
What Hetzner does not give you is a managed application platform. You get a bare Linux VM. That means the deployment quality depends on decisions you make in the first hour:
- whether SSH is exposed to the whole internet with password logins
- whether the server is reproducible or hand-configured
- where PostgreSQL runs and how it is backed up
- whether a server rebuild loses uploaded files
This guide provisions a Hetzner Cloud server reproducibly with the hcloud CLI and cloud-init, locks down network access with a Hetzner Firewall, and runs Django behind Gunicorn and Nginx with PostgreSQL on the same host and off-server backups.
Quick answer
- Install
hcloud, create an API token for your project, and upload your SSH public key. - Create a Hetzner Firewall that allows 80 and 443 from anywhere and 22 only from your IP.
- Create the server with
hcloud server create, attaching the firewall, SSH key, and a cloud-init file that creates a deploy user and disables password SSH. - Install Python, PostgreSQL, and Nginx; deploy the app into
/srv/myappwith a virtualenv. - Run Gunicorn under systemd on a Unix socket and proxy to it from Nginx.
- Add HTTPS with Certbot, enable Hetzner backups, and ship database dumps off the server.
- Verify from outside: open ports, HTTPS response,
check --deploy, and a test restore.
Step-by-step solution
1. Install and configure the CLI
Create an API token with read and write permission in the Hetzner Cloud Console under your project's Security settings, then:
hcloud context create django-prod
# paste the token when prompted
hcloud ssh-key create --name laptop --public-key-from-file ~/.ssh/id_ed25519.pub
hcloud server-type list
hcloud location list
Pick a server type and location from the lists. For a small Django app with PostgreSQL on the same host, 2 vCPUs and 4 GB RAM is a comfortable starting point.
2. Create a cloud firewall
Hetzner Firewalls filter traffic before it reaches the server, so a mistake in the server's own firewall cannot expose SSH to the internet.
MY_IP=$(curl -s https://ifconfig.me)
hcloud firewall create --name django-web
hcloud firewall add-rule django-web --direction in --protocol tcp --port 22 \
--source-ips "$MY_IP/32" --description "SSH from admin"
hcloud firewall add-rule django-web --direction in --protocol tcp --port 80 \
--source-ips 0.0.0.0/0 --source-ips ::/0 --description "HTTP"
hcloud firewall add-rule django-web --direction in --protocol tcp --port 443 \
--source-ips 0.0.0.0/0 --source-ips ::/0 --description "HTTPS"
Inbound traffic not matched by a rule is dropped once the firewall is applied. PostgreSQL port 5432 is intentionally not opened.
3. Write the cloud-init file
cloud-init.yaml:
#cloud-config
users:
- name: deploy
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ssh-ed25519 AAAA... you@laptop
package_update: true
package_upgrade: true
packages:
- python3-venv
- python3-dev
- build-essential
- libpq-dev
- postgresql
- nginx
- unattended-upgrades
write_files:
- path: /etc/ssh/sshd_config.d/10-hardening.conf
content: |
PasswordAuthentication no
PermitRootLogin no
runcmd:
- systemctl reload ssh
Passwordless sudo for the deploy user is convenient for automation; if you prefer, set a password and require it. Confirm you can log in as deploy before closing any root session.
4. Create the server
hcloud server create \
--name django-web-1 \
--type <server-type-from-list> \
--image ubuntu-24.04 \
--location <location-from-list> \
--ssh-key laptop \
--firewall django-web \
--enable-backup \
--enable-protection delete,rebuild \
--user-data-from-file cloud-init.yaml
hcloud server ip django-web-1
--enable-backupturns on Hetzner's automated server backups (charged as a percentage of the server price).- Delete and rebuild protection prevents an accidental
hcloud server deletefrom destroying production.
Wait for cloud-init to finish before configuring anything:
ssh deploy@SERVER_IP 'cloud-init status --wait'
5. Create the database
sudo -u postgres psql <<'SQL'
CREATE ROLE myapp WITH LOGIN PASSWORD 'generated-db-password';
CREATE DATABASE myapp OWNER myapp;
SQL
PostgreSQL on Ubuntu listens on localhost only by default, which is what you want here.
6. Deploy the application
sudo adduser --system --group --home /srv/myapp myapp
sudo -u myapp git clone https://github.com/example/myapp.git /srv/myapp/app
sudo -u myapp python3 -m venv /srv/myapp/venv
sudo -u myapp /srv/myapp/venv/bin/pip install -r /srv/myapp/app/requirements.txt gunicorn
Create /etc/myapp/env, readable only by root and the app group:
sudo install -d -m 750 -o root -g myapp /etc/myapp
sudo tee /etc/myapp/env >/dev/null <<'EOF'
DJANGO_SETTINGS_MODULE=config.settings
DJANGO_SECRET_KEY=generated-secret
DJANGO_ALLOWED_HOSTS=www.example.com
DATABASE_URL=postgres://myapp:generated-db-password@127.0.0.1:5432/myapp
EOF
sudo chmod 640 /etc/myapp/env
sudo chgrp myapp /etc/myapp/env
Run migrations and collect static files as the app user with that environment loaded:
sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; \
cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py migrate --noinput && \
/srv/myapp/venv/bin/python manage.py collectstatic --noinput'
7. Run Gunicorn with systemd
/etc/systemd/system/myapp.service:
[Unit]
Description=myapp Gunicorn
After=network.target postgresql.service
[Service]
User=myapp
Group=www-data
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
RuntimeDirectory=myapp
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application \
--bind unix:/run/myapp/gunicorn.sock \
--workers 3 \
--access-logfile -
Restart=on-failure
[Install]
WantedBy=multi-user.target
RuntimeDirectory=myapp creates /run/myapp owned by the service user on each start. With Group=www-data, the socket is group-owned by www-data, which Nginx runs as on Ubuntu.
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
8. Configure Nginx
/etc/nginx/sites-available/myapp:
server {
listen 80;
listen [::]:80;
server_name www.example.com;
client_max_body_size 20m;
location /static/ {
alias /srv/myapp/app/staticfiles/;
}
location / {
proxy_pass http://unix:/run/myapp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Nginx needs execute permission on each directory in the static path. If /srv/myapp was created with mode 750, static files return 403; give www-data traversal access or collect static files into a directory Nginx can read.
9. DNS and HTTPS
Create A and AAAA records for www.example.com pointing at the server's IPv4 and IPv6 addresses. Then:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d www.example.com
Certbot edits the Nginx server block for TLS and installs a renewal timer. Now enable the HTTPS settings in Django: SECURE_PROXY_SSL_HEADER, secure cookies, and SECURE_SSL_REDIRECT.
10. Off-server database backups
Hetzner server backups are whole-disk snapshots taken on Hetzner's schedule. They are useful for rebuilding the machine but are not a substitute for consistent, frequent database dumps stored elsewhere.
sudo -u postgres pg_dump --format=custom myapp > /var/backups/myapp-$(date +%F).dump
Schedule this with a systemd timer and copy the dump to object storage in a different location or provider. Test a restore into a scratch database regularly.
11. Verify from outside
nmap -Pn -p 22,80,443,5432 SERVER_IP
curl -I https://www.example.com/
curl -I https://www.example.com/static/admin/css/base.css
ssh deploy@SERVER_IP "sudo -u myapp bash -c 'set -a; . /etc/myapp/env; set +a; cd /srv/myapp/app && /srv/myapp/venv/bin/python manage.py check --deploy'"
Verification check:
- from a non-allowlisted network, 22 and 5432 are filtered; 80 and 443 are open
- the site returns
200over HTTPS, and HTTP redirects to HTTPS check --deployhas no warnings you have not accepted- a backup dump restores into a scratch database
Rollback
For application releases, keep the previous commit checked out in a separate release directory and switch a current symlink back, then restart the service. For infrastructure mistakes, restore from a Hetzner backup or snapshot; take a manual snapshot before risky changes:
hcloud server create-image --type snapshot --description "before-upgrade" django-web-1
Explanation
A single well-configured VM is a legitimate production architecture for many Django apps. The risk is not the size of the server but configuration drift and unrecoverable state. The cloud firewall, cloud-init, and CLI commands turn the server into something you can recreate, while off-server database dumps make the state recoverable independently of the machine.
Edge cases / notes
Changing your IP address
When your office or home IP changes, update the SSH rule with hcloud firewall delete-rule and add-rule, or use a VPN or bastion host with a fixed address.
Outgrowing one server
Move PostgreSQL to a second server on a Hetzner private network, attach both servers to the network, and allow 5432 only on the private interface.
Uploaded media
Media in /srv/myapp is included in server backups but not in database dumps. Back it up separately or store it in S3-compatible object storage.
Troubleshooting the first deployment
Work from the outside in: DNS, firewall, Nginx, Gunicorn, Django.
# DNS points at the server
dig +short www.example.com A
dig +short www.example.com AAAA
# Cloud firewall is attached and has the expected rules
hcloud firewall describe django-web
# Nginx is listening and its configuration is valid
sudo ss -ltnp | grep -E ':80|:443'
sudo nginx -t
# Gunicorn is running and the socket exists
systemctl status myapp --no-pager
ls -l /run/myapp/gunicorn.sock
# Request through Nginx locally
curl -sI http://127.0.0.1/ -H "Host: www.example.com"
Match what you see:
- Connection times out from outside, works locally: the Hetzner Firewall is missing a rule or is not applied to the server.
502 Bad Gateway: Nginx cannot reach the socket. Checkjournalctl -u myappfor Gunicorn errors and the Nginx error log forPermission deniedon the socket path.400 Bad Request: Django rejected the host. CheckDJANGO_ALLOWED_HOSTSin/etc/myapp/envand restart the service after changing it.- Certbot fails its challenge: port 80 must be open to the internet and DNS must already resolve to the server's IP, including the
AAAArecord if one exists, because Let's Encrypt may validate over IPv6.
IPv6 notes
Hetzner Cloud servers get an IPv6 /64 network by default. If you publish an AAAA record, make sure Nginx listens on IPv6 (listen [::]:80; and listen [::]:443 ssl;) and that the firewall rules include ::/0 for web ports. A missing IPv6 listener leads to confusing failures for visitors whose networks prefer IPv6.
Keeping the server maintained
- Security updates install automatically through
unattended-upgrades; check/var/log/unattended-upgrades/occasionally. - Kernel updates need a reboot. Schedule reboots in a maintenance window and confirm
myapp,nginx, andpostgresqlare enabled so they start automatically. - Watch disk usage for logs and database growth:
df -handsudo du -sh /var/lib/postgresql /var/log. - Resize the server with
hcloud server change-typewhen needed; it requires a shutdown. Passing--keep-diskleaves the disk size unchanged, which lets you scale back down later; a larger disk cannot be shrunk.
Common mistakes
- Leaving the default
rootlogin enabled after creating a deploy user. - Opening PostgreSQL to the internet to connect a local GUI client; use an SSH tunnel instead (
ssh -L 5433:127.0.0.1:5432 deploy@SERVER_IP). - Treating Hetzner backups as the only backup; they live in the same provider and project.
- Storing
/etc/myapp/envin the Git repository.
Next steps
- Go deeper on the app server layer in Deploy Django with Gunicorn and Nginx on Ubuntu.
- Harden the host with the Linux Server Hardening Checklist for Django.
- Set up restore-tested dumps with Backup and Restore PostgreSQL for Django Apps.
FAQ
Is a Hetzner Firewall enough, or do I also need ufw?
The Hetzner Firewall is the primary control because it filters traffic before it reaches the server. A host firewall such as ufw adds defense in depth, for example if the cloud firewall is detached by mistake.
Are Hetzner server backups enough for a Django database?
No. They are infrequent whole-disk images. Take regular pg_dump backups, store them off the server, and test restores.
Which Ubuntu image should I use on Hetzner for Django?
Use a current Ubuntu LTS image such as Ubuntu 24.04, which receives security updates for years and ships a Python 3 version supported by current Django releases.
Can I run Django with Docker on Hetzner instead?
Yes. The provisioning and firewall steps stay the same; replace the virtualenv and systemd service with Docker Compose. Keep the database volume and backups off the container lifecycle.