Linux DDoS mitigation on the server itself is worth doing and easy to overrate. The kernel can answer SYN floods without holding state, keep floods out of its connection table, rate limit each source and drop what you do not serve, but only after a packet has crossed your network port. Once the port is full, none of it helps. Below: settings, a working nftables ruleset, counters to watch, and the ceiling's arithmetic. Types of DDoS attacks covers what each exhausts.
Values quoted come from one Ubuntu 24.04 box we run (kernel 6.8, 2 vCPU, 7 GB RAM); several scale with memory, so read yours with sysctl.
SYN floods: cookies, not bigger queues
A SYN flood sends handshake openings, usually forged, that never complete. RFC 4987 describes the aim: fill a listener's backlog of half-open connections so real clients are refused. The kernel doc calls tcp_max_syn_backlog (512 on our box) a per-listener limit, about 304 bytes per entry; somaxconn (4096) caps an application's own backlog, and nginx asks for 511 unless listen sets backlog=.
A queue loses at any size. With tcp_synack_retries = 5, the doc puts the final timeout for a half-open connection at 63 seconds, so 512 ÷ 63 ≈ 8 forged SYNs a second keep 512 slots full. Two retries give up after 1 + 2 + 4 = 7 seconds, and 512 ÷ 7 ≈ 73 a second still fill it: as RFC 4987 notes, attackers just send proportionally more.
Cookies take the queue out of the fight: the server encodes the connection's state in its SYN-ACK sequence number and rebuilds it only when a genuine ACK returns. At tcp_syncookies = 1, the default, Linux sends them only when a queue overflows. They are a fallback: a cookie has room for little beyond a few MSS values and can cost TCP extensions. Keep it at 1, leave tcp_abort_on_overflow = 0, which the doc warns can harm clients, and size queues for legitimate bursts in /etc/sysctl.d/60-syn.conf, applied with sysctl --system:
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
Set nginx's backlog=8192 to match; 8,192 × 304 bytes ≈ 2.5 MB.
Conntrack: the table that fills first
A stateful ruleset, anything matching ct state, records every flow it accepts. Per the kernel doc, nf_conntrack_max defaults to nf_conntrack_buckets, which defaults to memory ÷ 16,384, capped at 262,144. As 262,144 × 16,384 bytes is 4 GiB, any larger machine sits on the cap; ours reads 262,144 for both. A full table refuses new flows, real ones too.
Each new source address and port is an entry. UDP entries last 30 seconds, 120 once the kernel detects a stream; an established TCP entry whose client vanishes lasts 432,000 seconds, five days. So 262,144 ÷ 30 ≈ 8,700 new UDP flows a second keep the table full: at an assumed 100 bytes a packet, 8,738 × 100 × 8 ≈ 7 Mbps, which a 1 Gbps port barely notices.
First fix: raise the ceiling, max equal to buckets (entries are hashed once per direction, two per bucket when full). The net.netfilter keys exist only while the module is loaded, so list nf_conntrack in /etc/modules-load.d/conntrack.conf and add to /etc/sysctl.d/61-conntrack.conf:
net.netfilter.nf_conntrack_buckets = 524288
net.netfilter.nf_conntrack_max = 524288
Second fix: exempt stateless, high-rate traffic. Authoritative DNS answers each query and forgets it; a notrack rule at raw priority, before conntrack, skips it both ways. Untracked packets never match ct state established, so the filter accepts them by port.
A default-drop ruleset with per-source limits
For a host serving SSH, web and DNS, saved as /etc/nftables.conf:
#!/usr/sbin/nft -f
flush ruleset
table inet raw {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
udp dport 53 notrack
}
chain output {
type filter hook output priority raw; policy accept;
udp sport 53 notrack
}
}
table inet filter {
set ssh4 { type ipv4_addr; timeout 60s; flags dynamic; }
set ssh6 { type ipv6_addr; timeout 60s; flags dynamic; }
set web4 { type ipv4_addr; timeout 60s; flags dynamic; }
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
icmp type echo-request limit rate 10/second accept
icmpv6 type echo-request limit rate 10/second accept
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
ct state new tcp dport 22 update @ssh4 { ip saddr limit rate 3/minute } accept
ct state new tcp dport 22 update @ssh6 { ip6 saddr limit rate 3/minute } accept
tcp dport { 80, 443 } ct state new update @web4 { ip saddr limit rate over 50/second } counter drop
tcp dport { 80, 443 } accept
udp dport 53 accept
counter # counts policy drops
}
}
The policy does most of the work, silently dropping the unlisted. A bare limit is one shared bucket: 10 pings a second from everyone. A meter, a set updated from the packet path, gives each source its own limiter, released after 60 idle seconds.
Per-source limits stop a few noisy addresses. Cloudflare estimated the Aisuru-Kimwolf botnet at 1 to 4 million hosts: at 3 connections a minute, 1,000,000 hosts open 50,000 a second without one tripping a limit. Many users can share one carrier-grade NAT address; set the web limit from your logs. Load it from a console such as IPMI with nft -f /etc/nftables.conf, then systemctl enable nftables; flush ruleset also removes ufw's or Docker's tables.
rp_filter: strict, loose, and what 2 means
The kernel doc: 0 does no source validation; 1 is strict (RFC 3704), dropping a packet whose arrival interface is not the best route back to its source; 2 is loose, dropping only sources no interface can reach. The higher of conf/all and the interface's value applies, so with all at 2, as on our box, setting eth0 to 1 still gives loose mode.
Neither stops a flood at a typical server: with one interface and a default route, every address is reachable through it, so forged sources pass both modes. rp_filter belongs on routers, VPN gateways and multi-homed machines. Spoofing is stopped in the sender's network by RFC 2827 ingress filtering, which RFC 4987 warns end hosts not to count on.
ICMP: what the kernel answers
Our box runs icmp_ratelimit = 1000 and icmp_ratemask = 6168: at least 1,000 ms between replies to one target for destination unreachable, source quench, time exceeded and parameter problem (the MTR guide decodes the mask). icmp_msgs_per_sec, default 10,000, caps those types across all targets.
A UDP flood at closed ports from forged sources brings a new target almost every packet, so an unfiltered box sends up to about 10,000 port-unreachables a second to strangers; the drop policy discards them first. Echo replies are not in the mask, so Linux answers every ping; the limit rule caps that.
Watching an attack
nft list ruleset | grep counter # per-rule counts
conntrack -C # entries (apt install conntrack)
cat /proc/sys/net/netfilter/nf_conntrack_max
nstat -az TcpExtSyncookiesSent TcpExtListenOverflows
ss -Htn state syn-recv | wc -l # half-open handshakes
a=$(cat /sys/class/net/eth0/statistics/rx_bytes); sleep 10
b=$(cat /sys/class/net/eth0/statistics/rx_bytes)
echo "$(( (b - a) * 8 / 10 / 1000000 )) Mbps in"
- SyncookiesSent rising: cookies took over. ListenOverflows rising: the application accepts too slowly.
- Conntrack count near the max: raise it or add notrack.
- High
si in top, inbound below port speed: the box is the bottleneck; rules still help.
- Inbound at port speed: stop tuning. The pipe is full.
Outside, a probe on a second machine (dedicated server monitoring) sees what users see; mtr shows where loss starts.
Where Linux DDoS mitigation stops: a full port
Everything above filters packets that already crossed the port. When attack plus real traffic exceeds what that link carries, the upstream port facing your server drops the excess before Linux sees it, real packets and junk alike. Filtering on the box saves CPU, not bandwidth.
Cloudflare's 2025 Q4 report records a 31.4 Tbps attack lasting 35 seconds, and attacks in its December campaign averaged 4 Tbps: 4,000 times a 1 Gbps port, 400 times a 10 Gbps one. Volume is cheap: CISA puts NTP's amplification factor at 556.9, so 2 Mbps of forged requests returns about 2 × 556.9 ≈ 1,114 Mbps, enough to fill 1 Gbps.
The fix is filtering before the port. Choosing a DDoS protected dedicated server explains scrubbing and sizing; a host without it may answer a full port with a null route, finishing the attacker's job. Every ZetServers KVM VPS and dedicated server, including 1 Gbps dedicated servers from €209 a month, includes 100 Gbps of network-layer (L3/L4) protection at no charge, 100 times that port; tiers run up to 10 Tbps with Layer 7 filtering at €4,499 a month (ex VAT, October 2026). Upstream removes the volume; what looks legitimate still reaches the box, where everything above earns its keep.
Frequently asked questions
Can a Linux server stop a DDoS attack by itself?
Only an attack smaller than its port. SYN cookies, conntrack sizing and nftables limits keep services standing while traffic fits, but every filter on the box runs after packets cross the port. Once an attack fills the link, only filtering in the network helps.
Should I enable SYN cookies on Linux?
Yes; most distributions already do. With tcp_syncookies = 1, Linux sends cookies only when a listening socket's queue overflows, so normal connections keep the full handshake. Avoid 2, which uses cookies for every connection. A rising TcpExtSyncookiesSent in nstat shows them working.
How large should nf_conntrack_max be?
Large enough that a burst of new flows cannot fill it before your rules act. Compare conntrack -C with the maximum at busy hours, leave headroom, and keep nf_conntrack_buckets equal to it. Exempt stateless, high-rate services such as DNS with notrack so they occupy no entries.
Does rp_filter stop spoofed DDoS traffic?
Not on a typical server. With one interface and a default route, every internet address routes back through the interface a forged packet arrived on, so both modes accept it. It helps on routers and multi-homed hosts; spoofing is stopped by ingress filtering in the sender's network.