DDoS attacks (Distributed Denial of Service) overload a server with a massive volume of requests, making it unreachable for regular visitors. Effective protection is built in layers: network level (Cloudflare/CDN), system level (sysctl) and application level (Nginx) — no single layer on its own covers every attack scenario.
Layer 1: Cloudflare DDoS Protection (recommended)
Cloudflare absorbs DDoS traffic before it reaches your server, because all traffic passes through its network of points of presence:
- Turn on Proxy (the orange cloud) for the A and CNAME DNS records — without it Cloudflare only sees DNS lookups while the actual traffic goes straight to the server.
- Security → DDoS → enable HTTP DDoS Attack Protection.
- Security → WAF → enable Managed Rules.
- Security Level: High during an attack, Medium in normal operation.
Layer 2: Nginx connection limits (on top of UFW Firewall)
http {
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
limit_req_zone $binary_remote_addr zone=req_limit:10m rate=50r/s;
server {
limit_conn conn_limit 20; # Max 20 connections from one IP
limit_req zone=req_limit burst=100 nodelay;
limit_req_status 429;
# Timeouts against Slowloris
client_body_timeout 10;
client_header_timeout 10;
keepalive_timeout 5;
send_timeout 10;
}
}
Layer 3: System tuning (sysctl)
# /etc/sysctl.d/99-ddos-protect.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 2048
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 5
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 300
net.ipv4.conf.all.rp_filter = 1
sysctl --system
Reading attack signals in the logs
- 499 in the Nginx log — the client dropped the connection before getting a response; a mass spike of 499s points to a SYN flood or a saturated worker queue, not a slow application.
- upstream timed out (110: Connection timed out) — during an application-layer (L7) attack, PHP-FPM workers are busy processing forged requests and cannot keep up with real users.
- too many open files in dmesg or the Nginx log — the file descriptor limit is exhausted by thousands of simultaneous connections; raising
ulimit -nis a stopgap, not a fix for the attack itself. - Resource temporarily unavailable (11: Resource temporarily unavailable) on accept() — the queue of new connections is full, the server physically cannot accept new sockets faster than the attacker creates them.
Responding to an attack
netstat -tn | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn | head -20
ss -s
# Quickly block the attacking IP
iptables -I INPUT -s ATTACK_IP -j DROP
# Turn on "Under Attack" mode in Cloudflare
# Security, Settings, Security Level: I'm Under Attack
Hiding the IP behind Cloudflare: make sure the server's real IP is not exposed through DNS history, email headers or other channels. Cloudflare is only effective while the attacker does not know the real IP and cannot hit it directly, bypassing the protection.
Comparing the protection layers
| Layer | What it protects against | Where it is configured | Requires a server reboot |
|---|---|---|---|
| Cloudflare | Volumetric (L3/L4) and part of L7 attacks | Cloudflare dashboard | No |
| Nginx | L7: flooding requests to one URL | Virtual host config | No (reload) |
| sysctl | L4: SYN flood, socket exhaustion | OS kernel | No (sysctl --system) |
When this set of measures is not enough
- The attack hits the server's IP directly, bypassing Cloudflare — if the IP leaked earlier, DNS proxying will not save you; you need to change the IP and keep it secret, or move the server into a provider's protected network.
- The attack runs at hundreds of gigabits per second — the server's own uplink physically cannot carry that traffic even with perfect Nginx rules; only upstream filtering at the provider or CDN level solves this.
- The attack mimics real users through rotating residential proxies — a simple per-IP limit cannot tell them apart from real people; you need behavioral WAF rules and a captcha on suspicious sessions.
How to verify the protection is working
# Confirm requests really reach Cloudflare instead of going around it
dig +short example.com
# Expect an IP from Cloudflare's ranges, not your server's IP
# Check that the rate limit triggers on Nginx itself
for i in $(seq 1 60); do curl -o /dev/null -s -w "%{http_code}\n" https://example.com/; done | sort | uniq -c
# Confirm the sysctl parameters were applied
sysctl net.ipv4.tcp_syncookies
If DNS returns an IP from Cloudflare's range, part of the requests during a load test come back as 429, and sysctl shows the values you set, all three protection layers are working together.