Estimated reading time: 17 minutes
Table of contents
The Fortress is Built, But The Gate is Unguarded
The server is built, the backups are resilient, and the watchdog is on duty. My Ultimate LEMP Server has become a formidable fortress. The walls (nftables) are strong, the watchtower (Monit) is manned, and the escape plan (Restic) is in place. But then I checked my Nginx logs. What I saw was a relentless, automated barrage from bots across the globe. They were scanning for WordPress vulnerabilities, testing for SQL injection, and attempting to exploit plugins.
This made me realize a critical truth. My current firewall is a solid wall, but it’s blind to the content of the traffic hitting my websites. It’s a fortress with no guards checking who’s coming through the main gate. For this reason, I knew I needed a final, crucial layer of security: an intelligent defense system that could analyze web traffic and neutralize threats before they ever reached my applications.
My Checklist for an Intelligent Defense System
As is my custom, I defined a clear set of requirements for this new security layer. A proper defense shouldn’t just be a static list of rules; it needs to be adaptive and, above all, efficient.
- Lightweight & Performant: Most importantly, it must not slow down the websites it’s protecting.
- Intelligent & Adaptive: It must be able to identify and block emerging threats, not just known, years-old attacks.
- Automated Response: It must automatically block malicious IPs at the firewall level, without any manual intervention from me.
- Community-Powered: This was the key. The best defense is a shared one. For instance, the ideal solution should leverage threat intelligence from thousands of other servers to protect itself from new attacks the moment they are detected anywhere in the world.
The Solution: CrowdSec, a Modern WAF and IPS
After some research, I landed on CrowdSec. It’s a modern, open-source, and lightweight security engine that perfectly fits my philosophy. I chose it because it’s not just a web application firewall (WAF); it’s a collaborative Intrusion Prevention System (IPS). In short, it’s like a server’s neighborhood watch. When one server gets attacked, it reports the attacker’s IP to a central blocklist. Subsequently, the system shares that blocklist with the entire network, and everyone instantly gets protected from that attacker. This pragmatic, community-powered approach was exactly what I was looking for.

Step 1: Ensuring CrowdSec Sees the Real Attacker
Before I could install the bouncers, my first step was to tackle a prerequisite for any clean, modular Nginx setup. I needed to confirm that my main nginx.conf file was configured to include separate configuration files. This is a standard and highly recommended practice that allows me to add snippets without touching the core file.
# Check for the include directive
grep 'http.d' /etc/nginx/nginx.conf
The output, include /etc/nginx/http.d/*.conf;, confirmed my setup was correct. With that prerequisite handled, I could move on to the most critical part of my configuration.
Because my server often sits behind reverse proxies (Cloudflare and Nginx Proxy Manager), I had to solve a dangerous blind spot. By default, a reverse proxy acts like a mask, hiding the true source of incoming traffic. Consequently, my Nginx logs would only record the IP address of my proxy, not the actual visitor. If a bot from a malicious IP were to start scanning my site, CrowdSec would see the attack as coming from my trusted proxy. It would then do its job and block the proxy’s IP, instantly taking my entire site offline.
For this reason, preventing this self-inflicted outage is the top priority. The following scenarios detail the mandatory adjustments required for your specific setup.
A Foundational Security Step: Protecting Your Origin Server
Any setup that uses Cloudflare relies on one crucial assumption: that all traffic reaches your server exclusively through the Cloudflare network. However, if an attacker discovers your server’s real IP address, they can bypass Cloudflare entirely and send malicious requests directly to your front door. This would render both Cloudflare’s protection and my real_ip configuration useless.
For this reason, locking down the origin server is a non-negotiable step. The goal is to configure the firewall to only accept inbound web traffic (on ports 80 and 443) from Cloudflare’s official IP ranges, dropping all other connections. Ideally you would configure a firewall before it reaches your connection, if your Internet Service Provider would offer you that option, you could even do it before it reaches your router, but most of them do not offer that kind of feature, so you would need to configure a firewall on your router, however, if your router also doesn’t give you that option, then the next best thing is to configure the firewall on the Proxmox host before the traffic hits your VM or container.
Here is the end-to-end process of this configuration using only the Proxmox host shell.
While this can be done in the web GUI, I find that for tasks like this, the Proxmox command line is faster, more precise, and far more repeatable.
Step 1: Automating the Cloudflare IP List
First, I needed a way to automatically maintain a list of Cloudflare’s ever-changing IP addresses. I decided to manage this with a simple script and a Proxmox IPSet.
I logged into my Proxmox host shell and installed the necessary tools.
# Install curl and jq
sudo apt update
sudo apt install -y curl jq
Next, I downloaded the updater script directly from my GitHub repository into the correct location and made it executable.
# Download and prepare the updater script
sudo curl -o /usr/local/bin/update-proxmox-cloudflare-ips.sh \
https://raw.githubusercontent.com/ramonvanraaij/Scripts/main/linux/Proxmox/update-proxmox-cloudflare-ips.sh
sudo chmod +x /usr/local/bin/update-proxmox-cloudflare-ips.sh
With the script ready, I ran it for the first time to create the cloudflare_ips IPSet. Then, to make it a true “set and forget” solution, I linked the script into the daily cron directory.
# Run the script manually for the first time
sudo /usr/local/bin/update-proxmox-cloudflare-ips.sh
# Automate daily updates with a cron job
sudo ln -s /usr/local/bin/update-proxmox-cloudflare-ips.sh /etc/cron.daily/update-proxmox-cloudflare-ips
Now, my Proxmox host will always have an up-to-date list of trusted Cloudflare IP addresses.
Step 2: Defining My Trusted Networks with pvesh
With the Cloudflare list being managed automatically, I used the pvesh tool to configure the rest of my firewall rules.
First, I enabled the firewall for the entire cluster.
# Enable the Proxmox firewall
sudo pvesh set /cluster/firewall/options --enable 1
Next, I created a second IPSet named local to hold all of my trusted, local, and public IP addresses. This is a clean way to manage multiple trusted sources in a single group.
# Create the 'local' IPSet
sudo pvesh create /cluster/firewall/ipset --name local --comment "My local and public IPs"
Then, I populated this local IPSet with all the network ranges I want to grant administrative access from.
# Add trusted networks to the 'local' IPSet
sudo pvesh create /cluster/firewall/ipset/local --cidr <YOUR_LAN_IPV4>/24 --comment "Local IPv4 network"
sudo pvesh create /cluster/firewall/ipset/local --cidr <YOUR_PUBLIC_IPV4>/32 --comment "Router public IPv4"
# Add any other trusted public or local IPv6/IPv4 addresses as needed:
sudo pvesh create /cluster/firewall/ipset/local --cidr fe80::/10 --comment "Link-local IPv6 network"
sudo pvesh create /cluster/firewall/ipset/local --cidr <YOUR_LOCAL_IPV6_PREFIX>/64 --comment "Local IPv6 network"
sudo pvesh create /cluster/firewall/ipset/local --cidr <YOUR_PUBLIC_IPV6_PREFIX>/64 --comment "Public IPv6 network"
Remember to replace <YOUR_LAN_IPV4>, <YOUR_PUBLIC_IPV4> etc. etc. with your actual IP addresses/ranges.
Step 3: Building the “Web Server” Security Group
With my IPSets ready, the next logical step was to create a reusable template of firewall rules. For this, I used a Proxmox Security Group. I created a group named webserver_inbound and added two rules to it.
# Create the Security Group
sudo pvesh create /cluster/firewall/groups --group webserver_inbound --comment "Allow inbound web and admin traffic"
# Rule 1: Allow web traffic from Cloudflare IPs
sudo pvesh create /cluster/firewall/groups/webserver_inbound --type in --action ACCEPT --source '+cloudflare_ips' --dest '+local' --dport 80,443 --proto tcp --enable 1 --comment "Allow Cloudflare IPs"
# Rule 2: Allow web and admin traffic from my trusted networks
sudo pvesh create /cluster/firewall/groups/webserver_inbound --type in --action ACCEPT --source '+local' --dest '+local' --dport 22,80,443,2812 --proto tcp --enable 1 --comment "Allow my networks for web and admin access"
Step 4: Applying the Shield to My LEMP Container
The final step was to apply this new, powerful Security Group to my LEMP container (which has the VMID 101 on my system).
First, I enabled the firewall specifically for that container.
# Enable the firewall for the target container
sudo pvesh set /nodes/pve/lxc/101/firewall/options -enable true
Then, I added a single rule to the container’s firewall to apply my webserver_inbound Security Group. Using pos 0 ensures it’s the first rule evaluated.
# Apply the security group to the container
sudo pvesh create /nodes/pve/lxc/101/firewall/rules --type group --action webserver_inbound --enable 1 --pos 0 --comment "Webserver Inbound Rules"
To ensure all my changes were loaded correctly, I performed a final restart of the Proxmox firewall service.
# Restart the firewall service
sudo pve-firewall restart
With these steps, my origin server is now properly shielded. It will only accept web traffic that comes through the Cloudflare network, and all administrative access is restricted to my trusted IPs.
Scenario 1: LEMP Server is Directly Connected to the Internet
If you have no reverse proxy and your LEMP server is connected directly to the internet, Nginx will see the real visitor IP by default. No changes are needed. You can safely skip ahead to Step 2.
Scenario 2: Cloudflare Directly in Front of LEMP Server
In this setup (Visitor → Cloudflare → LEMP), my server’s Nginx instance needs to be told two things:
- Trust connections coming from any of Cloudflare’s IP addresses.
- The real visitor’s IP is located in the
CF-Connecting-IPheader that Cloudflare adds to the request.
Since Cloudflare’s IP ranges can change, the best practice is to automate updating the list of trusted IPs.
1. Automate Cloudflare IP List Updates
I use a simple script that automatically fetches the latest IP ranges from Cloudflare, places them in a dedicated config file, and reloads Nginx. This turns a tedious manual task into a “set and forget” solution.
# Download the updater script
sudo curl -o /usr/local/bin/update-cloudflare-ips.sh \
https://raw.githubusercontent.com/ramonvanraaij/Scripts/refs/heads/main/linux/nginx/update-cloudflare-ips.sh
# Make the script executable
sudo chmod +x /usr/local/bin/update-cloudflare-ips.sh
# Run the script once to create the initial IP list
sudo /usr/local/bin/update-cloudflare-ips.sh
# Set up a weekly cron job to keep the list updated
sudo ln -s /usr/local/bin/update-cloudflare-ips.sh /etc/periodic/weekly/update-cloudflare-ips
This script creates and maintains the file /etc/nginx/http.d/cloudflare-ips.conf, which tells Nginx to trust Cloudflare’s IPs.
2. Configure Nginx to Use the Cloudflare Header
Next, I created a small, separate configuration file to tell Nginx which header contains the real IP address.
# Create a new config file to specify the real IP header
sudo tee /etc/nginx/http.d/realip-header.conf > /dev/null <<'EOF'
# Tell Nginx to find the real IP in the Cloudflare header
real_ip_header CF-Connecting-IP;
EOF
Finally, I reloaded Nginx to apply the changes.
# Test Nginx configuration and reload the service
sudo nginx -t && sudo service nginx reload
With both files in place, my LEMP server now correctly logs the visitor’s IP.
Scenario 3: NGINX Proxy Manager (NPM) Only
In this setup (Visitor → NPM → LEMP), my LEMP server only needs to trust one IP: the IP of my NGINX Proxy Manager.
1. Configure NGINX Proxy Manager
In the NPM web UI, go to your Proxy Host’s settings and click on the “Advanced” tab. Add the following snippet to ensure it passes the visitor’s IP in the standard X-Forwarded-For header.
# In Nginx Proxy Manager's "Advanced" tab for the host:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
2. Configure the Backend LEMP Server
On my LEMP server, I created a simple configuration file to trust NPM and read the standard header.
# Create a new config file for the proxy IP
sudo tee /etc/nginx/http.d/proxy-ips.conf > /dev/null <<'EOF'
# Trust requests from my Nginx Proxy Manager
set_real_ip_from <YOUR_NPM_IP>;
# Use the standard header that NPM is sending
real_ip_header X-Forwarded-For;
real_ip_recursive on;
EOF
Remember to replace <YOUR_NPM_IP> with the actual local IP address of your NPM instance (e.g., 192.168.0.50).
After saving the file, I reloaded Nginx.
# Test Nginx configuration and reload the service
sudo nginx -t && sudo service nginx reload
Scenario 4: The Chained Setup (Cloudflare → NPM → LEMP)
This is my setup, and it’s surprisingly simple to configure correctly. The key insight is that my backend LEMP server does not need to know about Cloudflare. It only needs to trust NPM. The CF-Connecting-IP header is added by Cloudflare and passed straight through NPM to the backend.
1. Configure NGINX Proxy Manager
For this specific goal, you can leave the “Advanced” tab in NPM blank. NPM will pass the CF-Connecting-IP header through by default, which is all we need. No custom Nginx configuration is required in NPM.
2. Configure the Backend LEMP Server
This is where the magic happens. I configure my LEMP server to trust requests from NPM, but tell it to get the real IP from the Cloudflare header.
# Create the proxy configuration file on the LEMP server
sudo tee /etc/nginx/http.d/proxy-ips.conf > /dev/null <<'EOF'
# --- The ONLY IP we need to trust is our proxy ---
set_real_ip_from <YOUR_NPM_IP>;
# --- The header containing the REAL visitor IP from Cloudflare ---
real_ip_header CF-Connecting-IP;
EOF
Again, replace <YOUR_NPM_IP> with the local IP address of your NPM instance.
After creating that file, a final reload of Nginx ties everything together.
# Test Nginx configuration and reload the service
sudo nginx -t && sudo service nginx reload
With the correct configuration for my specific scenario in place, my Nginx logs now show the true source of all traffic, and my environment is fully prepared for the CrowdSec installation.
Step 2: Installing the CrowdSec Agent and Bouncers
Update: I’ve updated sections 1 and 2 on Oct. 14, 2025 as these instructions were not working anymore, I needed to add the edge main repo to fix dependency issues and explicitly install the r2 library from the edge community repo. Also needed to update readline to the latest version.
With the Nginx environment prepared, I could now install the CrowdSec components.
1. Enable the Edge Repositories
First, I needed to add Alpine’s edge, “main”, “community” and “testing” repositories. This was a necessary step, as the latest CrowdSec agent, firewall bouncer and its dependencies for Alpine Linux reside here. As a rule, I’m cautious about using edge repositories, as they can contain unstable software. However, in this case, it is needed to get the latest stable versions from CrowdSec and the firewall bouncer.
# Add the edge main, community and testing repository
echo "https://dl-cdn.alpinelinux.org/alpine/edge/main" | sudo tee -a /etc/apk/repositories
echo "@edge-testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | sudo tee -a /etc/apk/repositories
echo "@edge-community https://dl-cdn.alpinelinux.org/alpine/edge/community" | sudo tee -a /etc/apk/repositories
2. Install the Agent and Firewall Bouncer
With the repository added, I installed the agent and the nftables firewall bouncer and tell the cs-firewall-bouncer service that it needs the net and crowdsec services.
# Update package lists and update readline
sudo apk update && sudo apk upgrade readline
# Install CrowdSec, the re2 library
sudo apk add crowdsec@testing re2@community
# Install the CrowdSec firewall bouncer
sudo apk add cs-firewall-bouncer@community
# Make sure the cs-firewall-bouncer depends on the net and crowdsec services
sudo sed -i '/after firewall/i\ \tneed crowdsec\
\ \tneed net' /etc/init.d/cs-firewall-bouncer
3. Manually Install the Nginx Bouncer
As of this writing, the Nginx bouncer is not available in the Alpine repository and its installer is incompatible. After significant debugging, I found the most reliable method is to bypass the faulty installer (well, “faulty” is the wrong word, it just doesn’t support Alpine Linux out of the box) and set up the bouncer with a series of manual, deliberate steps.
First, I installed all the required dependencies in one go using apk. This includes the Lua libraries that the bouncer needs, which is much cleaner than building them from source.
# Install dependencies for the Nginx bouncer
# This adds the database drivers, LuaRocks, and build tools.
# Install system packages from APK
sudo apk add git tar unzip nginx-mod-http-lua lua5.1 gettext \
lua5.1-resty-http lua5.1-cjson lua5.1-sql-mysql lua5.1-sql-sqlite3 \
luarocks5.1 lua5.1-dev lua5.1-filesystem lua-resty-string \
lua-resty-openssl
# Install the Lua logging library using the version-specific luarocks command
sudo luarocks-5.1 install lua-log
Next, I created a setup directory and cloned the two required repositories. The cs-nginx-bouncer repository provides the Nginx-specific configuration, while the lua-cs-bouncer contains the core Lua logic and templates shared by all Lua-based bouncers.
# Create a new setup directory and navigate into it
mkdir -p ~/crowdsec-setup && cd ~/crowdsec-setup
# 1. Clone the Nginx bouncer (for its Nginx config)
git clone https://github.com/crowdsecurity/cs-nginx-bouncer.git
# 2. Clone the unified Lua bouncer (for the code and templates)
git clone https://github.com/crowdsecurity/lua-cs-bouncer.git
With the source code downloaded, I manually placed the files from both repositories into their final destinations.
# Create the library path expected by the Nginx config
sudo mkdir -p /usr/local/lua/crowdsec/
# Create the path for HTML templates
sudo mkdir -p /var/lib/crowdsec/lua/templates/
# From cs-nginx-bouncer: Copy the Nginx configuration file
sudo cp cs-nginx-bouncer/nginx/crowdsec_nginx.conf /etc/nginx/http.d/crowdsec_nginx.conf
# From lua-cs-bouncer: Copy the Lua library files
sudo cp -r lua-cs-bouncer/lib/* /usr/local/lua/crowdsec/
# From lua-cs-bouncer: Copy the HTML template files
sudo cp -r lua-cs-bouncer/templates/* /var/lib/crowdsec/lua/templates/
Now, I generated a unique API key for the bouncer and created its configuration file using the template from the unified Lua library.
# Navigate into the setup directory
cd ~/crowdsec-setup
# Generate a unique API key
BOUNCER_NAME="cs-nginx-bouncer-$(tr -dc A-Za-z0-9 </dev/urandom | head -c 8)"
API_KEY=$(sudo cscli bouncers add "$BOUNCER_NAME" -o raw)
# Define paths and variables
LAPI_URL="http://127.0.0.1:8080"
CONFIG_TEMPLATE="lua-cs-bouncer/config_example.conf"
BOUNCER_CONFIG="/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf"
# Create the bouncer's configuration file using the template
API_KEY=${API_KEY} CROWDSEC_LAPI_URL=${LAPI_URL} envsubst '$API_KEY,$CROWDSEC_LAPI_URL' < ${CONFIG_TEMPLATE} | sudo tee ${BOUNCER_CONFIG} > /dev/null
Finally, I tested my Nginx configuration, restarted the service to activate the bouncer, and then verified that it was online and communicating with the CrowdSec Local API.
# Test the Nginx configuration for syntax errors
sudo nginx -t
# If successful, restart Nginx to apply all changes
sudo service nginx restart
# Check the bouncer's status. It should show a recent "Last API pull" time.
sudo cscli bouncers list

I can ignore the WARNING about the CAPI credentials, I will fix that in a bit.
This manual process is a robust and reliable way to install the bouncer on Alpine Linux. I then cleaned up the temporary setup directory.
# Clean up the installation files
cd ~ && rm -rf ~/crowdsec-setup
4. Enable the Services
Finally, with everything installed, I enabled all the services to ensure they start on boot.
# Enable the services
sudo rc-update add crowdsec default
# The manually installed Nginx bouncer is part of Nginx and does not require its own service
sudo rc-update add cs-firewall-bouncer default
Step 3: Getting CrowdSec Fully Operational
On a minimal Alpine Linux system, the CrowdSec package requires several manual steps after installation to become fully operational.
1. Initialize the Hub, Register the Agent, and Install Collections
First, the agent needs its core components. While troubleshooting, the log files revealed it was crashing because it couldn’t find its Hub index and couldn’t authenticate with its own API. The fix is to first update the hub, then register the agent as a “machine,” and finally install the recommended collections.
# Download the hub index
sudo cscli hub update
# Register the agent to the local API. Use --force to overwrite any stale credentials.
sudo cscli machines add --auto --force
# Install the recommended collections for a Linux server, Nginx and WordPress.
sudo cscli collections install crowdsecurity/linux crowdsecurity/nginx \
crowdsecurity/wordpress crowdsecurity/appsec-wordpress
2. Configure the Firewall Bouncer
My next step was to ensure the firewall bouncer was configured to work correctly with my nftables.nft file. By default, the bouncer tries to create its own tables. The correct approach is to switch it to “set-only” mode, which tells it to only manage the contents of the blocklist sets I’ve defined. I also added a line to configure the cs-firewall-bouncer to retry its connection to the LAPI, instead of crashing on the first failure.
I programmatically updated the bouncer’s configuration with the following command:
# Update the firewall bouncer config to use my nftables sets
sudo sed -i \
-e 's/mode: iptables/mode: nftables/' \
-e 's/blacklists_ipv4: crowdsec-blacklists/blacklists_ipv4: crowdsec-blacklist-ipv4/' \
-e 's/blacklists_ipv6: crowdsec6-blacklists/blacklists_ipv6: crowdsec-blacklist-ipv6/' \
-e '/^\s*ipv4:/,/^\s*ipv6:/s/set-only: false/set-only: true/' \
-e '/^\s*ipv4:/,/^\s*ipv6:/s/table: crowdsec/table: filter/' \
-e '/^\s*ipv4:/,/^\s*ipv6:/s/chain: crowdsec-chain/set: crowdsec-blacklist-ipv4/' \
-e '/^\s*ipv6:/,/^\s*nftables_hooks:/s/set-only: false/set-only: true/' \
-e '/^\s*ipv6:/,/^\s*nftables_hooks:/s/table: crowdsec6/table: filter/' \
-e '/^\s*ipv6:/,/^\s*nftables_hooks:/s/chain: crowdsec6-chain/set: crowdsec-blacklist-ipv6/' \
/etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
# Retry connecting to the LAPI, instead of crashing on the first failure.
echo -e "\n#Retry connecting to the LAPI, instead of crashing on the first failure.\nretry_initial_connect: true" | sudo tee -a /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
Next, I generated an API key for the firewall bouncer so it could communicate with the agent.
# Generate an API key for the firewall bouncer
API_KEY=$(sudo cscli bouncers add cs-firewall-bouncer -o raw)
I inserted this key into the bouncer’s configuration file.
# Add the API key to the firewall bouncer's config file
sudo sed -i "s/api_key:.*/api_key: ${API_KEY}/" /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
3. Configure NGINX Log Acquisition
Next, I configured the agent to monitor my Nginx logs.
# Create/overwrite the acquisition file to monitor all site logs
sudo tee /etc/crowdsec/acquis.yaml > /dev/null <<'EOF'
# This monitors all individual site logs
filenames:
- /var/log/nginx/*/access.log
- /var/log/nginx/access.log
labels:
type: nginx
EOF
4. Enrolling in the Community Blocklist
The final and most important step is to connect my agent to the CrowdSec Central API. This is what unlocks the “crowd-sourced” part of the shield. Without this, my agent runs in standalone mode, only banning IPs it detects itself. By enrolling, I can download the community blocklist containing thousands of malicious IPs identified by the entire network.
I started the one-time enrollment process with a simple command:
# Register the agent with the CrowdSec Central API
sudo cscli capi register
5. Start the CrowdSec Services
With all configurations in place, I could now start the CrowdSec agent and the firewall bouncer. I’ll restart, just in case.
# Restart the CrowdSec services
sudo service crowdsec restart
sudo service cs-firewall-bouncer restart
After running this, a quick check with sudo service crowdsec status and sudo service cs-firewall-bouncer status should now show started.
My agent is now connected to the community network. Let’s visit my site and load a few pages and check if both bouncers have done an API pull.
# List all bouncers within the database
sudo cscli bouncers list

Step 4: Integrating the Firewall Bouncer with Nftables
This is a crucial final step. The cs-firewall-bouncer is running, but my main firewall ruleset doesn’t yet know about it. I needed to update my /etc/nftables.nft file to create a unified ruleset that leverages both CrowdSec and SSHGuard for a complete, layered defense.
I replaced my existing firewall configuration with this new version. It defines dedicated blocklist sets for both CrowdSec and SSHGuard and adds rules to the input chain to drop traffic from any IP in those sets.
# Overwrite the nftables configuration with the correct, unified version
sudo tee /etc/nftables.nft > /dev/null <<'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
# Sets for CrowdSec (managed by cs-firewall-bouncer)
set crowdsec-blacklist-ipv4 {
type ipv4_addr
flags timeout
}
set crowdsec-blacklist-ipv6 {
type ipv6_addr
flags timeout
}
# Sets for SSHGuard (managed by sshguard)
set sshguard_blacklist_ipv4 {
type ipv4_addr
flags timeout
}
set sshguard_blacklist_ipv6 {
type ipv6_addr
flags timeout
}
chain input {
type filter hook input priority 0;
policy drop;
# accept traffic from loopback
iif lo accept
# drop invalid packets
ct state invalid drop
# accept established/related connections
ct state established,related accept
# --- Drop traffic from blacklists ---
# CrowdSec
ip saddr @crowdsec-blacklist-ipv4 drop
ip6 saddr @crowdsec-blacklist-ipv6 drop
# SSHGuard
ip saddr @sshguard_blacklist_ipv4 drop
ip6 saddr @sshguard_blacklist_ipv6 drop
# accept ping
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
# accept ssh, http, https, monit
tcp dport { 22, 80, 443, 2812 } accept
}
chain forward {
type filter hook forward priority 0;
policy drop;
}
chain output {
type filter hook output priority 0;
policy accept;
}
}
EOF
After updating the file, I reloaded the firewall to apply the new ruleset.
# Reload nftables to activate the new rules
sudo service nftables reload
# Restart the Crowdsec firewall bouncer
sudo service cs-firewall-bouncer restart
Now, my firewall is fully integrated with both CrowdSec and SSHGuard. After restarting, the bouncer will populate the crowdsec-blacklist-ipv4 and crowdsec-blacklist-ipv6 set that is used by the firewall rules.
Step 5: Observing the Crowd-Sourced Shield in Action
With everything installed and configured, it was time to see the system work. CrowdSec comes with a powerful command-line tool, cscli, that gives me a complete overview of its activity.
Now that the service was running correctly, I could check the status of my CrowdSec installation.
# Check the status of my CrowdSec installation
sudo cscli metrics

More importantly, I could see a real-time list of all the IPs that CrowdSec had blocked, why it had blocked them, and where they came from.
# See a list of all active decisions (blocks)
sudo cscli decisions list
This will probably show No active decisions, as nothing hapened yet. The real proof, however, was seeing the nftables blocklists being automatically populated with the “crowd-sourced” IPs. I can view the contents of the blocklist sets created by the firewall bouncer for both IPv4 and IPv6.
# View the contents of the crowdsec-blacklist-ipv4 set in nftables
sudo nft list set inet filter crowdsec-blacklist-ipv4 | head -n 20
# View the contents of the crowdsec-blacklist-ipv6 set in nftables
sudo nft list set inet filter crowdsec-blacklist-ipv6 | head -n 20

Bonus: Completing the Fortress – The Full Story
This guide to setting up a web application firewall & intrusion prevention system is the final piece in a series of posts where I’ve documented the creation of my entire server stack from the ground up. Each article is a logical building block, and together they form a complete blueprint for a secure, resilient, and efficient self-hosted environment.
A fortress is only as strong as its weakest link, and a new component is a potential blind spot until you fully integrate it into the existing infrastructure. For this reason, the final, finishing touch is to ensure the watchdog is monitoring the new CrowdSec services and that the automated backups are including their configurations.
If you’d like to see the full journey, here are the other chapters in the story:
My Ultimate LEMP Server: A Migration Story
My Ultimate LEMP Server: A Migration Story: This is the foundation. I walk through building the high-performance LEMP server that we are protecting in this post, tuned for speed and security on Alpine Linux.
Defying Murphy’s Law: Automated & Secure Off-Site Backups
Defying Murphy’s Law: Automated & Secure Off-Site Backups: A fortress is useless if you can’t rebuild it after a disaster. This post details the resilient, automated, and encrypted off-site backup strategy I implemented with Restic.
Bark Before it Bites: Lightweight Server Monitoring and Alerting
Bark Before it Bites: Lightweight Server Monitoring and Alerting: A server’s failure is often a silent one. In this guide, I set up a “watchdog” using Monit and Uptime Robot to ensure my server is always self-healing and that I’m alerted the moment something goes wrong.
Adding the New Services to the Watchdog (Monit)
To ensure the new security layer is always running, the first step is to add the CrowdSec agent and the firewall bouncer to the Monit configuration. This will automatically restart them if they ever crash.
I created a new file with the following rules:
# Create a new Monit configuration file for CrowdSec
sudo tee /etc/monit.d/crowdsec.conf > /dev/null <<'EOF'
# Monitor the CrowdSec agent
check process crowdsec with pidfile /var/run/crowdsec.pid
start program = "/sbin/service crowdsec start"
stop program = "/sbin/service crowdsec stop"
if 5 restarts within 5 cycles then timeout
# Monitor the CrowdSec Firewall Bouncer
check process cs-firewall-bouncer with pidfile /var/run/cs-firewall-bouncer.pid
restart program = "/sbin/service cs-firewall-bouncer restart"
if not exist for 2 cycles then restart
if 5 restarts within 5 cycles then timeout
EOF
After creating the file, a quick reload is all that’s needed to apply the new rules.
sudo monit reload
Adding CrowdSec’s Configuration to the Backup Plan (Restic)
After creating the file, you only need a quick reload to apply the new rules. I updated my main Restic backup script (/usr/local/bin/backup.sh) to include the entire /etc/crowdsec directory.
The updated PATHS_TO_BACKUP array in my script now looks like this:
# === Paths to Back Up ===
PATHS_TO_BACKUP=(
"/home/site1/site1.com"
"/home/site2/site2.com"
"/etc/nginx"
"/etc/php83"
"/etc/php84"
"/etc/ssh/sshd_config"
"/etc/nftables.nft"
"/etc/logrotate.d"
"/usr/local/bin"
"/etc/monitrc" # <-- Monit main config
"/etc/monit.d" # <-- Monit system and lemp configs
"/etc/crowdsec" # <--\
"/usr/local/lua/crowdsec" # <--- The new, essential additions
"/var/lib/crowdsec/data" # <--/
)
With these final steps, I have now fully monitored and backed up the new security layer, making the fortress truly complete.
Advanced Strategy: Where Should I Install CrowdSec?
A common architectural question is whether to install a web application firewall & intrusion prevention system like CrowdSec on the backend application server (as I’ve done here) or on the edge proxy, like Nginx Proxy Manager. Installing it on the edge is more efficient, as it blocks malicious traffic before it ever reaches the applications.
However, for this guide, I chose to install it on the LEMP server itself. This provides the ultimate last line of defense, allowing CrowdSec to analyze application-specific logs (like WordPress authentication failures) that a proxy would never see.
For the ultimate “Defense in Depth” posture, the professional solution is to install the CrowdSec agent on both the proxy and the backend server, and configure them to share a central database. This provides the best of both worlds, but for a single-server setup, protecting the application host directly is a robust strategy.
Final Thoughts: From a Fortress to an Intelligent Stronghold
This project was the final, crucial layer in my server’s security architecture. My LEMP server is no longer just a static fortress; it’s an intelligent stronghold with active sentries at the gate. These sentries don’t just work alone; they share information with a global network of lookouts, protecting my websites from threats they haven’t even seen yet. This proactive, community-powered defense completes the security posture of my server. By implementing a modern nginx web application firewall/intrusion prevention system, I have created a truly professional-grade, self-hosted environment that is ready for the modern web.
Buy me a coffee 🙂
If you found this post helpful, informative, or if it saved or made you some money, consider buying me a coffee. Your support means a lot and motivates me to keep writing.
You can do so via bunq.me (bunq, iDeal, Bankcontact and Credit- or Debit cards) or PayPal (PayPal and Credit- or Debit cards). Thank you!
Disclaimer
The blog posts, guides, and scripts provided on this website are for informational and educational purposes only. They are provided “as is” and without any warranty of any kind, either express or implied, including, but not limited to, the implied warranties of merchantability, fitness for a particular purpose, or non-infringement.
By using the information or scripts from this blog, you agree that I am not liable for any direct, indirect, incidental, consequential, or any other damages or losses arising from the use of or inability to use the information, scripts, or instructions contained herein. You assume full responsibility for any and all risks associated with the use of this content.
The blog posts, guides, and pages may contain referral/affiliate links. If you make a purchase through these links, I may receive a commission at no additional cost to you.