Security Advanced

Firewall and SSH Hardening for Django Servers: Default Deny, Key-Only Access, and the Docker Trap

Lock down a Django server properly: default-deny firewall rules, key-only SSH without locking yourself out, fail2ban that actually bans, IPv6 rules people forget, and why Docker quietly publishes ports straight past your firewall.

DjangoZen Team Aug 07, 2026 18 min read 2 views

A freshly provisioned server is scanned within minutes of coming online. Not by a person — by automated systems that sweep the entire address space looking for open ports, default credentials, and known-vulnerable services. Your Django application may be flawless and still be compromised through a database port you did not know was listening.

This guide covers the network-level and access-level hardening that belongs underneath every Django deployment: a firewall with a default-deny posture, SSH configured so that only you can reach it, automated banning of brute-force attempts, and the container-related pitfall that silently undoes all of it.

Start with a realistic threat model

You are not primarily defending against a targeted attacker who has chosen your company. You are defending against continuous, indiscriminate automation: credential stuffing against SSH, scans for exposed databases and admin panels, and probes for known vulnerabilities in whatever happens to answer.

That shapes priorities. Exotic defences matter far less than closing the obvious doors. The overwhelming majority of server compromises begin with something that should never have been reachable from the internet in the first place.

Find out what is actually listening

Before writing a single rule, look at what your server currently exposes. The answer is often surprising: a database bound to all interfaces, a cache with no authentication, a development server someone started and forgot.

sudo ss -tlnp
sudo ss -tlnp | grep -v "127.0.0.1\|::1"   # only what is reachable from outside

Anything in that second list is exposed to the internet unless a firewall says otherwise. A database or cache should almost never appear there — those belong on the loopback interface or a private network, not on a public address.

Bind services locally before firewalling them

A firewall is your second line of defence, not your first. If a service does not need to accept connections from outside the machine, configure it to listen only on loopback. Then a misconfigured firewall rule is an inconvenience rather than a breach.

# PostgreSQL
listen_addresses = 'localhost'

# Redis
bind 127.0.0.1 ::1

# Application server
gunicorn myapp.wsgi:application --bind 127.0.0.1:8000

Your web server proxies to the application over loopback; nothing else needs a public port. This single change eliminates an entire category of exposure.

Default deny, then allow what you need

A firewall built by blocking known-bad traffic will always be incomplete. Build it the other way round: deny everything inbound, then permit the specific services you intend to offer.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Three ports is the correct answer for most Django servers. If you find yourself opening a fourth, ask whether that service could be reached over an encrypted tunnel or a private network instead.

The lockout trap

The classic mistake takes one command. You enable a default-deny firewall before allowing SSH, your session drops, and the only way back in is your provider's console — assuming you can log into that.

Always add the SSH rule before enabling the firewall, and keep a second terminal session open while you work. If a change breaks connectivity, the existing session is your lifeline; a firewall rule does not usually terminate established connections.

When making risky changes remotely, a scheduled safety net is worth the effort:

sudo bash -c 'echo "ufw disable" | at now + 10 minutes'

If you lock yourself out, the firewall disables itself in ten minutes. If everything works, cancel the job. It feels paranoid until the day it saves you.

IPv6 rules are separate rules

Modern servers usually come with both an IPv4 and an IPv6 address, and a firewall that only filters IPv4 leaves half the door open. Some tools handle both automatically, others do not.

grep IPV6 /etc/default/ufw          # should read IPV6=yes
sudo ip6tables -L -n | head -20     # confirm rules exist for v6 too

Verify rather than assume, and test from an IPv6-capable client. An attacker scanning IPv6 space is less common than IPv4 scanning, but "less common" is not a security control.

The Docker trap: containers publish past your firewall

This is the finding that surprises most people. When you publish a container port, the container runtime writes its own rules directly into the packet filter, at a point that is evaluated before your firewall's rules. A port published as -p 5432:5432 is reachable from the internet even though your firewall says otherwise, and the firewall's status output will cheerfully report that everything is denied.

# Reachable from anywhere, regardless of firewall rules
docker run -p 5432:5432 postgres

# Bound to loopback only — what you almost always want
docker run -p 127.0.0.1:5432:5432 postgres

Publish container ports to a specific interface, and only publish what genuinely needs to be public. For everything else, let containers reach each other over an internal network and expose nothing. After changing anything, verify from outside the machine — never trust the firewall status alone.

Key-only SSH access

Password authentication on SSH is a standing invitation to brute force. Public-key authentication removes the entire attack category: there is no password to guess.

# On your workstation
ssh-keygen -t ed25519 -C "workstation-to-appserver"
ssh-copy-id -i ~/.ssh/id_ed25519.pub user@example.com

Ed25519 keys are short, fast and well supported. Generate a separate key per server so that a compromised workstation key does not open every machine you own, and give each key a comment that tells you where it came from.

Disable password authentication — carefully

Test key login before you turn passwords off, in a session you keep open. Then place the changes in a drop-in file rather than editing the main configuration, so upgrades do not silently revert your work.

# /etc/ssh/sshd_config.d/99-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
sudo sshd -t          # validate the configuration first
sudo systemctl reload ssh

Validate before reloading. A syntax error in the SSH configuration combined with a reload is one of the few reliable ways to lose access to a remote machine permanently.

Root login and the value of an unprivileged account

PermitRootLogin prohibit-password allows root over a key but never a password. Many teams go further and disable root login entirely, logging in as an unprivileged user and escalating with sudo. The benefit is auditability: the logs show who did what, rather than a series of anonymous root sessions.

Whichever you choose, be consistent, and make sure your deployment automation uses the same account. A hardening change that breaks your own deployment pipeline tends to get reverted rather than fixed.

Changing the SSH port: modest benefit, real cost

Moving SSH to a non-standard port reduces log noise from indiscriminate scanners. It is not a security control — anything scanning all ports finds it immediately — and it complicates every tool and teammate that expects the default.

If log noise bothers you, a rate limit achieves more for less friction:

sudo ufw limit 22/tcp

That drops connections from an address making repeated attempts in a short window, which is exactly the pattern automated scanning produces.

Automated banning that actually bans

An intrusion-prevention daemon watches logs and blocks addresses that repeatedly fail. On a key-only server it matters less, but it keeps logs readable and stops the constant background noise.

# /etc/fail2ban/jail.local
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
backend  = systemd

[sshd]
enabled = true
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

Check the status after a day. If the jail shows zero failures on a public server, it is almost certainly not reading the right log source rather than the internet having become polite.

Jails for your application, not just SSH

The same mechanism can protect application endpoints. If your web server logs failed login attempts in a recognisable format, a custom filter can ban addresses hammering your admin panel. Combine that with rate limiting in the web server itself, and application-level throttling in Django, so that a single misconfiguration in one layer does not remove all protection.

Consider outbound rules too

Most guides stop at inbound traffic. Outbound filtering is harder to maintain but valuable: if an application is compromised, restrictive egress rules limit what the attacker can do next — downloading a second-stage payload, exfiltrating a database, joining a botnet.

Start by observing rather than blocking. Log outbound connections for a week, see what your server legitimately talks to, and only then consider tightening. Blocking first tends to break package updates and certificate renewal in ways that are diagnosed slowly.

Unattended security updates

Firewalls do not patch vulnerabilities. A server that is never updated will eventually run a version of something with a public exploit.

sudo apt-get install -y unattended-upgrades
sudo systemctl enable --now unattended-upgrades

Automatic security updates carry a small risk of an unexpected restart and remove a much larger risk of an unpatched service. For most Django deployments that trade is clearly worth making.

Verify from outside, always

Every check so far has been run on the machine itself, which is the least trustworthy place to test a firewall. Scan from elsewhere.

nmap -Pn -p- example.com | head -20

You should see exactly the ports you intended and nothing else. If a database or cache port appears, you have found either a firewall rule that is not doing what you think or a container publishing past it. Both are worth finding today rather than in an incident report.

What a probe looks like in your logs

Hardening without observation is guesswork. Spend ten minutes reading the authentication log on a public server and the threat model stops being abstract.

sudo journalctl -u ssh --since "24 hours ago" | grep -c "Failed password"
sudo journalctl -u ssh --since "24 hours ago" | grep "Invalid user" | \
  awk '{print $NF}' | sort | uniq -c | sort -rn | head

You will see attempts against admin, test, ubuntu, postgres and dozens of others, from addresses all over the world, at a steady rate regardless of the time of day. None of it is aimed at you specifically. All of it succeeds somewhere, on servers that still accept passwords.

Once password authentication is off, those lines change character: the connection is refused before any credential is offered. That shift in the log is the clearest confirmation that your hardening actually took effect.

Application-layer probing

The same automation walks HTTP. Your web server log will show requests for administrative paths belonging to entirely different software, backup files, configuration files and environment files. Most return 404, which is the correct answer.

sudo awk '$9 ~ /^(401|403|404)$/ {print $7}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -15

Two things are worth acting on. If a request for something like an environment file returns anything other than 404, you have a real exposure — a file that should never be servable is being served. And if one address generates hundreds of these in a minute, that is what rate limiting and an application jail are for.

A verification routine worth repeating

Hardening is not a one-off task; servers drift. New services get installed, containers get published, rules get added during an incident and never removed. A short routine, run monthly, catches that drift while it is still cheap.

# 1. What listens on a public interface?
sudo ss -tlnp | grep -v "127.0.0.1\|::1"

# 2. Do the firewall rules still match intent?
sudo ufw status verbose

# 3. What does the outside actually see?
nmap -Pn -p- example.com | head -20

# 4. Is password authentication still disabled?
sudo sshd -T | grep -E "passwordauthentication|permitrootlogin"

# 5. Are the jails still catching anything?
sudo fail2ban-client status sshd

Five commands, a few minutes, and it answers the only question that matters: is this server still configured the way I believe it is? The answer is "no" more often than anyone expects, and finding that out during a routine check is considerably better than finding out afterwards.

Summary

Hardening a Django server is mostly about reducing what is reachable. Bind services to loopback so they are not exposed even without a firewall. Adopt default-deny and open only ports 22, 80 and 443. Add the SSH rule before enabling the firewall, and keep a second session open. Cover IPv6 explicitly. Watch for containers publishing ports past your rules — bind them to loopback. Move to key-only SSH, validate the configuration before reloading, and let an intrusion-prevention daemon handle the noise. Enable automatic security updates. Then verify from an external host, because a firewall that has only been tested from the inside has not been tested at all.