Ermir Beqiraj
Backend architect. Systems, agents, infrastructure — from inside the work.
all writing

Check the first part on how to prepare and deploy an ASP.NET Core application on Ubuntu with Nginx and MySQL. This is the second part of the guide.

The current setup is an ASP.NET Core app already hosted and running on Ubuntu with nginx. This article focuses on the security side.

Block requests by IP

To stop nginx from serving requests on bare IP (no hostname):

sudo nano /etc/nginx/sites-available/default

Add the following as the first server directive:

server {
    listen  80;
    listen  443;
    server_name  "";
    return  444;
}

Check for syntax errors: sudo nginx -t, then reload: sudo systemctl restart nginx

Firewall

ufw is the default firewall configuration tool for Ubuntu.

Allow HTTP and the SSH port you configured in part 1 (example: 4743):

sudo ufw allow 4743
sudo ufw allow http

Enable ufw: sudo ufw enable

Check status: sudo ufw status verbose

Fail2ban

Fail2ban monitors server logs for failed authentication attempts and suspicious activity, and can take automated action based on configured rules.

If not already installed: sudo apt-get install fail2ban

Navigate to /etc/fail2ban. The two relevant files:

  • jail.conf — base config, may be overwritten on updates
  • jail.local — your actual configuration, always reads first

Open jail.local:

sudo nano /etc/fail2ban/jail.local

Key options explained:

  • ignoreip — IP addresses to never ban (e.g., your own IP)
  • bantime — how long in seconds to ban an IP (default: 600)
  • maxretry — failures allowed before a ban
  • findtime — time window in which maxretry failures trigger a ban

Here’s a working jail.local configuration:

[DEFAULT]
ignoreip = 127.0.0.1/8
bantime  = 1800
findtime  = 600
maxretry = 4

[ssh]
enabled  = true
port     = 4743
filter   = sshd
logpath  = /var/log/auth.log

[nginx-noproxy]
enabled  = true
port     = http,https
filter   = nginx-noproxy
logpath  = /var/log/nginx/access.log
maxretry = 2

[nginx-badbots]
enabled  = true
port     = http,https
filter   = nginx-badbots
logpath  = /var/log/nginx/access.log
maxretry = 2
action   = cloudflare-firewall

[nginx-noscript]
enabled  = true
port     = http,https
filter   = nginx-noscript
logpath  = /var/log/nginx/access.log
maxretry = 2
action   = cloudflare-firewall

[nginx-nohome]
enabled  = true
port     = http,https
filter   = nginx-nohome
logpath  = /var/log/nginx/access.log
maxretry = 2

[nginx-444]
enabled  = true
port     = http,https
filter   = nginx-444
logpath  = /var/log/nginx/access.log
maxretry = 2

Now create the filter definitions. Navigate to filter.d and create each file:

cd /etc/fail2ban/filter.d

nginx-noproxy.conf — blocks open proxy abuse:

[Definition]
failregex = ^<HOST> -.*GET http.*
ignoreregex =

nginx-badbots.conf — reuse the Apache badbots filter:

sudo cp apache-badbots.conf nginx-badbots.conf

nginx-noscript.conf — blocks script/exploit scanners:

[Definition]
failregex = ^<HOST> -.*GET.*(\.php|\.asp|\.exe|\.pl|\.cgi|\.scgi)
ignoreregex =

nginx-nohome.conf — blocks home-directory access attempts:

[Definition]
failregex = ^<HOST> -.*GET .*/~.*
ignoreregex =

nginx-444.conf — bans IPs that got a 444 (IP-based request blocked):

[Definition]
failregex = ^<HOST> -.*"(GET|POST|HEAD).*HTTP.*" 444
ignoreregex =

Restart fail2ban: sudo systemctl restart fail2ban

Check active jails: sudo fail2ban-client status

If you accidentally lock yourself out while testing: sudo fail2ban-client set <JAIL_NAME> unbanip <YOUR_IP>

Fail2ban and Cloudflare integration

Proxying through Cloudflare provides fast global caching and an easy way to automatically block offending IPs via API.

Get the real client IP from Cloudflare in nginx

Create a config file inside conf.d (nginx will pick it up automatically):

sudo nano /etc/nginx/conf.d/cloudflare-ips.conf
# From https://www.cloudflare.com/ips
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 104.16.0.0/12;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2c0f:f248::/32;
set_real_ip_from 2a06:98c0::/29;

real_ip_header CF-Connecting-IP;

Test and restart nginx: sudo nginx -t && sudo systemctl restart nginx

Bash script for Cloudflare Firewall API

(Credits: Antoine Aflalo)

Install jq (JSON processor for bash):

sudo apt-get install jq

Create the script:

sudo nano /usr/local/sbin/cloudflare-firewall
#!/bin/bash
# Create/remove an IP ban on CloudFlare via API
#
# usage: cloudflare-firewall <cfuser> <cftoken> <cfzoneid> <add|remove> <ip> [note]

add() {
    local IP="${1}"; shift
    local NOTE="$@"

    curl -g -X POST "https://api.cloudflare.com/client/v4/zones/${CF_ZONEID}/firewall/access_rules/rules" \
     -H "X-Auth-Email: $CF_USER" \
     -H "X-Auth-Key: $CF_TOKEN" \
     -H "Content-Type: application/json" \
     --data @- << EOF
{"mode":"challenge","configuration":{"target":"ip","value":"$IP"},"notes":"$NOTE"}
EOF
}

remove() {
    local IP="${1}"
    local RULE_ID=$(curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${CF_ZONEID}/firewall/access_rules/rules?configuration_target=ip&configuration_value=${IP}" \
     -H "X-Auth-Email: $CF_USER" \
     -H "X-Auth-Key: $CF_TOKEN" \
     -H "Content-Type: application/json" | jq ".result|.[]|.id")
     RULE_ID="${RULE_ID%\"}"
     RULE_ID="${RULE_ID#\"}"

     curl -X DELETE "https://api.cloudflare.com/client/v4/zones/${CF_ZONEID}/firewall/access_rules/rules/${RULE_ID}" \
     -H "X-Auth-Email: $CF_USER" \
     -H "X-Auth-Key: $CF_TOKEN" \
     -H "Content-Type: application/json" \
     --data '{"cascade":"basic"}'
}

CF_USER="$1"; shift
CF_TOKEN="$1"; shift
CF_ZONEID="$1"; shift
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(add|remove)$ ]]; then
  "$HANDLER" "$@"
fi

Mark it executable:

sudo chmod +x /usr/local/sbin/cloudflare-firewall

Fail2ban custom Cloudflare action

Get your Cloudflare email, global API key, and Zone ID from the Cloudflare dashboard (website → Overview tab).

Create the action file:

sudo nano /etc/fail2ban/action.d/cloudflare-firewall.conf
[Definition]
actionstart =
actionstop =
actioncheck =

actionban = /usr/local/sbin/cloudflare-firewall <cfuser> <cftoken> <cfzone> add <ip> "<name> after <failures> failures at <time>"
actionunban = /usr/local/sbin/cloudflare-firewall <cfuser> <cftoken> <cfzone> remove <ip>

[Init]
cftoken = YOUR_CLOUDFLARE_API_TOKEN_HERE
cfuser  = YOUR_EMAIL_FOR_CLOUDFLARE_HERE
cfzone  = YOUR_ZONE_ID_HERE
name    = fail2ban

Restart fail2ban: sudo systemctl restart fail2ban

Check status — if every step completed correctly, nginx-badbots and nginx-noscript jails should now have the cloudflare-firewall action active.


“You either have been hacked, or you don’t know it yet.”

Security is a never-ending process. It usually depends on how much comfort and performance you’re willing to sacrifice for it. Follow best practices, keep your tools updated, and hire a security expert if the stakes are high.

Ermir Beqiraj is a backend architect building AI-integrated infrastructure. This is his personal writing.