Security

Advanced Nginx Security Hardening on an Ubuntu VPS for Production

You just deployed your app on a fresh Ubuntu 24.04 VPS and Nginx is serving it, but the default configuration is not production-grade. Out of the box, Nginx ships with weak TLS settings, no security headers, no rate limiting, and it happily accepts any HTTP method you throw at it. This guide hardens Nginx step by step for a production environment, with a focus on an Nginx security hardening Ubuntu VPS Vietnam production setup. Every section ends with a verify command so you know the change actually took effect.

Prerequisites

  • An Ubuntu 24.04 LTS VPS (this guide also works on Debian 12 with identical commands).
  • Root access or a sudo user.
  • A domain name pointing to your VPS, since TLS certificates require one.
  • Ports 80 and 443 open in your firewall. If you use ufw, run sudo ufw allow 80/tcp and sudo ufw allow 443/tcp.

This article assumes Nginx is already installed and serving a site. If it is not, install it first with sudo apt install nginx. The hardening steps below work on any Linux VPS with full root access. For a quick test box, a cheap Linux VPS with 2 GB of RAM is enough to follow along.

Why Hardening Matters More Than a Fast Server

Performance tuning gets all the attention, but a leaked private key or an open proxy is a career-ending incident. A default Nginx install exposes the server version, accepts weak ciphers, and answers to arbitrary Host headers. That matters a lot when your VPS carries production traffic: a single misconfiguration can turn your server into an open relay or a target for SSL stripping.

Hardening is also a compliance checkbox. If your service handles user data, a security baseline like the CIS Nginx benchmark or PCI DSS requires most of the settings in this guide. Doing it now, on a clean server, costs you twenty minutes. Doing it after an incident costs you a weekend and a postmortem.

Step 1 - Generating a Strong Diffie-Hellman Parameter

Forward secrecy in TLS relies on ephemeral Diffie-Hellman keys. Nginx ships with a built-in parameter set, but generating your own 2048-bit parameter adds a layer of safety. The command takes a few minutes on a small VPS, so run it in the background with tmux if you have a slow connection.

sudo mkdir -p /etc/nginx/ssl
sudo openssl dhparam -out /etc/nginx/ssl/dhparam.pem 2048

I use 2048 bits rather than 4096. The larger size adds negligible security but noticeably slows TLS handshakes on a 1 vCPU VPS. After the file is generated, make sure Nginx can read it:

sudo chmod 600 /etc/nginx/ssl/dhparam.pem
sudo chown root:root /etc/nginx/ssl/dhparam.pem
ls -l /etc/nginx/ssl/dhparam.pem

Verify: the output shows -rw------- 1 root root. If the permissions are looser, tighten them now.

Step 2 - Enforcing TLS 1.3 with a Hardened Cipher Suite

TLS 1.3 is the current protocol version and the only one you should allow in production. TLS 1.2 is still supported by all modern clients, but you can disable it entirely if your user base runs current browsers. I keep TLS 1.2 enabled for older API clients, but with a strict cipher list. TLS 1.3 ignores the cipher list anyway, it only negotiates the suites defined in the RFC.

Edit the main configuration file:

sudo nano /etc/nginx/nginx.conf

Inside the http block, add or replace these directives:

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_dhparam /etc/nginx/ssl/dhparam.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;

ssl_session_tickets off reduces the risk of session resumption attacks, at the cost of a slightly slower handshake for repeat visitors. On a low-traffic VPS the trade-off is worth it. The session cache in shared memory keeps handshakes fast without the security downside of tickets.

Test and reload:

sudo nginx -t
sudo systemctl reload nginx

Verify: sudo nginx -t prints syntax is ok and test is successful. Then check the negotiated protocol from a client:

curl -I https://your-domain.com
curl --tlsv1.2 -I https://your-domain.com 2>&1 | head -n 5

The first command should return HTTP headers. The second should succeed too, since TLS 1.2 is still enabled. If you want to enforce only TLS 1.3, change ssl_protocols to TLSv1.3 and test again with curl --tlsv1.2, it should fail with a handshake error.

Step 3 - Locking Down the TLS Configuration for Each Virtual Host

The global settings handle the protocol and ciphers, but each server block needs its own certificate paths and a few per-site directives. This is where most misconfigurations hide. Create a snippet so every site inherits the same strong defaults:

sudo nano /etc/nginx/snippets/ssl-hardening.conf

Paste this content:

ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/your-domain.com/chain.pem;
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 8.8.8.8 valid=300s;
resolver_timeout 5s;

If you have not set up certificates yet, install Certbot and obtain one with sudo apt install certbot python3-certbot-nginx then sudo certbot --nginx -d your-domain.com. The SSL certificates page at thueVPS also covers GoGetSSL options if you prefer a paid DV certificate for multiple domains.

Include this snippet in each server block that listens on 443:

include /etc/nginx/snippets/ssl-hardening.conf;

Verify: reload Nginx and check OCSP stapling is active:

sudo systemctl reload nginx
openssl s_client -connect your-domain.com:443 -status 2>/dev/null | grep -A1 "OCSP response"

You want to see OCSP Response Status: successful. If it says no response sent, your resolver or stapling directive has a problem.

Step 4 - Adding Security Headers at the HTTP Level

Security headers are cheap protection against common browser-side attacks. Set them once in the http block so every virtual host inherits them. Add these to /etc/nginx/nginx.conf:

add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header X-XSS-Protection "0" always;

Two things to note. First, X-XSS-Protection "0" looks counterintuitive, but the legacy filter in browsers introduced more vulnerabilities than it fixed, so modern guidance is to disable it and rely on a proper Content-Security-Policy instead. Second, the always parameter forces the header on all responses, including error pages. Without it, Nginx only sends headers on 2xx and 3xx responses.

A Content-Security-Policy header is the strongest of the set, but you cannot set a generic one in the global block. It depends on your application, so set it per site. A reasonable starting point for a simple app:

add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'self'; base-uri 'self'" always;

Verify: reload and inspect the response headers:

sudo systemctl reload nginx
curl -I https://your-domain.com

Check that X-Frame-Options, X-Content-Type-Options and Referrer-Policy appear in the output.

Step 5 - Hiding the Server Version and Limiting Information Disclosure

The default Nginx error pages leak the exact version, which helps an attacker pick a known CVE. Turn that off and replace it with a generic string. On the same /etc/nginx/nginx.conf, inside http:

server_tokens off;
more_clear_headers Server;

The second directive comes from the nginx-extras package. If it is not available, the first line alone removes the version number but keeps the nginx token. That is acceptable. Install the extras package on Ubuntu with:

sudo apt install nginx-extras

You should also drop the default server block that responds to any unknown Host header. That block is a common vector for DNS rebinding attacks. In /etc/nginx/sites-enabled/default, replace the entire content with:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

Returning 444 closes the connection without sending any response. That is the correct behavior for an unknown host.

Verify:

sudo systemctl reload nginx
curl -I http://your-domain.com
curl -I -H "Host: nonexistent.com" http://your-vps-ip

The first shows a bare Server: nginx (or nothing). The second hangs for a moment and returns a connection error, that is the 444 working.

Step 6 - Rate Limiting to Dampen Brute Force and Scraping

Rate limiting lives in the http block, but the limits apply per server block. Define a shared zone once:

limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;

Then apply the limits in a server block, usually on the one handling your API or login page:

limit_req zone=general burst=20 nodelay;
limit_conn perip 20;

The burst allows short spikes above the rate, and nodelay means those requests are processed immediately rather than queued. For a login endpoint, use a much stricter limit, rate=5r/m is reasonable. If your login page sits behind a reverse proxy, use $http_x_forwarded_for as the key instead of $binary_remote_addr, otherwise all traffic appears to come from the proxy IP and the limit blocks everyone.

Verify: fire 20 requests in a row and check for 503 responses:

for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" https://your-domain.com/; done | sort | uniq -c

The output shows a mix of 200 and some 503 responses once the burst is exhausted.

Step 7 - Blocking Bad Bots and Common Attack Patterns

A surprising amount of traffic on a public VPS is scanners looking for WordPress admin panels, .env files, or PHP shells. You can reject them at the Nginx layer before they reach your app. Add a map to the http block:

map $http_user_agent $bad_bot {
    default 0;
    ~*curl 0;
    ~*wget 0;
    ~*(sqlmap|nikto|nessus|masscan|nmap|hydra|gobuster) 1;
    ~*(python-requests|scrapy|http-client) 1;
}

Then in a server block, reject matching agents:

if ($bad_bot) {
    return 444;
}

Be careful with the user-agent list. Blocking curl and wget at the map level is a mistake, you will lock out your own monitoring and backup scripts. The list above deliberately keeps them allowed, and only targets known scanner toolkits and aggressive scrapers. If a legitimate client uses a blocked agent, you will see it in the access log and can whitelist it.

Verify:

curl -A "sqlmap/1.6" -I https://your-domain.com
curl -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -I https://your-domain.com

The first returns nothing (connection closed), the second returns normal HTTP headers.

Step 8 - Limiting Request Size and Timeouts

Unlimited client request bodies are an invitation to fill your disk. Set strict limits in the http block:

client_max_body_size 10m;
client_body_timeout 10s;
client_header_timeout 10s;
send_timeout 10s;
keepalive_timeout 65;
keepalive_requests 100;

The 10m body limit suits most web apps. If you run a file upload service, raise it only for the specific location that handles uploads, not globally. The timeout values stop slow clients from holding connections open and exhausting worker processes. On a small VPS this matters more than the fancy TLS settings.

Verify:

sudo nginx -t
curl -X POST -H "Content-Type: text/plain" --data-binary @/dev/zero -I https://your-domain.com 2>&1 | grep -i "413\|100"

You should see a 413 Request Entity Too Large response.

Troubleshooting

Symptom: nginx -t fails with "unknown directive". You are using a directive from nginx-extras (like more_clear_headers) without the package installed. Install it, or remove that directive from the config. Check which module provides the directive with nginx -V 2>&1 | tr ' ' '\n' | grep module.

Symptom: all clients get 503 after enabling rate limiting. Your site is behind Cloudflare or another proxy, so $binary_remote_addr is the proxy IP for everyone. Change the rate limit key to $http_cf_connecting_ip (Cloudflare) or configure Nginx to use real_ip from the trusted proxy IPs before the limit_req_zone directive.

Symptom: OCSP stapling shows "no response sent". The resolver may not be reachable from your VPS, or the firewall blocks outbound DNS on port 53. Check connectivity with dig +short your-domain.com and make sure resolver 1.1.1.1; is inside the same server block or the http block.

Symptom: your own scripts get blocked by rate limiting. Add a separate, higher limit for paths used by internal tooling, or whitelist your office IP with an allow directive before the limit_req line.

FAQ

What is the most important Nginx security header?

Content-Security-Policy is the most protective, but it is also the only one that can break your site if misconfigured. Start with the easiest ones: X-Frame-Options, X-Content-Type-Options and Referrer-Policy. Add CSP per site after you test it.

Should I disable TLS 1.2 entirely?

If your users are on current browsers, yes. TLS 1.3 alone is safer and faster. Keep TLS 1.2 only if you have older API clients or embedded devices that cannot upgrade, and pair it with a strict cipher list.

Does rate limiting hurt performance?

No. The shared memory zone uses a few megabytes, and the check happens before the request reaches your application. It actually protects performance by stopping a single client from saturating your workers.

Is fail2ban still necessary if Nginx rate limits are active?

Yes. Rate limiting caps request volume, but it does not ban an IP or inspect logs. fail2ban watches access logs and blocks repeat offenders at the firewall level. Use both, they solve different problems. See our guide on hardening SSH with fail2ban for the pattern.

How do I know if my Nginx config is actually secure?

Run sudo lynis audit system from the CIS-hardening toolkit, or use an online scanner like SSL Labs SSL Server Test against your domain. Both flag missing headers, weak ciphers and protocol issues you may have missed. Our article on security auditing with Lynis walks through the full process.

Related articles

Ubuntu VPS 上 Nginx 生产环境加固要点

本文针对部署在越南 Ubuntu VPS 上的 Nginx 生产环境,逐项完成安全加固:启用 TLS 1.3 并限制为强密码套件、为每个站点添加安全响应头、隐藏服务器版本、配置请求频率限制以阻止暴力破解和爬虫、限制请求体积和超时。每项操作都附有验证命令,建议按步骤执行后使用 SSL Labs 或 Lynis 复核配置。对于面向越南用户的业务,选择本地机房可降低延迟并简化合规。

Note: This guide is for general reference. Every system and infrastructure has its own specifics, so test each step in a safe environment and consult a qualified engineer before applying it in production.