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.

Hardening a hostAccountskeys, no root loginNetworkfirewall, few portsSoftwareupdates, minimal setAuditinglogs, intrusion checksReduce what is exposed, then keep what remains patched and observed.

Quick answer

  1. SSH: key-based authentication only, no root login, access restricted by firewall or VPN.
  2. Firewall: allow only 22 (restricted), 80, and 443 inbound; everything else denied.
  3. Updates: unattended security upgrades enabled; reboot schedule for kernel updates.
  4. Users: the app runs as a dedicated unprivileged user; deploy and admin access are separate.
  5. Services: PostgreSQL and Redis bound to localhost or a private network, with authentication.
  6. Files: secrets readable only by root and the app user; code not writable by the app user where avoidable.
  7. systemd: sandboxing options on the Gunicorn and Celery units.
  8. 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
sshd_config.d/10-hardening.confPermitRootLoginnoPasswordAuthenticationnoKbdInteractiveAuthenticationnoPubkeyAuthentication yes1231Log in as a named user, then sudo.2Keys only; password guessing becomes pointless.3Closes the other password prompt too.sshd -t before reload, and keep a session open while testingdjango-deployment.com
sshd -T prints the effective settings, including overrides from other files.

2. Firewall

What to check:

sudo ss -tulpn
sudo ufw status verbose
the firewall, rule by ruleRuledefault incoming✕denydefault outgoingallow22/tcp✓allow from office or VPN only80, 443/tcp✓allow (Nginx Full)Docker-published ports bypass ufw; bind containers to 127.0.0.1django-deployment.com
A cloud firewall in front of the host adds a second, independent layer.

Every listening port should be intentional. A typical web server exposes only:

PortServiceSource
22SSHadmin IPs or VPN only
80HTTP (redirect, ACME challenges)anywhere
443HTTPSanywhere

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 sudo for 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 sudo rules:
# /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, owner root, 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
sandbox the app servicesNoNewPrivileges=trueno setuid escalationProtectSystem=strictread-only filesystemReadWritePaths=/srv/myapp/mediaonly what must be writtenProtectHome=true, PrivateTmp=trueisolated home and /tmpsystemd-analyze security gunicorn scores the unitdjango-deployment.com
Add options one at a time and restart, so a missing write path is obvious.

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_server block that rejects unknown hostnames.
  • client_max_body_size set 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
brute force, then a banFailed login 1loggedFailures 2 to 4within findtimeFailure 5maxretry reachedIP bannedfirewall rule, bantimeBan expiresor grows if repeatedThe same approach can watch Django admin login failures in the logsdjango-deployment.com
Behind a proxy, fail2ban must read logs with the real client IP.

/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

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.

Learning path · step 12 of 15

Hardening a Django deployment

Keep going