Hardening Nginx on HestiaCP: How to Stop Layer 7 DDoS Attacks and Speed Up Sites with Caching
A hands-on guide to building a custom Nginx template on HestiaCP that mitigates application-layer (Layer 7) DDoS attacks while enabling dynamic content caching to speed up your sites.
A "classic" DDoS attack aims to saturate the server's network bandwidth or connections (so-called Layer 3/4 attacks, at the network and transport level). But there is another category, often more insidious because it needs far less bandwidth to cause damage: Layer 7 attacks, at the application level. Here the target isn't the network connection itself, but the web server and the applications running on it: thousands of HTTP requests that look "legitimate" in form, but sent so quickly that they exhaust CPU, memory and database connections, bringing down even a server whose internet connection is nowhere near saturated.
Anyone running their own sites with HestiaCP, the open-source server control panel built around Nginx (on its own, or as a reverse proxy in front of Apache), has access to a powerful but little-known tool for defending against this kind of attack: custom Nginx templates. In this guide we'll see how to build a tailor-made template that adds application-layer DDoS mitigation rules, and at the same time how to use Nginx's caching to speed up dynamic content (PHP, Node.js apps, Docker containers), further reducing server load even when there is no attack going on.
How Nginx templates work in HestiaCP
HestiaCP doesn't hand-write the configuration for every site: for each domain it applies a template, a model file with placeholders that the panel replaces with the domain's real values (domain name, file path, system user, and so on) every time the domain is created or "rebuilt". The default templates live in:
/usr/local/hestia/data/templates/web/nginx/ # Nginx used as a reverse proxy in front of Apache
/usr/local/hestia/data/templates/web/nginx/php-fpm/ # Nginx with PHP-FPM (no Apache)
Each template is actually a set of three files sharing the same name with different extensions: .tpl (HTTP configuration), .stpl (HTTPS configuration) and .sh (an optional script run at generation time). For example, the "default" template is made up of default.tpl, default.stpl and default.sh.
Golden rule: never edit the original templates. Every HestiaCP update overwrites the default files, so any customization made directly on them would be lost. The correct procedure is to copy them under a new name and work only on the copies:
cd /usr/local/hestia/data/templates/web/nginx/php-fpm/
cp default.tpl hardened.tpl
cp default.stpl hardened.stpl
cp default.sh hardened.sh 2>/dev/null || true
(the example uses the Nginx + PHP-FPM templates; if the site runs behind Nginx + Apache, the same steps apply inside the nginx/ folder instead of nginx/php-fpm/).
Inside the copied files you'll find placeholders such as %domain%, %docroot%, %user%, %home% and %backend_lsnr%: HestiaCP automatically substitutes them with the correct values for every domain that uses the template, so they must be left untouched while you add new directives around them.
First hardening rule: rate limiting requests
The ngx_http_limit_req_module module, included in Nginx by default, lets you count requests per IP address (or any other variable) and reject the ones that exceed a threshold, returning an HTTP 429 Too Many Requests error. It is the simplest and most effective defense against application-layer floods: a bot firing hundreds of requests per second at the login page or the site's internal search engine gets blocked before it can burn resources on the PHP backend.
The counting zone only needs to be declared once, globally, inside the http block of the main Nginx configuration (/etc/nginx/nginx.conf, or a separate file included from it, for example /etc/nginx/conf.d/ddos-mitigation.conf):
# 10 MB of shared memory is enough to track roughly 160,000 IPs
limit_req_zone $binary_remote_addr zone=req_zone_general:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=req_zone_login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conn_zone:10m;
Two separate rate-limiting zones are created here: a generic one (req_zone_general, 20 requests per second per IP) for the whole site, and a much stricter one (req_zone_login, 5 requests per minute) applied only to the login page, a typical target for both brute-force and DDoS attacks. A zone for limit_conn was also added, which limits not the request rate but the number of simultaneous connections per IP, useful against attempts to exhaust the backend's connection pool.
In the custom template (hardened.tpl and hardened.stpl), inside the server { ... } block, the zones just defined are applied:
server {
listen %ip%:%web_port%;
server_name %domain_idn% %alias_idn%;
root %docroot%;
# Limit simultaneous connections per single IP across the whole site
limit_conn conn_zone 20;
location / {
# General rate limit: allows short bursts without immediately
# blocking a user who clicks around quickly
limit_req zone=req_zone_general burst=40 nodelay;
limit_req_status 429;
try_files $uri $uri/ /index.php?$args;
}
location = /wp-login.php {
limit_req zone=req_zone_login burst=5 nodelay;
limit_req_status 429;
include /home/%user%/conf/web/%domain%/nginx.hosting.conf*;
}
# ... the rest of the template (PHP-FPM handling, error pages, etc.)
# stays the same as the original "default" template.
}
A quick explanation of the parameters used, for anyone who doesn't work with Nginx every day:
rate=20r/s: the sustained rate allowed, in this case 20 requests per second for each IP.burst=40: a kind of "tank" of extra requests an IP can build up and spend all at once, so a real user browsing quickly between pages isn't penalized (every image, every CSS or JS file on a page triggers its own request).nodelay: without this option, Nginx would queue (delay) requests that exceed the threshold; withnodelay, requests beyond the burst are instead rejected immediately with a 429 error, avoiding a backlog of pending connections that would themselves consume resources.limit_req_status 429: explicitly sets the returned error code (the default would otherwise be 503, but 429 is semantically more correct and is recognized better by CDNs and monitoring tools).
Second hardening rule: cutting off slow connections (anti-Slowloris)
Another family of Layer 7 attacks, known as Slowloris, doesn't send many requests but very few, keeping each connection open for as long as possible by trickling data in tiny fragments: this way it exhausts the maximum number of simultaneous connections the server can handle, without ever having to generate high traffic. The remedy is to shorten the maximum time Nginx grants a client to finish sending its request:
client_body_timeout 10s;
client_header_timeout 10s;
keepalive_timeout 15s;
send_timeout 10s;
# Also cap sizes, to avoid abnormal uploads used as a flood vector
client_max_body_size 10m;
large_client_header_buffers 4 8k;
These values (much lower than the default 60 seconds) should also go inside the server { ... } block of the custom template, or inside the global http { ... } block if you want to apply them to every domain on the server. Be careful not to overdo it: overly aggressive timeouts can interrupt legitimate large-file uploads from slow or mobile connections; 10 seconds is generally a good compromise for ordinary sites.
Optimizing the caching of dynamic content
Mitigating application-layer DDoS isn't only about "blocking the bad guys": it's also about making it very cheap to answer a request, so that even a high volume of traffic (malicious, or simply a sudden spike in popularity) doesn't bring the server to its knees. Nginx's caching for dynamic content exists for exactly this: pages generated by PHP (or by a Node.js app behind a proxy) are temporarily saved to disk and served identically to subsequent requests, without having to re-run the application code and database queries every time.
HestiaCP distinguishes two cases, depending on the stack chosen for the domain:
Nginx + PHP-FPM: FastCGI cache
For domains running Nginx with PHP-FPM (no Apache), you use FastCGI cache. In the global configuration (the same /etc/nginx/conf.d/ddos-mitigation.conf file used above, or nginx.conf) the cache is defined:
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=FASTCGICACHE:100m inactive=60m max_size=1g;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
In the custom template, in the block that handles PHP-FPM, you need to add the activation include after the include /etc/nginx/fastcgi_params; line already present in the original template:
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass %backend_lsnr%;
fastcgi_index index.php;
include /etc/nginx/fastcgi_params;
# Enables the cache for this domain (the file is created by HestiaCP
# when FastCGI cache is turned on from the panel for the domain)
include /home/%user%/conf/web/%domain%/nginx.fastcgi_cache.conf*;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
Not every page should be cached, though: a shopping cart, a logged-in account area, or an endpoint that generates different content for each user must not serve one visitor the page saved for another. Those paths are excluded by setting the $no_cache variable:
set $no_cache 0;
if ($request_uri ~* "/(cart|checkout|wp-admin|my-account)") {
set $no_cache 1;
}
if ($http_cookie ~* "wordpress_logged_in") {
set $no_cache 1;
}
Once the template has been created, FastCGI cache for a specific domain is enabled from the domain's edit page in the HestiaCP panel (the section dedicated to the Nginx/backend template), by selecting the newly created template. To purge the cache at any time (for instance after a site update), just use the "Purge Nginx Cache" button on the same page, or from the command line:
v-purge-nginx-cache username domain.tld
Nginx + Apache: proxy cache
When a domain instead uses Nginx as a reverse proxy in front of Apache (a very common HestiaCP setup to support .htaccess and mod_php), FastCGI cache isn't available: instead you use Nginx's proxy cache, which works in a conceptually identical way but applies to proxied traffic (it also works fine for Node.js apps or Docker containers behind the proxy). Custom templates for this purpose follow a dedicated naming convention: caching-customname.tpl, caching-customname.stpl and caching-customname.sh, always created by copying the matching starting template HestiaCP provides in the nginx/ folder (not nginx/php-fpm/), and then selectable as a dedicated "caching template" from the domain's page.
A warning about WebSockets
Both the standard template and the proxy-cache ones include the proxy_hide_header Upgrade; directive by default, originally added to work around a Safari compatibility issue. This line, however, also blocks WebSocket connections (used for example by real-time chat, browser push notifications, or some admin panels): if the hosted application needs them, remove it from the custom template.
Applying the template and verifying it works
After saving the changes to the .tpl/.stpl files, you need to select the new template for the target domain: in the HestiaCP panel, open the domain's edit page (Web > domain name > Edit), expand the advanced options and choose the newly created template (hardened, in our example) from the Nginx/Proxy template dropdown. Alternatively, from the command line, you can force a configuration rebuild for all of a user's domains with:
v-rebuild-user username
Before pushing everything to production, it's good practice to validate the syntax and reload Nginx manually:
nginx -t && systemctl reload nginx
To check that rate limiting really works, you can simulate a small burst of repeated requests from your own machine (not to be confused with an actual load test, which should be run carefully and only against systems you own):
for i in $(seq 1 80); do
curl -o /dev/null -s -w "%{http_code}\n" https://domain.tld/
done | sort | uniq -c
If the configuration is correct, the first requests (within the configured burst) will return 200, while later ones, once the threshold is exceeded, will answer 429. That's the sign the rate-limiting zone is doing its job.
A few warnings before switching everything on in production
IP-based rate limiting has an inherent limitation worth keeping in mind: many users browsing from corporate networks, universities, or certain mobile networks share the same public IP via NAT, and overly low thresholds risk blocking legitimate users along with any bots. That's why it's advisable to start with conservative values (like the 20 r/s in the example), monitor the 429 error logs for a few days, and only tighten the thresholds afterward where needed. It's also good practice to exclude your own monitoring tools' IP addresses (uptime checkers, CDNs, backup services) from rate limiting, using a geo directive or a simple map block before the limit_req zone, to avoid false alarms.
Finally, the application-layer rate limiting described in this guide is an effective first line of defense against Layer 7 DDoS, but it doesn't replace dedicated network-layer (Layer 3/4) protection against very large volumetric attacks: for those, upstream mitigation services remain essential, such as a CDN with anti-DDoS protection or the filtering offered by some hosting providers and connectivity vendors.
Official sources
Share
What's Your Reaction?
Like
0
Dislike
0
Love
0
Funny
0
Angry
0
Sad
0
Wow
0