Overview and What You Will Learn
A freshly provisioned Linux server accepts connections on every port from anywhere on the internet. PostgreSQL, Redis, application APIs — all reachable by anyone who scans your IP. A firewall closes every door and opens only the ones that should be open.
By the end of this lab you will:
- Understand how netfilter, iptables, and ufw relate to each other
- Read and write iptables rules using correct syntax
- Build a complete production ruleset from scratch
- Configure ufw for simpler firewall management on Ubuntu servers
- Handle the Docker and ufw bypass problem correctly
- Persist firewall rules across server reboots
Why This Matters in Production
A PhonePe payment server had its database port (5432) briefly exposed to the internet due to a missing firewall rule after a server rebuild. Automated scanners found the open port within 4 minutes and began brute-force attempts. A properly configured firewall with default-deny would have made the port invisible — not just closed, but completely unresponsive to external probes.
Core Principles
iptables tables and chains — the packet path:
+------------------------------------------+| Packet arrives at server NIC |+------------------------------------------+ | v+------------------------------------------+| PREROUTING (nat table) || DNAT -- redirect to different port/IP |+------------------------------------------+ | routing decision / \ for this host forward to other host | | v v+------------------+ +------------------+| INPUT chain | | FORWARD chain || (filter table) | | (filter table) |+------------------+ +------------------+ | accepted packets | v+------------------------------------------+| Local process (nginx, postgres, etc.) |+------------------------------------------+ | v+------------------------------------------+| OUTPUT chain (filter table) |+------------------------------------------+ | v+------------------------------------------+| POSTROUTING (nat table) || SNAT -- rewrite source IP for outbound |+------------------------------------------+Rule anatomy:
iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT| | | | | | |tool action chain proto dest port source target Actions: ACCEPT DROP REJECT LOG RETURNChains: INPUT OUTPUT FORWARDProtocols: tcp udp icmp allDetailed Step-by-Step Practical Lab
Milestone 1 — Read current iptables rules
## List all rules with packet/byte counterssudo iptables -L -n -v ## List specific chainsudo iptables -L INPUT -n -v ## List with line numbers (needed for deleting specific rules)sudo iptables -L INPUT -n -v --line-numbers ## Output on a clean server:## Chain INPUT (policy ACCEPT)## num pkts bytes target prot opt in out source destination## (empty -- no rules, everything accepted by default) ## Check current default policiessudo iptables -L | grep 'Chain.*policy'## Chain INPUT (policy ACCEPT) <- dangerous default## Chain FORWARD (policy ACCEPT)## Chain OUTPUT (policy ACCEPT) ## List nat table (for port forwarding / Docker)sudo iptables -t nat -L -n -v ## List all tablesfor table in filter nat mangle; do echo "=== Table: $table ===" sudo iptables -t $table -L -n -vdoneMilestone 2 — Build a production iptables ruleset
Apply rules in this exact order. Rules are evaluated top-to-bottom — the first match wins.
## Step 1: Allow established/related connections first## This is stateful firewall -- allows return traffic for outgoing connections## Without this, you would need explicit rules for every responsesudo iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT ## Step 2: Allow loopback (127.0.0.1 -- local traffic)## Services communicating with themselves must worksudo iptables -A INPUT -i lo -j ACCEPT ## Step 3: Allow SSH from internal network only## Replace 10.0.0.0/8 with your VPN/office CIDRsudo iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT ## Step 4: Allow HTTP and HTTPS from anywheresudo iptables -A INPUT -p tcp --dport 80 -j ACCEPTsudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT ## Step 5: Allow your application port from internal network onlysudo iptables -A INPUT -p tcp --dport 4000 -s 10.0.0.0/8 -j ACCEPT ## Step 6: Allow ICMP ping (useful for monitoring)sudo iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT ## Step 7: Log dropped packets before the default policy## This captures what is being blocked for security analysissudo iptables -A INPUT -m limit --limit 5/min -j LOG --log-prefix "iptables-dropped: " ## Step 8: Set default policies to DROP## DO THIS LAST -- if done first, you lock yourself outsudo iptables -P INPUT DROPsudo iptables -P FORWARD DROPsudo iptables -P OUTPUT ACCEPT ## outbound is usually unrestricted ## Verify the rulesetsudo iptables -L INPUT -n -v --line-numbersCOMMON MISTAKE / WARNING**Common Mistake:** Setting `iptables -P INPUT DROP` before adding allow rules. This immediately blocks all incoming traffic including your SSH session. Always build the full ruleset with ACCEPT rules first, verify it in a second terminal, then set the DROP default policy as the final step.
COMMON MISTAKE / WARNING**Security:** Always keep a second terminal open when changing firewall rules. If your rule changes block your SSH connection, the existing session is your only way back in. Test from a second connection before closing the first.
Milestone 3 — Manage individual rules
## Add a rule at the top (INSERT) instead of bottom (APPEND)## Useful for allowing a specific IP with higher prioritysudo iptables -I INPUT 1 -p tcp --dport 22 -s 203.0.113.10 -j ACCEPT ## Delete a rule by line numbersudo iptables -D INPUT 3 ## Delete a rule by specification (exact match required)sudo iptables -D INPUT -p tcp --dport 80 -j ACCEPT ## Flush (delete all rules) from a chain## WARNING: if default policy is DROP, this blocks all trafficsudo iptables -F INPUT ## Replace a rule at a specific positionsudo iptables -R INPUT 2 -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT ## Check if a specific packet would match## (test without actually adding a rule)sudo iptables -C INPUT -p tcp --dport 22 -s 10.0.0.0/8 -j ACCEPT## exit code 0 = rule exists, exit code 1 = rule does not existMilestone 4 — Persist rules across reboots
iptables rules are stored in memory and lost on reboot by default.
## Method 1: iptables-persistent (Debian/Ubuntu -- recommended)sudo apt install iptables-persistent## During install, it asks to save current rules -- say yes ## Save current rules manuallysudo iptables-save | sudo tee /etc/iptables/rules.v4sudo ip6tables-save | sudo tee /etc/iptables/rules.v6 ## Restore rules from file (done automatically at boot by iptables-persistent)sudo iptables-restore < /etc/iptables/rules.v4 ## Method 2: systemd service (RHEL/CentOS/Amazon Linux)sudo yum install iptables-servicessudo systemctl enable iptablessudo service iptables save## Saves to /etc/sysconfig/iptables ## Verify rules load on bootsudo reboot## After reboot:sudo iptables -L -n -v## Rules should be presentMilestone 5 — Configure ufw for simpler management
ufw (Uncomplicated Firewall) writes iptables rules with a simpler interface. Standard on Ubuntu servers.
## Check ufw statussudo ufw status verbose ## Set default policiessudo ufw default deny incomingsudo ufw default allow outgoingsudo ufw default deny routed ## Allow services by name (uses /etc/services for port numbers)sudo ufw allow ssh ## port 22sudo ufw allow http ## port 80sudo ufw allow https ## port 443 ## Allow specific port/protocolsudo ufw allow 4000/tcp ## Allow from specific IPsudo ufw allow from 10.0.0.0/8 to any port 22sudo ufw allow from 52.66.1.100 ## Allow range of portssudo ufw allow 8000:9000/tcp ## Enable the firewallsudo ufw enable ## View rules with numberssudo ufw status numbered## Status: active## To Action From## [1] 22/tcp ALLOW 10.0.0.0/8## [2] 80/tcp ALLOW Anywhere## [3] 443/tcp ALLOW Anywhere ## Delete a rule by numbersudo ufw delete 3 ## Disable and reset all rulessudo ufw disablesudo ufw resetMilestone 6 — Fix the Docker and ufw bypass problem
Docker adds iptables rules that bypass ufw. A container with -p 80:80 becomes reachable from the internet even if ufw blocks port 80.
## Verify the problem: Docker adds to DOCKER chainsudo iptables -L DOCKER -n -v## Rules here bypass ufw INPUT chain ## Fix 1: Use DOCKER-USER chain (Docker-supported solution)## Rules in DOCKER-USER are evaluated BEFORE Docker's rulessudo iptables -I DOCKER-USER -p tcp --dport 80 -s 203.0.113.0/24 -j DROP## This blocks port 80 for a specific IP even though Docker exposed it ## Block all external access to Docker ports except from internal networksudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -j DROP## ! -s 10.0.0.0/8 means: NOT from internal network ## Fix 2: Bind Docker container to localhost only (preferred)## Instead of: docker run -p 80:80 nginx## Use: docker run -p 127.0.0.1:80:80 nginx## Then nginx only accepts connections from localhost## Put nginx reverse proxy in front for external access ## Fix 3: Disable Docker iptables manipulation (not recommended)## Edit /etc/docker/daemon.json:## {"iptables": false}## Then manage Docker networking manually -- complexProduction Best Practices and Common Pitfalls
| Mistake | Problem | Fix |
|---|---|---|
| Setting default DROP before adding allow rules | Locked out immediately | Add all allow rules first, set DROP policy last |
| Not saving rules after changes | Rules lost on reboot | Run iptables-save after every change |
| Assuming ufw covers Docker ports | Docker bypasses ufw | Use DOCKER-USER chain or bind to localhost |
| Blocking ICMP entirely | Monitoring tools stop working | Allow icmp echo-request explicitly |
| No logging before DROP | Cannot audit what was blocked | Add LOG rule before default DROP policy |
Quick Reference and Troubleshooting Commands
| Task | Command |
|---|---|
| List all rules | sudo iptables -L -n -v |
| List with numbers | sudo iptables -L INPUT --line-numbers |
| Add allow rule | sudo iptables -A INPUT -p tcp --dport PORT -j ACCEPT |
| Delete by number | sudo iptables -D INPUT LINE_NUMBER |
| Save rules | sudo iptables-save > /etc/iptables/rules.v4 |
| ufw status | sudo ufw status numbered |
| ufw allow port | sudo ufw allow PORT/tcp |
| Docker-aware block | sudo iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -j DROP |
PLACEMENT PRO TIP**Tip:** After building your iptables ruleset, run `sudo iptables -L INPUT -n -v --line-numbers` and read it top to bottom as if you are a packet arriving at the server. Does every legitimate traffic source match a rule before hitting the DROP policy? This mental walkthrough catches most firewall misconfigurations before they cause incidents.
REMEMBER THIS**Remember:** iptables rules are evaluated top-to-bottom and the first matching rule wins. Order matters enormously. An ACCEPT rule for port 22 at line 5 never fires if a DROP rule at line 3 matches first. Always verify rule order after any changes with `--line-numbers`.