A distributed denial of service (DDoS) attack tries to exhaust a resource on your server: network bandwidth, the kernel's connection tables, or the CPU and memory of your application. No single server can absorb a large volumetric flood on its own, but many attacks that reach a host are small enough to be handled locally if the server is prepared. In this tutorial you will harden an Ubuntu 24.04 server with kernel settings that resist SYN floods, an nftables firewall that limits new connections and concurrent connections per source IP, and Nginx request limits, and you will learn the commands that tell you what kind of attack you are facing.
Understanding what the server can and cannot stop
Attacks fall into three broad groups, and each one is handled at a different layer:
| Type | Examples | Where it must be stopped |
|---|---|---|
| Volumetric | UDP floods, DNS or NTP amplification | Upstream, before traffic reaches your uplink |
| Protocol (L3/L4) | SYN floods, ACK floods, connection floods | Kernel and host firewall, if volume fits the link |
| Application (L7) | HTTP floods, slow requests, expensive endpoints | Web server and application |
If a flood saturates your network port, nothing you configure on the server helps, because the packets are dropped before your firewall sees them. That class of attack has to be filtered upstream by your provider's network. This guide covers the two lower rows: what you can control on the host.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS, such as a CubePath VPS, with a non-root user that has
sudoprivileges. - Console access to the server (for example the provider's web console) in case a firewall mistake cuts your SSH session.
- Nginx serving your site, for Step 4. The kernel and firewall steps apply to any service.
Step 1 - Tuning the kernel for SYN floods
In a SYN flood the attacker sends many TCP connection requests and never completes the handshake. Each half-open connection occupies a slot in the SYN backlog until it times out. SYN cookies let the kernel answer without storing state once the backlog is full, and a shorter retry count frees slots faster.
Create a sysctl file:
sudo nano /etc/sysctl.d/60-ddos-hardening.conf
# Answer with SYN cookies when the SYN backlog overflows
net.ipv4.tcp_syncookies = 1
# Larger queues for half-open and fully established connections
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
# Retransmit SYN-ACK 2 times instead of 5 (frees slots in about 7s instead of 63s)
net.ipv4.tcp_synack_retries = 2
# Larger queue for packets waiting to be processed by the kernel
net.core.netdev_max_backlog = 16384
# Room for more tracked connections (default depends on RAM)
net.netfilter.nf_conntrack_max = 262144
The nf_conntrack_max key only exists once the connection tracking module is loaded. Load the module at boot, before the sysctl settings are applied, so the key is always available:
echo nf_conntrack | sudo tee /etc/modules-load.d/conntrack.conf
sudo modprobe nf_conntrack
Apply all sysctl files now:
sudo sysctl --system
Confirm the values:
sysctl net.ipv4.tcp_syncookies net.ipv4.tcp_synack_retries net.netfilter.nf_conntrack_max
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 2
net.netfilter.nf_conntrack_max = 262144
somaxconn only raises the upper limit. Applications set their own listen backlog; Nginx, for example, uses 511 unless you add backlog= to its listen directive.
Each conntrack entry uses a few hundred bytes of kernel memory, so 262,144 entries cost well under 100 MB. On a server with less than 2 GB of RAM, use 131072 instead.
Step 2 - Building an nftables firewall with per-IP limits
A default-deny firewall that drops invalid packets and limits how fast each source can open connections stops most small L4 floods and brute-force traffic. This step uses nftables directly, which is the packet filtering framework behind the firewall tools on Ubuntu 24.04.
Install nftables:
sudo apt update
sudo apt install nftables
This ruleset replaces UFW. If UFW is active on the server, check it with sudo ufw status, copy any extra ports you had allowed into the rules below, and then turn UFW off with sudo ufw disable so that the two tools do not manage the same tables.
Open the main nftables configuration file:
sudo nano /etc/nftables.conf
Replace its contents with the following ruleset. It allows SSH, HTTP and HTTPS and drops everything else:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
# Sources opening new connections too fast (entries expire after 1 minute)
set newconn_v4 {
type ipv4_addr
flags dynamic, timeout
timeout 1m
}
set newconn_v6 {
type ipv6_addr
flags dynamic, timeout
timeout 1m
}
# Concurrent connections per source for the web ports
set webconn_v4 {
type ipv4_addr
flags dynamic
}
set webconn_v6 {
type ipv6_addr
flags dynamic
}
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid counter drop
# A new TCP connection must start with a bare SYN
tcp flags & (fin|syn|rst|ack) != syn ct state new counter drop
# ICMP: allow what the network needs, rate limit ping
icmp type echo-request limit rate 10/second burst 20 packets accept
icmp type echo-request drop
ip protocol icmp accept
icmpv6 type echo-request limit rate 10/second burst 20 packets accept
icmpv6 type echo-request drop
meta l4proto ipv6-icmp accept
# SSH: at most 10 new connections per minute per source
tcp dport 22 ct state new update @newconn_v4 { ip saddr limit rate over 10/minute burst 5 packets } counter drop
tcp dport 22 ct state new update @newconn_v6 { ip6 saddr limit rate over 10/minute burst 5 packets } counter drop
tcp dport 22 accept
# Web: at most 50 concurrent connections per source
tcp dport { 80, 443 } ct state new add @webconn_v4 { ip saddr ct count over 50 } counter drop
tcp dport { 80, 443 } ct state new add @webconn_v6 { ip6 saddr ct count over 50 } counter drop
tcp dport { 80, 443 } accept
}
chain forward {
type filter hook forward priority filter; policy drop;
}
chain output {
type filter hook output priority filter; policy accept;
}
}
How the important rules work:
ct state invalid dropdiscards packets that do not belong to any known connection, such as stray ACK or RST floods.- The
tcp flagsrule drops "new" connections that do not start with a SYN, which blocks several scan and flood techniques. update @newconn_v4 { ip saddr limit rate over ... }keeps a token bucket per source address inside the set. Only the offending source is dropped, not everyone.add @webconn_v4 { ip saddr ct count over 50 }counts the tracked connections of each source and drops new ones above the limit.
Pick limits that fit your traffic. A single office or mobile carrier NAT can put many users behind one IP, so 50 concurrent connections is a starting point, not a universal value. If you run SSH on another port, change 22 in all three lines.
Check the syntax without loading it:
sudo nft -c -f /etc/nftables.conf
The command prints nothing when the file is valid. Enable the service, which loads /etc/nftables.conf now and at every boot:
sudo systemctl enable --now nftables
If the service was already running, load the new file with sudo systemctl restart nftables. From a new terminal, open a second SSH session to confirm you can still log in before closing the first one.
Verify that the ruleset is loaded:
sudo nft list chain inet filter input | head -n 8
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid counter packets 0 bytes 0 drop
tcp flags & (fin | syn | rst | ack) != syn ct state new counter packets 0 bytes 0 drop
Step 3 - Testing the connection limits
Test the SSH limit from another machine that is not your main admin connection. This loop opens 20 TCP connections to port 22 in a row with nc:
for i in $(seq 1 20); do nc -z -w 2 your_server_ip 22 && echo "open $i" || echo "blocked $i"; done
open 1
open 2
open 3
open 4
open 5
open 6
blocked 7
blocked 8
...
The first connections use up the burst of 5 plus the regular rate. After that, new connections are dropped until the rate falls below 10 per minute. On the server, the counter on the SSH drop rule increases and the source appears in the set:
sudo nft list set inet filter newconn_v4
table inet filter {
set newconn_v4 {
type ipv4_addr
size 65535
flags dynamic,timeout
timeout 1m
elements = { 198.51.100.23 limit rate over 10/minute burst 5 packets expires 52s }
}
}
Step 4 - Limiting requests in Nginx
HTTP floods use completed TCP connections, so the firewall sees normal traffic. Nginx can limit the request rate and concurrent connections per client, and close slow clients that keep connections open without sending data (the "slowloris" technique).
Create a configuration file that Nginx includes inside the http block on Ubuntu:
sudo nano /etc/nginx/conf.d/ddos.conf
# 10 requests per second per client IP, tracked in 10 MB of shared memory
limit_req_zone $binary_remote_addr zone=req_perip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_perip:10m;
limit_req_status 429;
limit_conn_status 429;
# Close clients that send headers or body too slowly
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
keepalive_timeout 15s;
Then apply the limits in your site's server block, for example in /etc/nginx/sites-available/your_domain:
server {
# ... existing listen, server_name and ssl lines ...
limit_conn conn_perip 20;
location / {
limit_req zone=req_perip burst=40 nodelay;
# ... existing try_files or proxy_pass ...
}
}
burst=40 nodelay lets a browser load a page with many assets at once, while a client that keeps sending more than 10 requests per second on average receives 429 Too Many Requests. Static files are cheap to serve, so you can also apply limit_req only to expensive locations such as /api/, /search or login pages.
Test and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Verify the limit with a quick burst of requests from another machine:
for i in $(seq 1 80); do curl -s -o /dev/null -w '%{http_code}\n' https://your_domain/; done | sort | uniq -c
41 200
39 429
Rejected requests are logged in /var/log/nginx/error.log with the message limiting requests. You can ban clients that trigger it repeatedly with the nginx-limit-req jail of Fail2Ban.
Step 5 - Recognizing an attack
When the server slows down, a few commands tell you which layer is under pressure.
Get a summary of socket states:
ss -s
Count half-open connections. A few dozen is normal; thousands point to a SYN flood:
ss -Htn state syn-recv | wc -l
Check whether SYN cookies are being sent, which also means the SYN backlog overflowed:
nstat -az TcpExtSyncookiesSent
#kernel
TcpExtSyncookiesSent 18422 0.0
List the source IPs with the most established connections to HTTPS:
ss -Htn state established '( sport = :443 )' | awk '{print $4}' | sed 's/:[0-9]*$//' | sort | uniq -c | sort -rn | head
312 203.0.113.54
48 198.51.100.7
6 192.0.2.19
Compare the conntrack table usage against its maximum. When the count reaches the maximum, the kernel drops new connections and logs nf_conntrack: table full, dropping packet:
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
sudo dmesg | grep -i conntrack
Watch live bandwidth per interface to see whether the link itself is saturated:
sudo apt install nload
nload
Finally, see which firewall rules are dropping traffic by reading their counters:
sudo nft list chain inet filter input | grep counter
If an attack comes from a small number of addresses, block them explicitly by adding a rule at the top of the chain, for example sudo nft insert rule inet filter input ip saddr 203.0.113.54 drop. For hundreds of sources or a saturated link, contact your provider's support team, because the traffic needs to be filtered before it reaches the server.
Troubleshooting
You were locked out after loading the ruleset. Log in from the provider's web console and run sudo nft flush ruleset to remove all rules, then fix /etc/nftables.conf and load it again.
sysctl --system reports "cannot stat /proc/sys/net/netfilter/nf_conntrack_max". The nf_conntrack module was not loaded. Run sudo modprobe nf_conntrack and apply the settings again. The file in /etc/modules-load.d/ takes care of this at boot.
Legitimate users receive 429 errors. Many users share one IP behind corporate or mobile NAT. Raise rate and burst in Nginx, or the ct count limit in nftables, and apply strict limits only to expensive endpoints.
Docker containers lose connectivity. Docker manages its own nftables and iptables rules, and flush ruleset removes them. On Docker hosts, remove the flush ruleset line, use a table name of your own, and restart Docker after loading the firewall.
Conclusion
Your server now resists SYN floods with SYN cookies and shorter timeouts, limits new and concurrent connections per source in nftables, throttles abusive HTTP clients in Nginx, and you know the commands to identify which layer an attack targets. Next, consider putting a CDN or reverse proxy with caching in front of public sites, combining the Nginx limits with a Fail2Ban nginx-limit-req jail, and sending the conntrack and socket metrics to your monitoring system so you are alerted before users notice.
