Skip to content

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.

ActionBehaviourClient sees
ACCEPTPacket passesNormal response
DROPPacket silently discardedTimeout (~30s)
REJECTPacket discarded with an ICMP errorConnection 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 ​

DirectionMeaningDefault policyWhy
IncomingThe world → your serverdenyNothing gets in unless you explicitly allow it
OutgoingYour server → the worldallowYour server needs to reach apt repos, npm registry, Let's Encrypt, your SMTP provider
RoutedThrough your serverdenyYou 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:

PortProtocolServiceWhy public
22TCPSSHYou need to administer the server
80TCPHTTPLet's Encrypt HTTP-01 challenge + redirect to HTTPS
443TCPHTTPSThe actual application traffic

Why port 80 stays open ​

You might reason: "everything is HTTPS, so close 80." Don't.

  1. Let's Encrypt HTTP-01 validation requires port 80 (Level 16). Close it and your certificate renewal fails silently in 60 days.
  2. 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.
  3. Port 80 serves only a 301 redirect (plus the ACME challenge path), so the exposure is minimal.

Ports that must NOT be public ​

PortServiceWhat happens if exposed
3000NuxtBypasses Nginx: no TLS, no rate limiting, no security headers, no access logs. Attackers hit your app directly.
3001NestJS APISame, plus your API is served over plain HTTP — tokens and credentials in cleartext
5432PostgreSQLDirect access to all your data. Credential-stuffing and CVE exploitation against the DB engine itself.
6379RedisCatastrophic. See below.
9229Node inspectorFull remote debugger: read memory, read env vars, execute arbitrary code
27017MongoDBHistorically the source of tens of thousands of ransomed databases
8080, 5000, 4000Common dev serversWhatever 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:

  1. CONFIG SET dir /home/deploy/.ssh
  2. CONFIG SET dbfilename authorized_keys
  3. SET x "ssh-rsa AAAA... attacker"
  4. 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:

bash
# 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.10

Now 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:

bash
# 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.

bash
# 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 enable

You 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.

bash
# SERVER
sudo ufw status verbose
Status: 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:

bash
# SERVER
sudo ufw app list
Available applications:
  Nginx Full
  Nginx HTTP
  Nginx HTTPS
  OpenSSH
ProfilePorts
OpenSSH22/tcp
Nginx HTTP80/tcp
Nginx HTTPS443/tcp
Nginx Full80 and 443/tcp
bash
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.

bash
# 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.92

ONLY 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:

bash
sudo ufw limit ssh/tcp

This denies an IP that makes more than 6 connection attempts in 30 seconds. Complementary to fail2ban.

Managing rules ​

bash
# 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
bash
# Delete by number — DELETE FROM THE BOTTOM UP
sudo ufw delete 4

# Or delete by matching the original rule
sudo ufw delete allow 5432/tcp

RULE 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:

bash
sudo ufw insert 1 deny from 45.148.10.92

Putting a deny first ensures it is not shadowed by a later broad allow.

Other management commands:

bash
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 verbose

ufw 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 ​

bash
# SERVER
sudo ufw logging on          # 'low' level — logs blocked packets
sudo ufw logging medium      # also logs allowed packets matching rules
sudo ufw logging off

Blocked packets land in the kernel log:

bash
sudo grep 'UFW BLOCK' /var/log/syslog | tail -20
sudo journalctl -k --since "1 hour ago" | grep UFW

A typical entry:

[UFW BLOCK] IN=eth0 OUT= SRC=45.148.10.92 DST=203.0.113.10 PROTO=TCP SPT=51234 DPT=23

DPT=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.

bash
# This is publicly accessible on port 5432, despite UFW default-deny:
docker run -d -p 5432:5432 postgres

You 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:

bash
# Reachable only from the host
docker run -d -p 127.0.0.1:5432:5432 postgres

In docker-compose.yml:

yaml
services:
  postgres:
    image: postgres:16-alpine
    ports:
      - "127.0.0.1:5432:5432"    # ✅ localhost only
      # - "5432:5432"            # ❌ 0.0.0.0 — publicly exposed

THE 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:

bash
# SERVER
docker ps --format "table {{.Names}}\t{{.Ports}}"
sudo ss -tulpn | grep docker
NAMES       PORTS
app-db      127.0.0.1:5432->5432/tcp     ✅ safe
app-redis   0.0.0.0:6379->6379/tcp       ❌ EXPOSED TO THE INTERNET

For a stricter setup, the ufw-docker tool rewrites the chains so UFW governs Docker too:

bash
# 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 ufw

Binding 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 firewallUFW
RunsOutside your VM, on the provider's networkInside your VM's kernel
Blocks trafficBefore it reaches you (saves bandwidth/CPU)On arrival
Managed viaProvider web UI / APISSH
Survives a broken VMYesNo
Aware of Docker's chainsYes — it does not care what's insideNo (see above)
If you lock yourself outFixable from the web UI on your phoneRequires 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 ping works 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:

bash
# 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:

bash
# 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.10

If 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" ​

  1. Rule order — sudo ufw status numbered; an earlier deny wins over a later allow.
  2. The cloud firewall — check the provider UI. UFW is not the only gatekeeper.
  3. IPv6 — you allowed IPv4 only, and the client connected over IPv6. Look for (v6) rules.
  4. Wrong protocol — ufw allow 443 covers TCP and UDP; ufw allow 443/tcp is TCP only. Check what you actually wrote.
  5. 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:

bash
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 in ufw 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 -tulpn shows app, DB, and cache bound to 127.0.0.1 only
  • [ ] Every Docker ports: entry is either absent or prefixed with 127.0.0.1:
  • [ ] I verified Docker port exposure with docker ps and its --format output
  • [ ] I use ssh -L tunnels for database access, not an open 5432
  • [ ] UFW logging is low (not medium) in production
  • [ ] I know how to recover through the provider's web console

Next: Level 6 — NVM and Node.js →