GICT knowledge base
Linux Kernel Tuning for Web Servers: The Settings That Actually Matter
Published
A default Linux install is conservative. The kernel caps connections at numbers that made sense for a desktop from 2010. On a VPS handling a few hundred concurrent users, those caps stay invisible. On a server handling thousands, they silently drop connections and kill throughput. This is how to fix that.
All tuning is done through sysctl, which reads and writes kernel parameters at runtime. Changes made with sysctl take effect immediately. To make them permanent across reboots, you store them in a config file under /etc/sysctl.d/.
What the defaults look like on your server right now
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog net.core.netdev_max_backlog
On a fresh Ubuntu 24.04 install, you will typically see somaxconn at 4096, tcp_max_syn_backlog at 256, and netdev_max_backlog at 1000. For a busy web server, all three are too low. Run that command on your VPS right now. Those are the numbers you are about to fix.
How a TCP connection actually works (and why the queue matters)
Before your web server accepts a connection, the kernel handles three steps:
- SYN arrives — the kernel stores the connection in the SYN queue and replies with SYN-ACK
- Client sends ACK — the connection moves to the accept queue, waiting for your web server to call
accept() - Server calls accept() — the connection dequeues and your web server hands it off to a worker process
The SYN queue length is controlled by tcp_max_syn_backlog. The accept queue length is controlled by somaxconn. If either queue fills up, the kernel drops packets. The client sees a connection refused error. Your users see nothing — the failure is silent.
Your Nginx listen directive can set a backlog value, but the kernel has the final word. If Nginx says listen 443 backlog=65535 and somaxconn is 4096, the kernel silently caps it at 4096. That is why somaxconn is the most important setting on this list.
The complete config file
Create /etc/sysctl.d/99-webserver.conf with all settings in one place:
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 16384
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.ipv4.tcp_window_scaling = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 10240 65535
fs.file-max = 2097152
vm.swappiness = 10
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
Apply it immediately:
sudo sysctl -p /etc/sysctl.d/99-webserver.conf
Verify the changes took:
sysctl net.core.somaxconn net.ipv4.tcp_congestion_control net.core.default_qdisc
What each group of settings does
Connection queue and backlog
The first three lines handle connection queuing. somaxconn is the kernel listen queue limit — the maximum number of connections that can wait for your web server to call accept(). tcp_max_syn_backlog is the SYN queue size before the three-way handshake completes. netdev_max_backlog is the queue for packets received faster than the kernel can process them — relevant on high-throughput servers or when the hypervisor delivers bursts of traffic.
TIME_WAIT and connection recycling
Every closed TCP connection spends time in TIME_WAIT state. This exists to catch delayed packets belonging to the old connection before the port can be reused. On a busy web server handling thousands of short-lived requests, these accumulate fast and consume available ports.
Two settings address this. tcp_fin_timeout reduces the FIN_WAIT_2 timeout from 60 seconds down to 15. The connection stays in TIME_WAIT for less time, freeing ports faster. tcp_tw_reuse lets the kernel reuse TIME_WAIT sockets for new outbound connections — this matters for servers that initiate connections (calling upstream APIs, connecting to databases) more than servers that receive them.
SYN flood protection and tcp_syncookies
When a server is flooded with SYN packets that never complete the handshake, the SYN queue fills up and blocks legitimate users. tcp_syncookies solves this by encoding connection state into the SYN-ACK sequence number, bypassing queue storage entirely. When the final ACK arrives, the kernel can reconstruct the connection from the cookie.
Enable it on any public-facing server:
net.ipv4.tcp_syncookies = 1
There is a minor performance cost under normal conditions — negligible compared to the protection it provides. Leave it on.
Ephemeral port exhaustion
When your server makes outbound connections — to a database, an API, a cache — the kernel picks an ephemeral port from a configurable range. The default range is typically 32768 to 60999. That gives you about 28,000 available source ports. On a server handling 10,000 requests per second with short-lived connections, you can exhaust that range in seconds.
Check your current range:
sysctl net.ipv4.ip_local_port_range
Widen it significantly:
net.ipv4.ip_local_port_range = 10240 65535
This gives you roughly 55,000 available ports, which is enough for most workloads. If you are still running out, your architecture is the problem — you should be pooling connections rather than opening a new one per request.
Keep-alive and dead connection detection
Default TCP keep-alive sends its first probe after 2 hours of inactivity. On a production server, that means a crashed client holds a connection open for 2 hours before the kernel notices. Load balancers, reverse proxies, and any NAT setup will also silently accumulate dead connections.
These settings cut that dramatically:
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
First probe after 5 minutes of inactivity, then a probe every 30 seconds, and after 5 failed probes the connection is dropped. Net effect: a dead connection is cleaned up within 7 minutes instead of 2 hours.
TCP buffers and window scaling
The buffer settings raise the ceiling on TCP receive and send buffers to 16MB:
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
Note the three values in tcp_rmem and tcp_wmem: minimum, default, and maximum. The kernel starts at the default and auto-tunes up to the maximum based on actual throughput. Setting a high maximum does not waste memory — the kernel only uses what it needs. But if you set the maximum too low, the kernel cannot take advantage of high-bandwidth links.
tcp_window_scaling enables TCP window scaling, which allows windows larger than 64KB. Without it, connections with latency above ~50ms cannot fully utilize bandwidth. If your server talks to users across continents, this matters.
BBR vs cubic: which congestion control to use
Linux ships with cubic as the default congestion control algorithm. It has been the standard for over a decade and is well-tested. bbr (Bottleneck Bandwidth and RTT) is a newer algorithm developed by Google that outperforms cubic on most paths, especially on high-bandwidth, high-latency links.
BBR does not drop packets to gauge congestion — it measures bandwidth and RTT directly. On a VPS this usually means 5-30% better throughput with lower latency variance. It requires two settings:
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
The fq (Fair Queue) qdisc is required because BBR relies on it for pacing. The default qdisc on most systems is fq_codel, which is fine for general use but not compatible with BBR’s pacing mechanism. BBR has been in the mainline kernel since version 4.9. If your VPS runs Ubuntu 18.04 or later, it is supported.
To verify BBR is active:
sysctl net.ipv4.tcp_congestion_control
sysctl net.core.default_qdisc
File descriptor limits
Every open socket consumes a file descriptor. The default system limit is usually around 2 million files (fs.file-max), but the per-process limit defaults to 1024 or 4096. A single Nginx worker handling many connections will hit that ceiling fast.
Set the system-wide limit high:
fs.file-max = 2097152
Then increase the per-process limit for your web server. For Nginx, this is done via systemd override (see the next section). For other services, set LimitNOFILE in the service’s systemd unit or use ulimit -n at runtime.
Swappiness and memory pressure
vm.swappiness controls how aggressively the kernel swaps memory to disk. The default is 60. On a VPS where disk I/O is typically slower than on bare metal, a high swappiness can hurt performance by paging out active processes in favor of idle data.
vm.swappiness = 10
This tells the kernel to prefer keeping pages in RAM and only swap when absolutely necessary. On a web server with enough RAM for its workload, this is the right choice. If you are routinely hitting swap, you need more RAM, not a higher swappiness value.
Nginx-side tuning to pair with this
Kernel tuning and web server tuning work together. Your kernel can queue 65,000 connections, but if Nginx only accepts 1,024, that is your ceiling. In your Nginx config:
worker_processes auto;
events {
worker_connections 4096;
use epoll;
multi_accept on;
}
worker_connections sets how many clients each worker can handle. use epoll tells Nginx to use the scalable event notification model that Linux provides — essential for handling thousands of connections efficiently. multi_accept on lets a worker accept multiple connections per wakeup instead of one.
Also increase the systemd file limit for Nginx, or it will hit the default 1024 open files cap:
sudo systemctl edit nginx
Add:
[Service]
LimitNOFILE=65535
Then reload systemd and restart Nginx:
sudo systemctl daemon-reload && sudo systemctl restart nginx
What not to tune on a VPS
Some performance guides recommend settings designed for bare-metal hosts with 10Gbps NICs and custom network hardware. On a VPS, the hypervisor already manages hardware-level queueing, IRQ routing, and interrupt moderation. Do not copy parameters meant for physical network appliances.
Also avoid tuning values you have not measured. If you do not know your current connection count, packet loss rate, or memory pressure, the settings above are a safe starting point. But if your server is not under load and has no performance complaints, leave the defaults alone.
sysctl -p first — that takes effect immediately without a reboot. Only move settings into /etc/sysctl.d/ once you have verified they work. A bad sysctl value does not crash the kernel, but it can silently disable a feature or cause connectivity issues that are hard to debug.Related guides
Plan a Safe Redis-to-Valkey Migration
Plan a Redis-to-Valkey migration: check version compatibility, rehearse recovery, stop writers for cutover and protect against lost writes during rollback.
Linux AdministrationHow to Restrict an SSH Key to One Command
Restrict an SSH key to one command with authorized_keys options. Protect the command and check that unwanted shell and forwarding access are denied.
Linux AdministrationHow to Schedule Tasks with systemd Timers
Schedule tasks with systemd timers. Verify service results separately from timer status, test missed-run handling and use an explicit UTC schedule.
Linux AdministrationHow to Restrict a Linux Service with systemd Security Settings
Restrict a Linux service with systemd drop-ins, a dedicated account, read-only paths, private temporary storage, and controlled writable directories.
