Hardening DigitalOcean Debian 13 (Trixie) ufw firewall

Set up ufw and the DigitalOcean Cloud Firewall on DigitalOcean Debian 13 (Trixie)

Two firewalls, one allowlist, zero lockouts. A step-by-step walkthrough of a default-deny firewall on a fresh DigitalOcean server running Debian 13 (Trixie): ufw at the host layer, the DigitalOcean Cloud Firewall at the edge, rate-limited SSH, and the Docker-versus-ufw gotcha that catches most small teams out exactly once.

Why two firewalls, not one

A host-level firewall and a cloud firewall solve different problems. ufw enforces policy on the VM itself — every process that opens a socket is subject to its rules. The DigitalOcean Cloud Firewall enforces policy before traffic reaches the VM at all, saving CPU cycles and, more importantly, giving you a layer that still works if ufw is ever misconfigured or disabled.

Configure both with the same allowlist. Defence in depth is not redundancy — it is the acknowledgement that configuration drifts, and a second independent gate catches drift before it becomes an incident. The extra five minutes per server is cheap compared to explaining a breach to customers.

Prerequisites

  • SSH access as a sudo-capable admin user. If you are still logging in as root, follow the harden-SSH guide linked below first.
  • The port your SSH server actually listens on — 22 on a default image, something else if you have already hardened SSH. Picking the wrong port here locks you out.
  • Access to the DigitalOcean web console or doctl/hcloud CLI for the edge firewall rules.

Before running any of the commands below, open a second SSH session in a separate terminal and keep it open until the end of the guide. If anything goes wrong, that second session is how you recover without reaching for the provider's rescue console.

Part 1. Install ufw and set defaults

ufw is usually already installed on Debian 13 (Trixie) images, but it is inactive. Set the default policies before adding any rules — the default-deny stance is the whole point, and you want it in place before you enable the firewall.

sudo apt-get update sudo apt-get install -y ufw sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw default deny routed

default deny routed is the one most guides skip. It blocks traffic that the host is asked to forward between networks — relevant the moment Docker or WireGuard is installed, because both turn the host into a router. Setting it now costs nothing and prevents a whole category of later surprise.

Part 2. Allow SSH before enabling ufw

This is the step that locks people out. Adding the SSH allow rule before enabling the firewall is non-negotiable. If you have already moved SSH off port 22, substitute your actual port.

sudo ufw allow 2222/tcp comment 'ssh' sudo ufw limit 2222/tcp comment 'ssh rate limit'

The limit rule tells iptables to drop connections from any source that tries more than six times in 30 seconds. It does not replace fail2ban — it runs earlier, at the connection level, and absorbs the bulk of scanner noise before SSH even sees it. The two tools complement each other.

Part 3. Allow the public application ports

Most teams expose HTTP and HTTPS. If your reverse proxy listens on anything else, substitute the ports accordingly.

sudo ufw allow 80,443/tcp comment 'http https'

If your architecture puts a load balancer in front of the VPS, scope the rule to the load balancer's address range instead of opening to the world. sudo ufw allow from 10.0.0.0/16 to any port 80,443 proto tcp is the pattern — it forces traffic to come in through the LB and blocks direct-to-IP scans entirely.

Part 4. Enable ufw

sudo ufw --force enable sudo ufw status verbose

Verify status in your original session, then confirm you can still open a new SSH connection from a fresh terminal. Only close the spare session once the new one succeeds.

Part 5. Cloud firewall at the DigitalOcean edge

Attach a DigitalOcean Cloud Firewall to the droplet from the control panel or via doctl. Configure it alongside ufw on the host.

Mirror the host allowlist at the cloud firewall:

  • TCP 2222 from your office IPs or VPN range — not from anywhere. SSH should never be reachable from the global internet if you can help it.
  • TCP 80 and 443 from 0.0.0.0/0 and ::/0 once the app is ready to go public.
  • Allow ICMP echo from anywhere if you want the box to be pingable for monitoring; drop it otherwise.
  • Block everything else inbound. Default-deny at the edge matches default-deny at the host.

Outbound rules at the edge firewall are worth a second look if your provider supports them. Locking outbound traffic to the IP ranges your app actually talks to (payment APIs, your database, package mirrors) is a meaningful exfiltration mitigation — but it is also the kind of rule that breaks in strange ways when an upstream service adds an IP, so keep a documented path to loosen it in a hurry.

Part 6. The Docker gotcha every team meets once

If Docker is installed on this host, parts of the iptables rule set are managed by the Docker daemon, and Docker's rules run before ufw's. The practical effect: a container published with -p 80:80 is reachable from the internet even if ufw says deny. Most small teams discover this by accidentally exposing a development container to the world.

Two ways to handle it, ordered by preference:

  • Publish containers to localhost, not the public interface. -p 127.0.0.1:8080:8080 keeps the container off the public stack entirely, and a reverse proxy (Caddy is simplest) terminates TLS on the ufw-allowed ports. This is what the Docker deploy guide recommends; it sidesteps the firewall question completely.
  • Patch ufw to filter Docker's forwarded traffic. Append explicit rules to /etc/ufw/after.rules under the ufw-user-forward chain that drop forwarded traffic to container networks by default. The community ufw-docker script implements this correctly; read it before applying it.

If you have not installed Docker yet, follow the install-Docker guide linked below for a setup that plays nicely with this one from the start.

Operational checklist

  • Review allowlists quarterly. Scoped SSH ranges drift as the team grows or VPN IPs change. A 15-minute calendar block per quarter is the cheapest audit you can do.
  • Keep ufw rules in sync with the edge firewall. If you ever find yourself adding a rule to one and not the other, write a runbook step that forces both. Configuration drift between layers is how the second gate quietly stops working.
  • Log denies while tuning. sudo ufw logging medium captures blocked traffic in /var/log/ufw.log; useful for diagnosing "why is this service broken" in the days after a new rule ships. Turn it back down to low once things are stable so it does not flood the journal.
  • Automate drift detection. Run sudo ufw status numbered from a scheduled job and alert on diffs. Small teams rarely do this manually; a platform should.

Troubleshooting

I enabled ufw and lost my SSH session
Reconnect from the DigitalOcean rescue console or web VNC, run sudo ufw disable, re-add the SSH allow rule, then ufw --force enable. Always open a second SSH session before firewall changes next time.
The app is up locally but unreachable from the internet
Three layers to check in order: the app binds to 0.0.0.0 or the public interface (not 127.0.0.1), ufw has an allow rule for the port, and the DigitalOcean Cloud Firewall has a matching rule. Traffic has to pass all three.
ufw status shows rules but traffic still gets through unexpectedly
The usual culprit is Docker. Run sudo iptables -L DOCKER-USER -n -v — if you see ACCEPT rules pointing at container networks, Docker is bypassing ufw. See Part 6.
ufw rate-limit keeps locking me out
Six connections in 30 seconds from one source IP triggers the drop. SSH multiplexing or aggressive keepalive scripts can hit it. Either disable the limit on trusted source ranges with ufw allow from YOUR_IP to any port 2222 proto tcp, or tune the multiplexing so one session handles many commands.

FAQ

Why ufw instead of raw nftables?
ufw is a thin abstraction over nftables; the rules it writes are the same ones you would write by hand. The value is the ergonomics and the idioms the abstraction enforces — default policies, rate limits, simple allow rules — which reduce the chance of a small-team engineer writing a subtly broken raw ruleset on a Wednesday afternoon.
Is the edge firewall really necessary if ufw is in place?
Yes. The edge firewall is the thing that still protects you when ufw is misconfigured, temporarily disabled for a change, or broken by a kernel upgrade. It also drops scanner traffic before it spends your CPU.
What about IPv6?
ufw writes rules for both IPv4 and IPv6 by default when IPV6=yes is set in /etc/default/ufw (the Debian default). The edge firewall needs the same rules set for the ::/0 IPv6 range; both DigitalOcean and other major providers support this from the same UI.
DeployCrate

Skip the sysadmin hire

Two firewalls layered correctly is how production servers stay locked down without a dedicated ops person. It is also ten minutes of careful work that small teams tend to half-finish — ufw with no matching edge rules, or edge rules that have drifted out of sync with what the host is actually running.

DeployCrate applies both layers consistently across every server your team provisions, and keeps them in sync as operators come and go. The ufw configuration mirrors host_safety.sh in our open script set; the cloud firewall rules are reconciled on every deploy. One allowlist, two gates, no drift — without any engineer on your team owning "the firewall rules".

Related guides