Introduction
In a previous blog post, I introduced iptables
as a foundational tool for Linux firewall management. While understanding the basic syntax is crucial, it’s equally important to harden your firewall — reducing your attack surface and enforcing security at the packet level. This post walks you through a practical script I use to secure my system with iptables
, explain its logic, and provide tips for customizing it depending on your environment (e.g., with or without Docker).
The Bash Script Firewall
#!/bin/bash
# Author: Jose Mendez
# Blog site: sandbox99.cc
# This script sets default policies to DROP and adds rules
# to allow necessary traffic (SSH, HTTP, HTTPS, localhost).
# It also adds LOG rules for monitoring traffic.
# It checks if policies/rules are already configured before applying.
# Require root privileges to run the script
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root."
exit 1
fi
echo "Starting iptables hardening setup with logging..."
# --- Configure Default Policies ---
# Desired policies
IPV4_INPUT_POLICY="DROP"
IPV4_FORWARD_POLICY="DROP"
IPV4_OUTPUT_POLICY="ACCEPT"
IPV6_INPUT_POLICY="DROP"
IPV6_FORWARD_POLICY="DROP"
IPV6_OUTPUT_POLICY="ACCEPT"
# Check and set IPv4 policies
echo "Checking IPv4 default policies..."
# Use iptables-save and parse for robustness
current_ipv4_policies=$(sudo iptables-save 2>/dev/null | grep ":INPUT\|:FORWARD\|:OUTPUT")
current_input_policy=$(echo "$current_ipv4_policies" | grep ":INPUT" | awk '{print $NF}')
current_forward_policy=$(echo "$current_ipv4_policies" | grep ":FORWARD" | awk '{print $NF}')
current_output_policy=$(echo "$current_ipv4_policies" | grep ":OUTPUT" | awk '{print $NF}')
if [ "$current_input_policy" != "$IPV4_INPUT_POLICY" ]; then
echo "Setting IPv4 INPUT policy to $IPV4_INPUT_POLICY..."
iptables -P INPUT "$IPV4_INPUT_POLICY"
else
echo "IPv4 INPUT policy is already set to $IPV4_INPUT_POLICY."
fi
if [ "$current_forward_policy" != "$IPV4_FORWARD_POLICY" ]; then
echo "Setting IPv4 FORWARD policy to $IPV4_FORWARD_POLICY..."
iptables -P FORWARD "$IPV4_FORWARD_POLICY"
else
echo "IPv4 FORWARD policy is already set to $IPV4_FORWARD_POLICY."
fi
if [ "$current_output_policy" != "$IPV4_OUTPUT_POLICY" ]; then
echo "Setting IPv4 OUTPUT policy to $IPV4_OUTPUT_POLICY..."
iptables -P OUTPUT "$IPV4_OUTPUT_POLICY"
else
echo "IPv4 OUTPUT policy is already set to $IPV4_OUTPUT_POLICY."
fi
# Check and set IPv6 policies
echo "Checking IPv6 default policies..."
if command -v ip6tables &> /dev/null; then
current_ipv6_policies=$(sudo ip6tables-save 2>/dev/null | grep ":INPUT\|:FORWARD\|:OUTPUT")
current_input_policy_v6=$(echo "$current_ipv6_policies" | grep ":INPUT" | awk '{print $NF}')
current_forward_policy_v6=$(echo "$current_ipv6_policies" | grep ":FORWARD" | awk '{print $NF}')
current_output_policy_v6=$(echo "$current_ipv6_policies" | grep ":OUTPUT" | awk '{print $NF}')
if [ "$current_input_policy_v6" != "$IPV6_INPUT_POLICY" ]; then
echo "Setting IPv6 INPUT policy to $IPV6_INPUT_POLICY..."
ip6tables -P INPUT "$IPV6_INPUT_POLICY"
else
echo "IPv6 INPUT policy is already set to $IPV6_INPUT_POLICY."
fi
if [ "$current_forward_policy_v6" != "$IPV6_FORWARD_POLICY" ]; then
echo "Setting IPv6 FORWARD policy to $IPV6_FORWARD_POLICY..."
ip6tables -P FORWARD "$IPV6_FORWARD_POLICY"
else
echo "IPv6 FORWARD policy is already set to $IPV6_FORWARD_POLICY."
fi
if [ "$current_output_policy_v6" != "$IPV6_OUTPUT_POLICY" ]; then
echo "Setting IPv6 OUTPUT policy to $IPV6_OUTPUT_POLICY..."
ip6tables -P OUTPUT "$IPV6_OUTPUT_POLICY"
else
echo "IPv6 OUTPUT policy is already set to $IPV6_OUTPUT_POLICY."
fi
else
echo "ip6tables command not found or saved rules not found. Skipping IPv6 policy configuration."
fi
# --- Add Essential Rules (Check if they exist first) ---
# Rule: Allow established and related incoming connections (IPv4)
echo "Checking for IPv4 established/related rule..."
if ! iptables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 established/related rule..."
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
else
echo "IPv4 established/related rule already exists."
fi
# Rule: Allow established and related incoming connections (IPv6)
echo "Checking for IPv6 established/related rule..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 established/related rule..."
ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
else
echo "IPv6 established/related rule already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 established/related rule."
fi
# Rule: Allow SSH incoming connections (Default port 22) (IPv4)
# Modify the port if your SSH server uses a different one
SSH_PORT="22"
echo "Checking for IPv4 SSH rule on port $SSH_PORT..."
if ! iptables -C INPUT -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 SSH rule on port $SSH_PORT..."
iptables -A INPUT -p tcp --dport "$SSH_PORT" -j ACCEPT
else
echo "IPv4 SSH rule on port $SSH_PORT already exists."
fi
# Rule: Allow SSH incoming connections (Default port 22) (IPv6)
echo "Checking for IPv6 SSH rule on port $SSH_PORT..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -p tcp --dport "$SSH_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 SSH rule on port $SSH_PORT..."
ip6tables -A INPUT -p tcp --dport "$SSH_PORT" -j ACCEPT
else
echo "IPv6 SSH rule on port $SSH_PORT already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 SSH rule."
fi
# Rule: Allow HTTP incoming connections (Port 80) (IPv4)
HTTP_PORT="80"
echo "Checking for IPv4 HTTP rule on port $HTTP_PORT..."
if ! iptables -C INPUT -p tcp --dport "$HTTP_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 HTTP rule on port $HTTP_PORT..."
iptables -A INPUT -p tcp --dport "$HTTP_PORT" -j ACCEPT
else
echo "IPv4 HTTP rule on port $HTTP_PORT already exists."
fi
# Rule: Allow HTTP incoming connections (Port 80) (IPv6)
echo "Checking for IPv6 HTTP rule on port $HTTP_PORT..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -p tcp --dport "$HTTP_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 HTTP rule on port "$HTTP_PORT"..."
ip6tables -A INPUT -p tcp --dport "$HTTP_PORT" -j ACCEPT
else
echo "IPv6 HTTP rule on port "$HTTP_PORT" already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 HTTP rule."
fi
# Rule: Allow HTTPS incoming connections (Port 443) (IPv4)
HTTPS_PORT="443"
echo "Checking for IPv4 HTTPS rule on port $HTTPS_PORT..."
if ! iptables -C INPUT -p tcp --dport "$HTTPS_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 HTTPS rule on port "$HTTPS_PORT"..."
iptables -A INPUT -p tcp --dport "$HTTPS_PORT" -j ACCEPT
else
echo "IPv4 HTTPS rule on port "$HTTPS_PORT" already exists."
fi
# Rule: Allow HTTPS incoming connections (Port 443) (IPv6)
echo "Checking for IPv6 HTTPS rule on port $HTTPS_PORT..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -p tcp --dport "$HTTPS_PORT" -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 HTTPS rule on port "$HTTPS_PORT"..."
ip6tables -A INPUT -p tcp --dport "$HTTPS_PORT" -j ACCEPT
else
echo "IPv6 HTTPS rule on port "$HTTPS_PORT" already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 HTTPS rule."
fi
# Rule: Allow localhost traffic (IPv4)
echo "Checking for IPv4 localhost rules..."
if ! iptables -C INPUT -i lo -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 localhost INPUT rule..."
iptables -A INPUT -i lo -j ACCEPT
else
echo "IPv4 localhost INPUT rule already exists."
fi
if ! iptables -C OUTPUT -o lo -j ACCEPT 2>/dev/null; then
echo "Adding IPv4 localhost OUTPUT rule..."
iptables -A OUTPUT -o lo -j ACCEPT
else
echo "IPv4 localhost OUTPUT rule already exists."
fi
# Rule: Allow localhost traffic (IPv6)
echo "Checking for IPv6 localhost rules..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -i lo -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 localhost INPUT rule..."
ip6tables -A INPUT -i lo -j ACCEPT
else
echo "IPv6 localhost INPUT rule already exists."
fi
if ! ip6tables -C OUTPUT -o lo -j ACCEPT 2>/dev/null; then
echo "Adding IPv6 localhost OUTPUT rule..."
ip6tables -A OUTPUT -o lo -j ACCEPT
else
echo "IPv6 localhost OUTPUT rule already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 localhost rules."
fi
# --- Add LOG Rules (Before default DROP) ---
# These rules log packets that would otherwise be dropped by the default policy
# Use a rate limit to prevent log flooding.
LOG_LIMIT="5/min" # Limit to 5 log messages per minute per rule
# Log IPv4 packets dropped by default INPUT policy
LOG_PREFIX_INPUT_DROP="iptables-input-dropped: "
echo "Checking for IPv4 INPUT drop logging rule..."
if ! iptables -C INPUT -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_INPUT_DROP" 2>/dev/null; then
echo "Adding IPv4 INPUT drop logging rule..."
# Place this rule near the end of the chain, before the default policy
# Check if there's a jump to a different chain near the end and place before it if needed.
# For simplicity, we'll append it. If you have complex jumps, verify placement manually.
iptables -A INPUT -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_INPUT_DROP"
else
echo "IPv4 INPUT drop logging rule already exists."
fi
# Log IPv6 packets dropped by default INPUT policy
LOG_PREFIX_INPUT_DROP_V6="ip6tables-input-dropped: "
echo "Checking for IPv6 INPUT drop logging rule..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C INPUT -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_INPUT_DROP_V6" 2>/dev/null; then
echo "Adding IPv6 INPUT drop logging rule..."
ip6tables -A INPUT -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_INPUT_DROP_V6"
else
echo "IPv6 INPUT drop logging rule already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 INPUT drop logging rule."
fi
# Log IPv4 packets dropped by default FORWARD policy
LOG_PREFIX_FORWARD_DROP="iptables-forward-dropped: "
echo "Checking for IPv4 FORWARD drop logging rule..."
if ! iptables -C FORWARD -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_FORWARD_DROP" 2>/dev/null; then
echo "Adding IPv4 FORWARD drop logging rule..."
# Place this rule near the end of the chain, before the default policy
iptables -A FORWARD -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_FORWARD_DROP"
else
echo "IPv4 FORWARD drop logging rule already exists."
fi
# Log IPv6 packets dropped by default FORWARD policy
LOG_PREFIX_FORWARD_DROP_V6="ip6tables-forward-dropped: "
echo "Checking for IPv6 FORWARD drop logging rule..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C FORWARD -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_FORWARD_DROP_V6" 2>/dev/null; then
echo "Adding IPv6 FORWARD drop logging rule..."
ip6tables -A FORWARD -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_FORWARD_DROP_V6"
else
echo "IPv6 FORWARD drop logging rule already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 FORWARD drop logging rule."
fi
# Log IPv4 packets hitting the DOCKER-USER chain (Optional, for debugging Docker traffic)
# Place this rule strategically in DOCKER-USER depending on what you want to log.
# Appending it logs packets that weren't handled by specific rules before it.
LOG_PREFIX_DOCKER_USER="iptables-docker-user: "
echo "Checking for IPv4 DOCKER-USER logging rule..."
if ! iptables -C DOCKER-USER -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_DOCKER_USER" 2>/dev/null; then
echo "Adding IPv4 DOCKER-USER logging rule..."
# It's often useful to log traffic *entering* the DOCKER-USER chain, which would
# require inserting the rule at the beginning (-I DOCKER-USER 1).
# For simplicity here, we append it. Adjust manually if needed.
iptables -A DOCKER-USER -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_DOCKER_USER"
else
echo "IPv4 DOCKER-USER logging rule already exists."
fi
# Log IPv6 packets hitting the DOCKER-USER chain (Optional)
LOG_PREFIX_DOCKER_USER_V6="ip6tables-docker-user: "
echo "Checking for IPv6 DOCKER-USER logging rule..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C DOCKER-USER -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_DOCKER_USER_V6" 2>/dev/null; then
echo "Adding IPv6 DOCKER-USER logging rule..."
ip6tables -A DOCKER-USER -m limit --limit "$LOG_LIMIT" -j LOG --log-prefix "$LOG_PREFIX_DOCKER_USER_V6"
else
echo "IPv6 DOCKER-USER logging rule already exists."
fi
else
echo "ip6tables command not found. Skipping IPv6 DOCKER-USER logging rule."
fi
# --- Recommended Rule for Docker Integration (DOCKER-USER Chain) ---
# This rule sends traffic from the DOCKER-USER chain to the RETURN target.
# Place your custom DOCKER-USER filtering rules *before* this RETURN rule.
echo "Checking for basic IPv4 DOCKER-USER chain rule (RETURN)..."
if ! iptables -C DOCKER-USER -j RETURN 2>/dev/null; then
echo "Adding basic IPv4 DOCKER-USER chain rule (RETURN)..."
iptables -A DOCKER-USER -j RETURN
else
echo "Basic IPv4 DOCKER-USER chain rule (RETURN) already exists."
fi
echo "Checking for basic IPv6 DOCKER-USER chain rule (RETURN)..."
if command -v ip6tables &> /dev/null; then
if ! ip6tables -C DOCKER-USER -j RETURN 2>/dev/null; then
echo "Adding basic IPv6 DOCKER-USER chain rule (RETURN)..."
ip6tables -A DOCKER-USER -j RETURN
else
echo "Basic IPv6 DOCKER-USER chain rule (RETURN) already exists."
fi
else
echo "ip6tables command not found. Skipping basic IPv6 DOCKER-USER rule."
fi
# --- Save Rules ---
echo "Saving iptables rules..."
if command -v netfilter-persistent &> /dev/null; then
netfilter-persistent save
echo "iptables rules saved using netfilter-persistent."
else
echo "netfilter-persistent not found. Please install it ('sudo apt install iptables-persistent') to make rules persistent across reboots."
echo "You can manually save rules using: sudo iptables-save > /etc/iptables/rules.v4"
if command -v ip6tables &> /dev/null; then
echo "And IPv6 rules using: sudo ip6tables-save > /etc/iptables/rules.v6"
fi
fi
echo "iptables hardening setup with logging complete."
echo "Monitor logs using 'sudo journalctl -k -f | grep \"iptables-\"' or by checking /var/log/kern.log or /var/log/syslog."
echo "Remember to add any specific filtering rules for Docker containers in the DOCKER-USER chain before the RETURN rule."
How the Script Works:
- Requires Root: It first checks if the script is run as root or sudo.
- Sets Policies Conditionally: For the
INPUT
,FORWARD
, andOUTPUT
chains (for both IPv4 and IPv6), it checks the current default policy. If it doesn’t match the desired secure policy (DROP
for INPUT/FORWARD,ACCEPT
for OUTPUT), it sets the policy. (see line 21 to 90 in the script above) - Adds Rules Conditionally:
- For essential rules like allowing established/related connections, SSH, and localhost traffic, it uses
iptables -C
(check command) to see if an identical rule already exists in the target chain. - The
2>/dev/null
suppresses error output if the rule doesn’t exist (which is expected). - If the check fails (rule doesn’t exist), it adds the rule using
iptables -A
(append to the end of the chain). - SSH (Port 22) Rules: Added conditional checks and
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
rules for both IPv4 and IPv6. (see line 117 to 139) - HTTP (Port 80) Rules: Added conditional checks and
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
rules for both IPv4 and IPv6. (see line 141 to 162) - HTTPS (Port 443) Rules: Added conditional checks and
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
rules for both IPv4 and IPv6. (see line 164 to 185)
- For essential rules like allowing established/related connections, SSH, and localhost traffic, it uses
- DOCKER-USER Chain: It includes a check for a basic
RETURN
rule in theDOCKER-USER
chain. This chain is where you’d typically add custom rules before Docker’s processing. The script adds a-j RETURN
rule at the end of this chain if it’s not present. Note: If you add custom filtering rules (e.g., source IP restrictions for specific container ports) toDOCKER-USER
, you should add them before the-j RETURN
rule. The script doesn’t automate adding those specific custom rules, but you can easily add them manually after running this script. (see line 313 to 335 if you don’t use DOCKER remove this line) - Added LOG Rules to iptables: (see line 224 to 310)
- How iptables Logging Works:
iptables
doesn’t have its own dedicated log file. Instead, you configureiptables
rules with aLOG
target (-j LOG
). When a packet matches a rule that includes theLOG
target, information about that packet is sent to the kernel’s logging facility (usually managed byrsyslog
orsyslog-ng
on Debian). The kernel logger then writes these messages to system log files. You typically place aLOG
rule before aDROP
orREJECT
rule for the traffic you want to log. This way, you log the attempt before the packet is discarded. - LOG Rule Definitions: Defined variables for log limits and prefixes.
- Conditional LOG Rules: Added conditional checks (
iptables -C
) before addingLOG
rules for:- IPv4 and IPv6
INPUT
chain (for dropped incoming packets). - IPv4 and IPv6
FORWARD
chain (for dropped forwarded packets). - IPv4 and IPv6
DOCKER-USER
chain (for packets hitting this chain – appended at the end for simplicity).
- IPv4 and IPv6
- Rate Limit: Added the
-m limit --limit 5/min
module to theLOG
rules to prevent log files from growing excessively fast during scans or attacks. - Log Prefixes: Used distinct prefixes (
iptables-input-dropped:
,iptables-forward-dropped:
,iptables-docker-user:
) to make it easy to identify and filter these messages in your system logs. - Final Message Updated: The final output message includes instructions on how to view the
iptables
logs usingjournalctl
or by checking common log files.
- How iptables Logging Works:
- Saves Rules: It attempts to save the rules using
netfilter-persistent save
, which is the standard method on Debian to make rules persistent across reboots. It also provides manual save commands ifnetfilter-persistent
is not installed. (see line 338 to 349) - Provides Feedback: The script prints messages indicating which policies it’s checking/setting and which rules it’s adding or found to be existing. (see screenshot below)

Sample screenshot on logs: ‘sudo journalctl -k -f | grep “iptables-“‘

Before Running:
- Review and Customize: Carefully review the script, especially the
SSH_PORT
variable and any commented-out examples for allowing other ports or restricting SSH by source IP. Adjust them to match your server’s configuration and security requirements. - Have a Recovery Plan: Since setting default policies to
DROP
can lock you out, ensure you have console access or another reliable recovery method before running this script. - Make Executable: Save the script as a file (e.g.,
harden_iptables.sh
) and make it executable:chmod +x harden_iptables.sh
- Run with sudo: Execute the script with root privileges:
sudo ./harden_iptables.sh
After running the script, verify your rules with sudo iptables -L -v -n
and sudo ip6tables -L -v -n
. You will then need to manually add ACCEPT
rules for any other ports your Docker containers publish that need to be accessible from outside, either in the INPUT
chain (less common for typical Docker setups routing through FORWARD) or preferably by adding specific rules in the DOCKER-USER
chain before the RETURN
rule the script adds there.
sudo iptables -L -v -n

Security-Focused Sections
Let’s highlight some security-driven parts of the script:
- Default DROP policies: Reduces surface area for attacks.
- Established connections only: Blocks unsolicited attempts.
- Limited port access: Only exposes necessary services.
- Added logging: Helps in auditing and intrusion detection.
- Docker interface filtering: Ensures container traffic is controlled.
Final Thoughts
Hardening your Linux firewall is more than writing a few iptables
rules — it’s about understanding your system’s needs, reducing exposure, and ensuring persistence and auditability. Use my script as a base, but tweak it to suit your environment.
- Run it manually first to validate.
- Always test changes, especially on remote systems — lockouts are real.
- Consider combining this with tools like
fail2ban
or port knocking for additional security.
0 Comments