Linux Server Hardening Checklist for Django
Harden a Linux server running Django: SSH keys, firewall rules, automatic security updates, least-privilege users, systemd sandboxing, fail2ban, and audit checks.
Problem statement
A Django application's security depends on the server beneath it. A well-configured app with secure settings is still exposed if the server allows SSH password logins from the internet, runs the application as root, exposes PostgreSQL and Redis publicly, or has not installed security updates in a year.
Hardening does not require exotic tools. Most real-world compromises of small and medium Django deployments come from a short list of basic gaps: weak remote access, unnecessary open ports, outdated packages, over-privileged processes, and secrets readable by the wrong users.
This checklist covers a Debian or Ubuntu server running Nginx, Gunicorn, and optionally PostgreSQL and Redis on the same host. Each item states what to check, a command to verify it, and a fix.
Quick answer
- SSH: key-based authentication only, no root login, access restricted by firewall or VPN.
- Firewall: allow only 22 (restricted), 80, and 443 inbound; everything else denied.
- Updates: unattended security upgrades enabled; reboot schedule for kernel updates.
- Users: the app runs as a dedicated unprivileged user; deploy and admin access are separate.
- Services: PostgreSQL and Redis bound to localhost or a private network, with authentication.
- Files: secrets readable only by root and the app user; code not writable by the app user where avoidable.
- systemd: sandboxing options on the Gunicorn and Celery units.
- Monitoring: brute-force protection, log retention, and alerts on disk, updates, and failed services.
Checklist
1. SSH access
What to check:
sudo sshd -T | grep -E "^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)"
Expected:
permitrootlogin no
passwordauthentication no
kbdinteractiveauthentication no
pubkeyauthentication yes
Fix in /etc/ssh/sshd_config.d/10-hardening.conf:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
sudo sshd -t && sudo systemctl reload ssh
Keep an existing session open and test a new login before closing it. Additional measures:
- restrict SSH at the cloud firewall or host firewall to known IP addresses, a VPN, or a bastion host
- use hardware-backed or passphrase-protected keys, and remove keys of people who no longer need access
- consider SSH certificates or an identity-aware access proxy for larger teams
2. Firewall
What to check:
sudo ss -tulpn
sudo ufw status verbose
Every listening port should be intentional. A typical web server exposes only:
| Port | Service | Source |
|---|---|---|
| 22 | SSH | admin IPs or VPN only |
| 80 | HTTP (redirect, ACME challenges) | anywhere |
| 443 | HTTPS | anywhere |
Fix with ufw:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
Docker publishes container ports by manipulating iptables directly, which bypasses ufw rules. If you run Docker, bind published ports to 127.0.0.1 ("127.0.0.1:8000:8000") or rely on a cloud firewall in front of the server.
Prefer a provider-level firewall (security groups, cloud firewall) in addition to the host firewall, so a host misconfiguration does not expose services.
3. Security updates
What to check:
systemctl status unattended-upgrades --no-pager
apt list --upgradable 2>/dev/null | head
cat /var/run/reboot-required 2>/dev/null
Fix:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades
Unattended upgrades install security updates automatically. Kernel and core library updates need a reboot to take effect; schedule reboots in a maintenance window, or use Unattended-Upgrade::Automatic-Reboot with a time if your service tolerates it. Monitor /var/run/reboot-required.
Also keep the Python interpreter and your application dependencies patched; OS updates do not cover packages installed in virtualenvs.
4. Users and privileges
What to check:
ps -o user,cmd -C gunicorn | sort -u | head
getent group sudo
Guidelines:
- Gunicorn and Celery run as a dedicated system user (for example
myapp) with no login shell and no sudo rights. - Human administrators log in with personal accounts, use
sudofor administration, and leave an audit trail. - The deploy user (used by CI) can update releases and restart services, but not arbitrary root commands. Grant specific
sudorules:
# /etc/sudoers.d/deploy
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp, /usr/bin/systemctl reload nginx
Validate sudoers changes with sudo visudo -cf /etc/sudoers.d/deploy.
5. Code and secret file permissions
What to check:
namei -l /srv/myapp/app/config/settings.py
ls -l /etc/myapp/env
sudo -u myapp test -w /srv/myapp/app/config/settings.py && echo "app user can modify code"
Guidelines:
- Code owned by the deploy user, readable by the app user, not writable by the app user. If the app process is compromised, it cannot modify its own code.
- Environment files and credentials: mode
640, ownerroot, group of the app user. - Media directories writable by the app user, but not executable locations served as scripts.
- No secrets in world-readable locations, shell histories, or Git working copies.
6. Database and cache exposure
What to check:
sudo ss -tlnp | grep -E ':5432|:6379'
Both should listen on 127.0.0.1 or a private network address only.
PostgreSQL (/etc/postgresql/*/main/postgresql.conf and pg_hba.conf):
listen_addresses = 'localhost'
# pg_hba.conf
host myapp myapp 127.0.0.1/32 scram-sha-256
Redis (/etc/redis/redis.conf):
bind 127.0.0.1 -::1
protected-mode yes
requirepass use-a-long-generated-password
The application's database role should own only its database, not be a superuser.
7. systemd sandboxing for app services
What to check:
systemd-analyze security myapp.service | tail -1
The command scores how exposed a service is. Add hardening options gradually, testing after each:
[Service]
User=myapp
Group=www-data
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/myapp/media /run/myapp
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
RestrictRealtime=true
CapabilityBoundingSet=
ProtectSystem=strict makes the filesystem read-only for the service except ReadWritePaths. Include every directory the app writes to (media, temporary upload directories if not using PrivateTmp, sockets).
8. Nginx configuration
What to check:
sudo nginx -T 2>/dev/null | grep -E "server_tokens|ssl_protocols|client_max_body_size"
Guidelines:
server_tokens off;to hide the Nginx version.- TLS 1.2 and 1.3 only (
ssl_protocols TLSv1.2 TLSv1.3;), with modern ciphers from a maintained recommendation such as Mozilla's configuration generator. - A
default_serverblock that rejects unknown hostnames. client_max_body_sizeset to what uploads need, not unlimited.- No directory listings (
autoindex off, the default).
9. Brute-force protection
SSH restricted by IP rarely sees attacks. For services that must stay open (for example SSH without a VPN, or the Django admin), add rate limiting:
sudo apt install fail2ban
/etc/fail2ban/jail.d/sshd.local:
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
For the Django admin and login, rate limiting in Nginx and application-level lockouts are more precise than log-based banning.
10. Logging and monitoring
What to check:
- authentication logs retained (
journalctl -u ssh,/var/log/auth.log) - journal size limits configured so logs do not fill the disk
- alerts for disk usage, failed systemd units, pending reboots, and unusual login activity
- time synchronization active (
timedatectl), which matters for logs, TLS, and signed tokens
systemctl --failed
timedatectl status | grep -E "synchronized|NTP"
11. Backups and recovery access
Hardening includes being able to recover:
- backups stored off the server with separate credentials
- documented recovery if the server is lost or compromised
- snapshots before major changes
A compromised server should be rebuilt from known-good sources, not cleaned in place.
Verify
Run an external scan from a network outside your allowlist:
nmap -Pn -sT -p 1-10000 SERVER_IP
Only ports 80 and 443 (and 22 from allowed networks) should be open.
Verification check:
- SSH rejects password and root logins
- only intended ports are reachable from the internet
- security updates install automatically and reboots are tracked
- Gunicorn and Celery run as unprivileged users with sandboxing
- PostgreSQL and Redis are not reachable from outside
- secrets have restrictive permissions
Explanation
Server hardening reduces the number of ways in and the damage possible after a foothold. Restricting network exposure removes most automated attacks; key-only SSH and patched packages close the most common entry points; unprivileged, sandboxed services limit what a compromised Django process can do; and proper file permissions keep secrets and code out of reach. Each item is small, and together they make a single-server Django deployment a much harder target.
Edge cases / notes
Managed platforms
On PaaS platforms, the provider handles most host hardening. Your responsibilities shift to access control for the platform account, secrets management, and application settings.
Containers
Container hosts need the same host hardening, plus container-specific practices: non-root containers, read-only root filesystems where possible, and no privileged containers.
Compliance requirements
Frameworks such as CIS Benchmarks provide detailed, auditable baselines. Automated tools can scan servers against them when compliance requires formal evidence.
Common mistakes
- Relying on ufw while Docker publishes ports around it.
- Running Gunicorn as root or as the same user that owns the code.
- Exposing Redis or PostgreSQL to the internet "temporarily".
- Enabling automatic updates but never rebooting for kernel patches.
Next steps
- Apply these steps on a new VM with Deploy Django on a Hetzner Cloud Server.
- Protect credentials with Manage Django Secrets in Production.
- Harden the HTTP layer with the Django Security Headers Checklist.
- Review unit files in How to Configure systemd for Gunicorn and Django.
FAQ
What are the most important hardening steps for a Django server?
Key-only SSH restricted to trusted networks, a firewall exposing only web ports, automatic security updates, running the app as an unprivileged user, and keeping databases and caches off public interfaces.
Does ufw protect Docker containers?
Not reliably. Docker's published ports bypass ufw rules through iptables. Bind published ports to localhost or use a cloud firewall in front of the server.
Should Gunicorn run as root?
No. Run it as a dedicated system user without sudo rights, and let Nginx listen on privileged ports. Root processes turn application vulnerabilities into full server compromises.
Is fail2ban necessary if SSH uses keys only?
It is less important with key-only authentication and IP restrictions, but it still reduces log noise and adds protection for services that must remain publicly reachable.