Level 5 — Firewall
A firewall decides which network packets are allowed in and out. Without one, every port your server opens — deliberately or accidentally — is reachable by anyone on the internet.
What a firewall is
The Linux kernel contains a packet filtering framework (netfilter, driven by nftables on Ubuntu 24.04). Every packet arriving at or leaving the network interface is checked against a set of rules, and the kernel decides: accept, drop, or reject.
| Action | Behaviour | Client sees |
|---|---|---|
| ACCEPT | Packet passes | Normal response |
| DROP | Packet silently discarded | Timeout (~30s) |
| REJECT | Packet discarded with an ICMP error | Connection refused (immediate) |
UFW's default policy is DROP for incoming. This is deliberate: dropping gives an attacker no confirmation that the host even exists, and makes port scans slow. It is also why a missing firewall rule presents as a timeout, not a refusal — a diagnostic clue worth internalising (Level 26).
What is UFW
UFW (Uncomplicated Firewall) is a friendly front-end to nftables. You could write nftables rules directly; UFW turns a page of syntax into ufw allow 443/tcp.
Note the last branch: binding to 127.0.0.1 protects you even if the firewall is wrong. Two independent controls. Use both.
Inbound vs outbound
| Direction | Meaning | Default policy | Why |
|---|---|---|---|
| Incoming | The world → your server | deny | Nothing gets in unless you explicitly allow it |
| Outgoing | Your server → the world | allow | Your server needs to reach apt repos, npm registry, Let's Encrypt, your SMTP provider |
| Routed | Through your server | deny | You are not a router (Docker manages its own chains) |
SHOULD YOU RESTRICT OUTBOUND TRAFFIC?
Restricting outbound (default deny, allowlist 53/80/443 and your SMTP port) makes life harder for an attacker who gets code execution — no reverse shell to an arbitrary port, no exfiltration to an arbitrary host.
It also breaks things constantly: a new package repository, a webhook to a partner API, a monitoring agent, an npm registry mirror. On a single app server the operational cost usually exceeds the benefit, because an attacker with code execution can tunnel over 443 anyway.
Recommendation: leave outbound open on a standard app server. Restrict it on database-only servers, which should talk to almost nothing.
Ports in production
Only three ports belong on the public internet:
| Port | Protocol | Service | Why public |
|---|---|---|---|
| 22 | TCP | SSH | You need to administer the server |
| 80 | TCP | HTTP | Let's Encrypt HTTP-01 challenge + redirect to HTTPS |
| 443 | TCP | HTTPS | The actual application traffic |
Why port 80 stays open
You might reason: "everything is HTTPS, so close 80." Don't.
- Let's Encrypt HTTP-01 validation requires port 80 (Level 16). Close it and your certificate renewal fails silently in 60 days.
- Users type
example.com, and browsers still try HTTP first in many cases. Without port 80 they get a timeout rather than a redirect to HTTPS. - Port 80 serves only a 301 redirect (plus the ACME challenge path), so the exposure is minimal.
Ports that must NOT be public
| Port | Service | What happens if exposed |
|---|---|---|
| 3000 | Nuxt | Bypasses Nginx: no TLS, no rate limiting, no security headers, no access logs. Attackers hit your app directly. |
| 3001 | NestJS API | Same, plus your API is served over plain HTTP — tokens and credentials in cleartext |
| 5432 | PostgreSQL | Direct access to all your data. Credential-stuffing and CVE exploitation against the DB engine itself. |
| 6379 | Redis | Catastrophic. See below. |
| 9229 | Node inspector | Full remote debugger: read memory, read env vars, execute arbitrary code |
| 27017 | MongoDB | Historically the source of tens of thousands of ransomed databases |
| 8080, 5000, 4000 | Common dev servers | Whatever you accidentally left running |
UNPROTECTED REDIS IS AN INSTANT COMPROMISE
Redis has no authentication by default and accepts commands from anyone who can reach it. The standard automated attack against an exposed Redis:
CONFIG SET dir /home/deploy/.sshCONFIG SET dbfilename authorized_keysSET x "ssh-rsa AAAA... attacker"SAVE
Redis has now written the attacker's SSH key into your authorized_keys. They log in. This is fully automated and takes minutes after your port becomes visible to a scanner. Shodan indexes tens of thousands of open Redis instances at any time.
Bind Redis to 127.0.0.1, set requirepass, and never open 6379. (Level 10)
"But I need to connect to PostgreSQL from my laptop"
Do it through an SSH tunnel, not an open port:
# LOCAL — forward local port 5433 to the server's 127.0.0.1:5432
ssh -L 5433:127.0.0.1:5432 deploy@203.0.113.10Now point TablePlus/DBeaver/pgAdmin at localhost:5433. The traffic is encrypted by SSH, authenticated by your key, and PostgreSQL never becomes visible on the internet. The database still thinks the connection came from 127.0.0.1.
This requires AllowTcpForwarding yes in sshd config (Level 4).
As a background tunnel:
# LOCAL
ssh -fN -L 5433:127.0.0.1:5432 deploy@203.0.113.10 # -f background, -N no command
# later:
pkill -f "5433:127.0.0.1:5432"Configuring UFW
ALWAYS ALLOW SSH BEFORE ENABLING UFW
sudo ufw enable with default-deny incoming and no SSH rule disconnects you immediately and prevents reconnection. UFW prints a warning about this; take it seriously.
The order is always: allow SSH → verify the rule exists → enable.
# SERVER
sudo apt install -y ufw # usually preinstalled on Ubuntu
# 1. Set default policies
sudo ufw default deny incoming
sudo ufw default allow outgoing
# 2. ALLOW SSH FIRST — before anything else
sudo ufw allow OpenSSH
# or, equivalently:
sudo ufw allow 22/tcp
# 3. Web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# 4. Verify the rules are staged
sudo ufw show added
# 5. Now enable
sudo ufw enableYou will be asked:
Command may disrupt existing ssh connections. Proceed with operation (y|n)?Answer y only after confirming the SSH rule is in ufw show added.
# SERVER
sudo ufw status verboseStatus: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)THE (v6) LINES MATTER
If your domain has an AAAA record, IPv6 traffic must be allowed too. UFW does this automatically when IPV6=yes in /etc/default/ufw (the Ubuntu 24.04 default). If you do not see (v6) rules, set it and run sudo ufw disable && sudo ufw enable.
Application profiles
UFW ships named profiles so you do not memorise port numbers:
# SERVER
sudo ufw app listAvailable applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH| Profile | Ports |
|---|---|
OpenSSH | 22/tcp |
Nginx HTTP | 80/tcp |
Nginx HTTPS | 443/tcp |
Nginx Full | 80 and 443/tcp |
sudo ufw allow "Nginx Full"
sudo ufw app info "Nginx Full"Profiles are just files in /etc/ufw/applications.d/ and appear when you install the corresponding package.
Restricting a rule to specific sources
The most useful UFW feature: allow a port only from certain addresses.
# SERVER
# SSH only from your office IP
sudo ufw allow from 198.51.100.42 to any port 22 proto tcp
# A whole subnet
sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp
# PostgreSQL from an app server on the private network only
sudo ufw allow from 10.0.0.5 to any port 5432 proto tcp
# Explicitly deny an abusive IP
sudo ufw deny from 45.148.10.92ONLY LOCK SSH TO YOUR IP IF IT IS STATIC
Home connections usually have dynamic IPs. If yours changes, you are locked out and must use the provider console to fix it. If your IP is dynamic, leave SSH open to all and rely on key-only auth plus fail2ban — that is already a very strong position.
A middle path: restrict SSH at the cloud firewall level (easy to edit from a phone via the provider's web UI) rather than in UFW (which requires access to change).
Rate-limiting SSH connections is a lighter-touch alternative:
sudo ufw limit ssh/tcpThis denies an IP that makes more than 6 connection attempts in 30 seconds. Complementary to fail2ban.
Managing rules
# SERVER
sudo ufw status numbered To Action From
-- ------ ----
[ 1] 22/tcp ALLOW IN Anywhere
[ 2] 80/tcp ALLOW IN Anywhere
[ 3] 443/tcp ALLOW IN Anywhere
[ 4] 5432/tcp ALLOW IN Anywhere# Delete by number — DELETE FROM THE BOTTOM UP
sudo ufw delete 4
# Or delete by matching the original rule
sudo ufw delete allow 5432/tcpRULE NUMBERS SHIFT AFTER EACH DELETE
Deleting rule 2 renumbers 3→2 and 4→3. If you plan to delete several, work from the highest number downward, or use the ufw delete allow ... form which is unambiguous.
Rules are evaluated top to bottom, first match wins. To insert a rule at a specific position:
sudo ufw insert 1 deny from 45.148.10.92Putting a deny first ensures it is not shadowed by a later broad allow.
Other management commands:
sudo ufw disable # turn off (rules are remembered)
sudo ufw enable # turn back on
sudo ufw reload # re-apply rules
sudo ufw reset # DELETE ALL RULES and disable — see warning
sudo ufw status verboseufw reset DISABLES THE FIREWALL AND WIPES EVERY RULE
It backs up the old rules to /etc/ufw/*.rules.<timestamp> but leaves the firewall off. If you run it, immediately re-add your rules and re-enable. Never run it as the first step of debugging.
Logging
# SERVER
sudo ufw logging on # 'low' level — logs blocked packets
sudo ufw logging medium # also logs allowed packets matching rules
sudo ufw logging offBlocked packets land in the kernel log:
sudo grep 'UFW BLOCK' /var/log/syslog | tail -20
sudo journalctl -k --since "1 hour ago" | grep UFWA typical entry:
[UFW BLOCK] IN=eth0 OUT= SRC=45.148.10.92 DST=203.0.113.10 PROTO=TCP SPT=51234 DPT=23DPT=23 — someone probing for telnet. You will see thousands of these per day. That is normal background noise on any public IP.
ufw logging medium FILLS YOUR DISK
On a busy server, logging every allowed packet generates gigabytes per day. Use low (the default) in production. If you turn logging up for debugging, set a reminder to turn it back down.
Docker and UFW — the trap
DOCKER BYPASSES UFW COMPLETELY
This surprises nearly everyone. Docker writes its own iptables/nftables rules in the DOCKER chain, which is processed before UFW's chain. A published container port is reachable from the internet even though ufw status shows it as denied.
# This is publicly accessible on port 5432, despite UFW default-deny:
docker run -d -p 5432:5432 postgresYou will look at ufw status, see no rule for 5432, and conclude you are safe. You are not.
The fix — bind published ports to loopback:
# Reachable only from the host
docker run -d -p 127.0.0.1:5432:5432 postgresIn docker-compose.yml:
services:
postgres:
image: postgres:16-alpine
ports:
- "127.0.0.1:5432:5432" # ✅ localhost only
# - "5432:5432" # ❌ 0.0.0.0 — publicly exposedTHE BEST OPTION: DO NOT PUBLISH THE PORT AT ALL
If only other containers need to reach PostgreSQL, omit ports: entirely and let them connect over the Docker network by service name (postgres:5432). A port that is not published cannot be exposed by any misconfiguration. Publish to 127.0.0.1 only when a host process — like a PM2-managed Node app — needs access. (Level 11)
Verify what Docker actually exposed:
# SERVER
docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo ss -tulpn | grep dockerNAMES PORTS
app-db 127.0.0.1:5432->5432/tcp ✅ safe
app-redis 0.0.0.0:6379->6379/tcp ❌ EXPOSED TO THE INTERNETFor a stricter setup, the ufw-docker tool rewrites the chains so UFW governs Docker too:
# SERVER — optional
sudo wget -O /usr/local/bin/ufw-docker \
https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
sudo systemctl restart ufwBinding to 127.0.0.1 is simpler and sufficient for a single-server deployment. Use ufw-docker when you have containers that genuinely must be reachable from specific external addresses.
Two layers: cloud firewall + UFW
| Cloud firewall | UFW | |
|---|---|---|
| Runs | Outside your VM, on the provider's network | Inside your VM's kernel |
| Blocks traffic | Before it reaches you (saves bandwidth/CPU) | On arrival |
| Managed via | Provider web UI / API | SSH |
| Survives a broken VM | Yes | No |
| Aware of Docker's chains | Yes — it does not care what's inside | No (see above) |
| If you lock yourself out | Fixable from the web UI on your phone | Requires console access |
Run both. The cloud firewall is your safety net when UFW is misconfigured, and it is the one you can fix without SSH.
HETZNER CLOUD FIREWALL — MINIMUM CONFIG
Inbound rules:
- TCP 22 — source
0.0.0.0/0,::/0(or your static IP) - TCP 80 — source
0.0.0.0/0,::/0 - TCP 443 — source
0.0.0.0/0,::/0 - ICMP — allow, so
pingworks for diagnostics
Outbound: allow all.
Firewalls attach to servers by label, so a rule set can protect a whole group.
Troubleshooting
"I enabled UFW and lost my SSH connection"
The rule was missing. Recover via the provider's web console:
# SERVER — via console
sudo ufw allow 22/tcp
sudo ufw reload
sudo ufw status"My site is unreachable but Nginx is running"
Walk the layers outward:
# SERVER — 1. Is Nginx listening on 0.0.0.0?
sudo ss -tulpn | grep nginx
# 2. Does it respond locally?
curl -I http://127.0.0.1
# 3. Does UFW allow it?
sudo ufw status | grep -E "80|443"
# 4. Are packets being dropped?
sudo grep 'UFW BLOCK' /var/log/syslog | grep -E "DPT=80|DPT=443" | tail
# LOCAL — 5. Is the port reachable from outside?
nc -vz 203.0.113.10 443
curl -I http://203.0.113.10If step 2 works and step 5 times out, it is a firewall (UFW or cloud). If step 5 says "connection refused", Nginx is not listening. If step 2 also fails, the problem is Nginx, not the firewall.
"The rule exists but traffic is still blocked"
- Rule order —
sudo ufw status numbered; an earlierdenywins over a laterallow. - The cloud firewall — check the provider UI. UFW is not the only gatekeeper.
- IPv6 — you allowed IPv4 only, and the client connected over IPv6. Look for
(v6)rules. - Wrong protocol —
ufw allow 443covers TCP and UDP;ufw allow 443/tcpis TCP only. Check what you actually wrote. - Nothing is listening — a firewall rule does not create a service.
sudo ss -tulpn | grep :443.
"I can reach a port that UFW says is denied"
Almost always Docker (see above). Confirm with:
sudo ss -tulpn | grep :<port>
docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo nft list ruleset | grep -A5 "chain DOCKER"Production Checklist — Level 5
- [ ]
ufw default deny incoming,ufw default allow outgoing - [ ] SSH allowed before UFW was enabled
- [ ] Only 22, 80, 443 are allowed inbound
- [ ] IPv6 rules present (
(v6)entries inufw status verbose) - [ ] Cloud firewall configured as a second, independent layer
- [ ] PostgreSQL (5432) is not in the allow list
- [ ] Redis (6379) is not in the allow list
- [ ] App ports (3000, 3001) are not in the allow list
- [ ]
sudo ss -tulpnshows app, DB, and cache bound to127.0.0.1only - [ ] Every Docker
ports:entry is either absent or prefixed with127.0.0.1: - [ ] I verified Docker port exposure with
docker psand its--formatoutput - [ ] I use
ssh -Ltunnels for database access, not an open 5432 - [ ] UFW logging is
low(notmedium) in production - [ ] I know how to recover through the provider's web console