Setting up iptables on a LAMP stack
An in-depth techical guide and introduction to firewalls.
The iptables
package gives us advanced, granular control over our firewall, with important built-in features like filtering and limiting. This guide will cover a more advanced approach, leveraging the mentioned capabilities, the included conntrack
module, and restrictive rules, while exploring potential security risks.
There are lot’s of guides that give you the bare minimum you need to succeed, and there is nothing wrong with that. However, providing you with proper reasoning behind some basic security practices, or shedding light on something that you might not think about until you are publicly launching your first project makes a great deal of importance. That’s where this guide comes in! A tough knowledge pill that’s worth to swallow.
Every command we use in this guide (except man
) needs elevated privileges, so I have prepended all commands with sudo
to make copying everything — after careful checking, of course — easier.
We will be implementing a firewall that follows the principles laid out by the stateful firewall concept.
The configuration covered in this guide was tested on a local Ubuntu 24.04 virtual machine and on a cloud Ubuntu 22.04 virtual machine. Both with Apache2 installed and enabled for HTTP/HTTPS rule testing. Testing included making and receiving HTTP/HTTPS requests, checking apt
connectivity, using the dig
command and using ping
from client to virtual machine.
This configuration is also compatible with the LEMP and MERN stacks, and should work with other similar stacks as well.
Originally, this was part of my “Setting up a LAMP (the stack)” guide, but I decided to split it into a separate publication to avoid overwhelming newcomers.
Manual and documentation
You are encouraged to check out the documentation of commands we will be using in this guide. Linux command documentation and manuals can be accessed by using man iptables
.
- Use up and down arrow keys or
Page Up
/Page Down
to navigate through the manual. - Press
/
while the manual is open and type your search term to highlight instances of that term or phrase. This is especially significant for newcomers, since long documentation can get overwhelming really quick. - To exit the manual, press
q
.
Some manuals also have examples on how to perform certain actions.
Alternatively, you can use iptables -h
for summarized details and list of options.
How do firewalls work?
All firewall environments use the same or similar terminology. The concepts remain unchanged. When we set up firewall rules, we add those rules to a chain, on a specific table. There is no need to elaborate on tables at this point, since usually, and in this case, we will be only working on the default “filter” table.
The usual chains are:
- INPUT — packets heading into our machine,
- FORWARD — packets passing through (mainly used by routers),
- OUTPUT — packets that our machine sends out.
Since we are setting up a LAMP stack firewall, I’m assuming that you want to host a website or other web application. This means that we need to open ports 80
(HTTP) and 443
(HTTPS). On top of that, we need to open the SSH/SFTP port 22
(SFTP is essentially FTP over SSH, which means it also uses port 22
), to keep remote communication with our machine open. Those ports only use the TCP protocol. We will also allow limited ICMP (pings) traffic and DNS lookups (port 53
via UDP).
Remember, if you changed your SSH port, you have to use your custom port instead of the default 22
!
In a standard firewall solution, rules are processed in the same order in which they are added. In a command line interface (CLI), this is especially important. In a graphical user interface (GUI), usually you are allowed to move things around, but in both cases ordering mistakes can cause major problems, including complete lockouts.
If you do lose remote access, most vendors that offer virtual machines also provide a web terminal or direct access via on-site support. If that’s not the case, don’t be ashamed to scrap the installation and try again from the start. It’s all part of the learning process.
Working with iptables
As you can probably see in the manual, there’s a lot of commands, flags, and parameters going on. Let’s go through some of the basic commands that will be helpful. We will skip the rule appending part, since we cover them in great detail later.
To get current rules from all chains, with their corresponding position, use:
sudo iptables -L -vn --line-numbers
- The
-L
parameter means “list”, and it lists all the rules in the selected chain. If no chain is specified, all chains will be displayed. - The
-v
parameter means “verbose”, this provides additional details about each rule. - The
-n
parameter means “numeric”, this will prevent iptables from attempting to resolve hostnames and display IP addresses and ports in their numerical form. - Finally, the
--line-numbers
option adds a line number to each rule to the output. This is especially helpful when you need to delete or modify a specific rule.
To delete a specific rule from a specific chain, you should look it up via the previous command, and then use:
sudo iptables -D {chain} {rule-id}
Alternatively, if you know the full command used to append the rule, or just copied the rule from here, you can just the same snippet but with the -D
option, instead of -A
. Example:
sudo iptables -D INPUT -p tcp --sport 80 -m conntrack --ctstate ESTABLISHED -j ACCEPT
Rule persistency
Rules defined directly via iptables
are ephemeral, which means that they are temporary, and exist only for the duration of the session. If we reboot our server without saving them, they will disappear.
Because of that, we also need the iptables-persistent
package on top of iptables
. It’s basically a service that loads your rules every time the machine starts.
To save the configuration, we can use the following:
sudo netfilter-persistent save
Danger: Rules take effect immediately after you add them! This command only saves them to load them back after restart!
The setup
Usually, you would want to set up INPUT and OUTPUT rules for specific ports, so your server can both receive and respond to requests from the internet. We will be taking it a bit farther.
When someone visits our server, they send a request, by which they establish a connection. On the first request, the state is NEW
, but everything after that has the ESTABLISHED
or RELATED
(to existing connection; auxiliary connection) state. Of course this can differ on other protocols, but HTTP/HTTPS are not that fancy.
We will be taking a balanced approach, by doing the following:
- As a server — allow
NEW
andESTABLISHED
inbound80, 443
traffic destined for our web server, and allow outgoing packets only toESTABLISHED
connections. - As a client — allow
NEW
andESTABLISHED
outbound80, 443
traffic destined for external web servers (apt
usage, external APIs), and allow onlyESTABLISHED
inbound connections. - SSH (
22
) — allow onlyNEW
orESTABLISHED
inbound traffic and exclusivelyESTABLISHED
outbound connections. This effectively prevents our server from making its own SSH connections. - ICMP (protocol) & DNS (
53
via UDP) — allow restricted ICMP andESTABLISHED
inbound DNS,NEW, ESTABLISHED
outbound DNS. Since this is not a DNS server, we do not accept new or unrelated inbound DNS packets. - Drop everything that does not match any of our rules.
This setup offers a solid foundation and limits the potential exposure surface while allowing fairly free standard HTTP/HTTPS traffic flow.
Theoretically, you could also use this setup with the MERN stack if you’re using NGINX as a reverse proxy. Since traffic between Node.js and NGINX is internal, it will flow smoothly over the loopback interface that we will leave open.
Security considerations
The second you expose your server or machine to the internet, you will notice that there will always be someone or something scanning and probing it. Either by attempting to break in using popular insecure credentials, scanning open ports or poking your deployed web application for usual configuration mistakes or environment vulnerabilities.
It’s important to highlight, that security is not an objective or destination, rather a directive that guides our actions. You cannot achieve security, but you can do your best to mitigate malicious activity. Another thing to keep in mind for the future is: security through obscurity is not real security. It’s a flawed security principle that focuses on secrecy instead of actual protection.
In some cases ICMP can be used maliciously as a tunneling method. We will mitigate this by blocking all ICMP except for inbound echo-request
type messages and outgoing echo-reply, destination-unreachable
messages. This will allow external machines to ping ours (useful for basic health checks), but prevent our server to send any ICMP messages except the basic responses, and limits the possibility for an ICMP tunneling attack to work, but does not completely eradicate it. If you do not plan to use ICMP, feel free to not include those rules in your configuration or remove them later.
Similar thing can be done with DNS, that’s why we are also limiting traffic on port 53
UDP.
It’s important to note that on its own, the server should not be able to send outgoing requests without any safeguards. Opening outgoing communication raises the risk of data exfiltration instigated by viruses or code execution vulnerabilities. This configuration is a great foundation, but if you want to go the extra mile, after you install everything, you should delete the rules that allow outside connections as a client, and instead allow only outgoing client communication to specific IP addresses. We will cover this process at the end of this guide
When it comes to rate-limiting, I would advise against using the standard iptables
limit functionality, since this will not differentiate between legitimate connections and brute force attacks. Setting a rate limit that way even for packets with NEW
state, can result in lockout in a situation where you are attempting to connect during a brute-force attack or just regular vulnerability probing/scanning. You should use a specialized utility, like fail2ban
for that purpose instead.
Before we begin with the configuration, it’s crucial to understand that if an attacker has gained direct access to the server, our firewall will do absolutely nothing to stop them.
Configuration
1. Install iptables and iptables-persistent
sudo apt install iptables iptables-persistent
During the iptables-persistent
installation process, you will be asked to save and load the current iptables
rules for IPv4 and IPv6. At this point it is save to confirm both prompts.
2. (Optional) Flush current rules
Danger! This may cause loss of access to your machine!
If you don’t want to keep your previous rules and do not have the default action set to DROP
or REJECT
on input or output chains, remove all current rules:
sudo iptables -F
3. Set up inbound rules
Feel free to paste this whole block directly:
sudo iptables -A INPUT -p tcp --dport 80 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -A INPUT -p tcp --dport 443 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -A INPUT -p icmp --icmp-type echo-request -j ACCEPT; \
sudo iptables -A INPUT -p udp --sport 53 -m conntrack --ctstate ESTABLISHED -j ACCEPT
Notice the ;
operator, it allows for command chaining. With it, commands will execute even if the previous command fails. On top of that, we are using the \
multiline operator to make the command chain more legible.
To break down what we just did:
- The
-A
flag means “append”, this tells the application that we want to add this rule at the end of a specific chain (INPUT
). - The
-p
parameter means “protocol”, it defines which protocol is this rule for. All of our ports work on TCP, so we only allow packets via TCP. - The
--sport
parameter can be found in theiptables-extensions(8)
manual, and defines which source (”s”) ports to allow. - The
--dport
parameter defines the destination port of the packet. - The
-m
parameter means “match”, in our case it matches the packet’s state to eitherNEW
orESTABLISHED
. This will only allow new or already established connections to communicate with our machine. We’re using theconntrack
module, that’s why it’s--ctstate
. - The
-j
parameter means “jump”, and specifies what to do when the packet matches our rule. Here we want to accept everything.
4. Set up outbound rules
sudo iptables -A OUTPUT -p tcp --sport 80 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --sport 443 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --sport 22 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p icmp --icmp-type echo-reply -j ACCEPT; \
sudo iptables -A OUTPUT -p icmp --icmp-type destination-unreachable -j ACCEPT; \
sudo iptables -A OUTPUT -p udp --dport 53 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
5. Set up rules that allow outside connections as a client
sudo iptables -A INPUT -p tcp --sport 80 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A INPUT -p tcp --sport 443 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --dport 80 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --dport 443 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
You will notice that those rules have reverse source, destination ports and connection states. That’s because when we are connecting to a remote server, our machine is acting as a client, so in the opposite way as with Apache.
6. Allow internal (loopback) traffic
sudo iptables -A INPUT -i lo -j ACCEPT; \
sudo iptables -A OUTPUT -o lo -j ACCEPT
Without those rules, your database will not be able to communicate with your web application, because we also set up a general DROP
rule further down the road.
7. Verify your rules before changing default behavior
Before setting the default DROP rules, check if your configuration contains the intended rules:
sudo iptables -L -vn
- The
-L
parameter means “list”, and it lists all the rules in the selected chain. If no chain is specified, all chains will be displayed. - The
-v
parameter means “verbose”, this provides additional details about each rule. - The
-n
parameter means “numeric”, this will prevent iptables from attempting to resolve hostnames and display IP addresses and ports in their numerical form.
The output should looks like this:
8. Change default behavior to drop all other packets
Danger! This may cause loss of access to your machine!
sudo iptables -P INPUT DROP; \
sudo iptables -P OUTPUT DROP; \
sudo iptables -P FORWARD DROP
This will drop all traffic that do not match our previous rules and is a critical component of an effective firewall.
Naturally we want to drop all FORWARD chain packets, since this is basically a resource provider and not a router. Under any circumstances there should be no forwarding going on, and if there is anything like that happening, it’s highly likely that there is some malicious activity going on.
9. Make the configuration persistent
sudo netfilter-persistent save
This will first save the rules we just set up to a file and update the configuration.
Preventing unwanted outgoing HTTP/HTTPS
Following up on the idea that the server itself should not be making new connections on itself (acting as a client), we should disable the potentially unsafe rules to lock our server down, while allowing the bare minimum it needs to serve web content.
As mentioned before, if you are calling any external APIs on the server side, which means in your PHP code (or on your server-side rendered JavaScript app), you will have to leave those rules in to avoid connectivity problems. Removing them will also prevent all packages that pull external data to your machine from working, most importantly apt/apt-get
and curl
.
Fortunately, at least for administrative purposes, there are solutions. The ones that I personally know of are:
- Port Knocking — this relies on sending a sequence of “knock” requests to the server, after which the server will: open up a port only for the IP address making the knocks (SSH) and set up rules that allow client HTTP/HTTPS requests. You can learn more from the Practical Guide to Port Knocking article available on cs.fyi.
- Scripting — make a script that appends allow client HTTP/HTTPS rules after you log in, and deletes them after you’re done with your tasks. This can be automated, but can also be done manually.
In all cases there will be some overhead.
Use this code snippet to delete the client rules:
sudo iptables -D INPUT -p tcp --sport 80 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -D INPUT -p tcp --sport 443 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -D OUTPUT -p tcp --dport 80 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -D OUTPUT -p tcp --dport 443 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
And this snippet to put them back in when you need them:
sudo iptables -A INPUT -p tcp --sport 80 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A INPUT -p tcp --sport 443 -m conntrack --ctstate ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --dport 80 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT; \
sudo iptables -A OUTPUT -p tcp --dport 443 -m conntrack --ctstate NEW,ESTABLISHED -j ACCEPT
Congratulations! You have improved your server’s security.
- You’ve hardened the system by implementing a robust firewall.
- You’ve gained fundamental knowledge of how firewalls work.
- You’ve also gained insights into both general and specific security concerns (like tunneling, exfiltration, rate-limitting)
Spotted a mistake? Let me know!
Thank you for reading. Hopefully this guide provided some value :)