Nginx Security Hardening on Ubuntu and Debian

11 min read·Matthieu·DebianNginxUbuntuTLSSecurityHardening|

Harden Nginx beyond its defaults with security headers, TLS 1.3, HSTS, method restrictions, and access controls. Each directive is tied to the specific attack it prevents.

A default Nginx install serves traffic. It does not protect it. The default configuration leaks version information, accepts any HTTP method, sends no security headers, and uses whatever TLS settings OpenSSL provides.

This guide hardens Nginx on Debian 12 and Ubuntu 24.04. Each section names the threat first, then shows the directive that blocks it. You can apply these changes to a running server without downtime.

Prerequisites:

We store all hardening directives in a single include file. This keeps your server blocks clean and makes auditing straightforward:

sudo touch /etc/nginx/snippets/security-hardening.conf

Every server block that needs hardening gets one line:

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

After each change in this guide, test and reload:

sudo nginx -t && sudo systemctl reload nginx

How do I hide the Nginx server version?

Add server_tokens off; to the http block in /etc/nginx/nginx.conf. This strips the version number from the Server response header and from default error pages. Attackers scan for specific versions with known CVEs. Hiding the version does not fix vulnerabilities, but it raises the cost of targeted attacks.

Add to /etc/nginx/nginx.conf inside the http block:

server_tokens off;

This goes in the main config, not the snippet, because it applies globally.

After reloading, check the response header:

curl -sI https://your-domain.com | grep -i server
Server: nginx

No version number. Before this change, it showed something like Server: nginx/1.24.0.

How do I block requests to unknown hostnames?

When someone hits your server's IP address directly or uses a hostname you don't recognize, Nginx serves the first server block it finds. This opens the door to DNS rebinding attacks and lets scanners fingerprint your services.

A default server block that rejects all unmatched requests stops this. Add it as the first server block Nginx loads:

sudo nano /etc/nginx/sites-available/00-default-deny.conf
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    server_name _;

    # Self-signed or snakeoil cert just to complete the TLS handshake
    ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
    ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;

    return 444;
}

Status code 444 is Nginx-specific. It closes the connection immediately without sending a response.

Enable it:

sudo ln -s /etc/nginx/sites-available/00-default-deny.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

On Debian 12 and Ubuntu 24.04, install the ssl-cert package if the snakeoil certificate is missing:

sudo apt install ssl-cert

Test it by hitting the IP directly:

curl -sI -k https://YOUR_SERVER_IP

The connection closes with an empty response or a curl error (exit code 52). No content served.

What TLS configuration should I use for Nginx in 2026?

Use Mozilla's Intermediate profile: TLS 1.2 and 1.3 with forward-secret cipher suites. This covers clients back to Firefox 27 and Android 4.4.2 while dropping insecure protocols. TLS 1.0 and 1.1 are vulnerable to POODLE and BEAST and have been deprecated since RFC 8996 (2021). If all your clients support TLS 1.3, use the Modern profile instead (ssl_protocols TLSv1.3; only).

TLS hardening got more urgent in early 2026. CVE-2026-1642 (CVSS 5.9) demonstrated that Nginx's TLS upstream handling had a race condition allowing MitM injection before the handshake completed. The fix landed in Nginx 1.28.2 and 1.29.5. Check your version with nginx -v and update if needed.

Profile Protocols Oldest Client Use Case
Modern TLS 1.3 only Firefox 63, Chrome 70 APIs, modern web apps
Intermediate TLS 1.2 + 1.3 Firefox 27, Android 4.4 General-purpose servers
Old TLS 1.0 + 1.1 + 1.2 + 1.3 IE 8 on XP Legacy compliance only

Generate DH parameters for the DHE cipher suites in the Intermediate profile. This takes a few minutes:

sudo openssl dhparam -out /etc/nginx/dhparam.pem 2048
sudo chmod 644 /etc/nginx/dhparam.pem

Add TLS hardening to /etc/nginx/snippets/security-hardening.conf:

# TLS protocols - Mozilla Intermediate profile
ssl_protocols TLSv1.2 TLSv1.3;

# Cipher suites - Mozilla Intermediate profile (version 5.7)
# TLS 1.3 suites are configured automatically by OpenSSL
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305';

ssl_prefer_server_ciphers off;
ssl_dhparam /etc/nginx/dhparam.pem;

# Session settings
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

Why ssl_prefer_server_ciphers off? With only strong ciphers in the list, client preference gives better performance. Clients pick the cipher their hardware accelerates best.

Why ssl_session_tickets off? Session tickets use a server-wide key. If that key leaks, an attacker can decrypt all recorded sessions. Without tickets, the shared session cache provides resumption only on the same server, which is fine for single-server setups.

OCSP stapling note: Let's Encrypt shut down its OCSP service in August 2025. If you use Let's Encrypt certificates, remove any ssl_stapling directives. They will cause warnings in your logs. Let's Encrypt now publishes revocation information exclusively through CRLs.

Reload and test from your local machine:

openssl s_client -connect your-domain.com:443 -tls1_2 </dev/null 2>/dev/null | grep "Protocol\|Cipher"
    Protocol  : TLSv1.2
    Cipher    : ECDHE-RSA-AES256-GCM-SHA384

Confirm TLS 1.1 is rejected:

openssl s_client -connect your-domain.com:443 -tls1_1 </dev/null 2>&1 | head -5

The handshake fails. For a full audit, run your domain through SSL Labs. Target an A or A+ grade.

How do I enable HSTS in Nginx?

HSTS (HTTP Strict Transport Security) tells browsers to only connect over HTTPS for a set period. Without it, a MitM attacker can intercept the initial HTTP request and downgrade the connection. Once a browser sees the HSTS header, it refuses plain HTTP connections to your domain until max-age expires.

Add to /etc/nginx/snippets/security-hardening.conf:

# HSTS - 2 years, include subdomains
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

The always flag ensures Nginx sends this header on error responses too (4xx, 5xx). Without always, a 403 or 500 response would not include the header, leaving a window for downgrade attacks.

Before adding includeSubDomains, confirm all your subdomains support HTTPS. This directive applies to every subdomain. A subdomain without a valid certificate becomes unreachable.

HSTS preloading: Adding preload to the header and submitting your domain to hstspreload.org hardcodes HTTPS enforcement in browsers. This is permanent. Removal takes months. Only add preload once you are certain every subdomain will always serve HTTPS.

After reloading:

curl -sI https://your-domain.com | grep -i strict
strict-transport-security: max-age=63072000; includeSubDomains

Which security headers should every Nginx server have?

Five response headers protect against common browser-side attacks: MIME sniffing, clickjacking, cross-site scripting, referrer leakage, and abuse of browser APIs. Add all five with the always flag so they apply to error responses too. The deprecated X-XSS-Protection header should not be included. Modern browsers have removed their XSS auditors, and the header itself introduced vulnerabilities in some cases.

Add to /etc/nginx/snippets/security-hardening.conf:

# Prevent MIME type sniffing - stops browsers from interpreting files as a
# different content type than declared, blocking drive-by download attacks
add_header X-Content-Type-Options "nosniff" always;

# Clickjacking protection - prevents your pages from being embedded in
# iframes on other sites
add_header X-Frame-Options "SAMEORIGIN" always;

# Content Security Policy - controls which sources can load scripts, styles,
# images, and other resources. Start restrictive, loosen as needed.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;

# Referrer Policy - controls how much URL information the browser sends
# when navigating away from your site
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Permissions Policy - disables browser features you don't use, preventing
# compromised scripts from accessing camera, microphone, etc.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

interest-cohort=() appeared in older guides to block Google FLoC. FLoC was abandoned in 2023. Modern browsers do not recognize this directive and log console warnings if you include it.

Header Value Threat Mitigated
X-Content-Type-Options nosniff MIME confusion attacks, drive-by downloads
X-Frame-Options SAMEORIGIN Clickjacking via iframe embedding
Content-Security-Policy default-src 'self' XSS, data injection, unauthorized resource loading
Referrer-Policy strict-origin-when-cross-origin URL leakage to third parties
Permissions-Policy camera=(), microphone=()... Abuse of browser APIs by injected scripts

CSP tuning: The example above is strict. Most applications need adjustments. If your site loads scripts from a CDN, add it: script-src 'self' https://cdn.example.com;. Use your browser's developer console to find CSP violations and loosen the policy incrementally. Never use unsafe-eval unless you understand the risk.

The add_header inheritance trap

Nginx drops all add_header directives from parent blocks when a child location block defines its own add_header. If you add a header inside a location block, all security headers from the server or http level disappear from that location's responses.

Two ways to handle this:

  1. Nginx 1.29.3+: Use add_header_inherit merge; in the child block to preserve parent headers.
  2. Older versions: Repeat the include /etc/nginx/snippets/security-hardening.conf; line inside any location block that adds its own headers.

Reload and check:

sudo nginx -t && sudo systemctl reload nginx
curl -sI https://your-domain.com | grep -iE "x-content-type|x-frame|content-security|referrer-policy|permissions-policy"

All five headers appear in the output. Check a specific location path too, not just the root, to catch inheritance issues.

How do I disable unsafe HTTP methods in Nginx?

Most sites need GET, HEAD, and POST. Methods like DELETE, PUT, TRACE, and OPTIONS (when unused) expand your attack surface. TRACE in particular can leak cookies and auth tokens through cross-site tracing (XST) attacks.

The cleanest approach in standard Nginx is limit_except, which is purpose-built for method restriction. Place it inside your location blocks:

location / {
    limit_except GET POST {
        deny all;
    }

    # ... your existing config
}

limit_except GET implicitly allows HEAD (HEAD is a GET without a body per the HTTP spec). Any method not listed returns a 403 Forbidden.

For a global approach using map (add to nginx.conf in the http block):

map $request_method $method_not_allowed {
    default  1;
    GET      0;
    HEAD     0;
    POST     0;
}

Then in your server block:

if ($method_not_allowed) {
    return 405;
}

The limit_except approach is preferred when you need per-location control. The map approach works when you want a blanket policy across the entire server.

Test with a blocked method:

curl -sI -X DELETE https://your-domain.com

Returns HTTP/1.1 403 Forbidden or HTTP/1.1 405 Not Allowed.

curl -sI -X GET https://your-domain.com

Returns HTTP/1.1 200 OK (or your normal response code).

How do I restrict access to admin paths by IP address?

Admin panels, monitoring dashboards, and status endpoints should not be reachable from the public internet. Nginx's allow and deny directives restrict access by IP address at the location level.

location /admin {
    allow 203.0.113.10;      # Your office IP
    allow 198.51.100.0/24;   # Your VPN range
    deny all;

    # ... proxy_pass or other directives
}

location /nginx-status {
    stub_status;
    allow 127.0.0.1;
    allow ::1;
    deny all;
}

Order matters. Nginx evaluates allow and deny top to bottom and stops at the first match. Place deny all last.

If your IP changes frequently, consider restricting access through your firewall instead, or use a VPN. IP-based ACLs in Nginx are a second layer, not a replacement for authentication.

From an allowed IP:

curl -sI https://your-domain.com/admin

Returns your normal response (200, 302, etc.).

From a different IP (or using a proxy), the same request returns HTTP/1.1 403 Forbidden.

Check the access log for denied requests:

sudo tail -5 /var/log/nginx/access.log | grep admin

What buffer and timeout limits prevent DoS attacks in Nginx?

Default buffer sizes and timeouts are generous. An attacker can exploit this by sending large headers, slow requests, or oversized bodies to tie up worker connections. Tightening these values limits the damage a single connection can do.

Add to /etc/nginx/snippets/security-hardening.conf:

# Maximum allowed request body size - reject uploads larger than 10MB
client_max_body_size 10m;

# Buffer for reading client request body
client_body_buffer_size 16k;

# Buffer for reading large client headers
large_client_header_buffers 4 16k;

# Timeouts - how long Nginx waits for client data
client_body_timeout 30s;
client_header_timeout 30s;
send_timeout 30s;
keepalive_timeout 65s;
Directive Value Too High Too Low
client_max_body_size 10m Allows huge uploads that fill disk Breaks file upload forms
client_body_buffer_size 16k Wastes memory per connection Forces temp files for small POSTs
large_client_header_buffers 4 16k Memory waste under header attacks Breaks apps with large cookies/URLs
client_body_timeout 30s Slow loris connections stay open Drops users on slow networks
client_header_timeout 30s Same as above Same as above
send_timeout 30s Ties up connections for slow clients Cuts off large downloads
keepalive_timeout 65s Connection pool exhaustion Extra TLS handshakes

Adjust client_max_body_size to match your application. If you handle file uploads, set it to whatever your app expects. A 10MB default is reasonable for most web applications.

Reload and test the body size limit:

dd if=/dev/zero bs=1M count=11 2>/dev/null | curl -s -X POST --data-binary @- -o /dev/null -w "%{http_code}" https://your-domain.com/

Expected: 413 (Request Entity Too Large).

For rate limiting and deeper DDoS protection, see Nginx Rate Limiting and DDoS Protection.

How do I test the full hardening configuration?

Reload and run through the key checks:

sudo nginx -t && sudo systemctl reload nginx

Check headers and version info with a single request:

curl -sI https://your-domain.com

The Server header should show nginx with no version number. All six security headers (HSTS, X-Content-Type-Options, X-Frame-Options, Content-Security-Policy, Referrer-Policy, Permissions-Policy) should appear in the response.

For TLS, confirm the protocol and cipher from your local machine:

openssl s_client -connect your-domain.com:443 </dev/null 2>/dev/null | grep -E "Protocol|Cipher"

You want TLSv1.2 or TLSv1.3 with a GCM or CHACHA20 cipher.

Hit the server IP directly to confirm the default deny block works:

curl -sk https://YOUR_SERVER_IP -o /dev/null -w "%{http_code}"

A 000 response code means the connection was closed without a response, which is correct.

Try a blocked HTTP method:

curl -sI -X TRACE https://your-domain.com -o /dev/null -w "%{http_code}"

This should return 403 or 405.

For an external audit, submit your domain to SSL Labs (target A or A+ grade) and securityheaders.com for a header-specific report.

Complete hardening snippet

The full /etc/nginx/snippets/security-hardening.conf with everything from this guide:

# /etc/nginx/snippets/security-hardening.conf
# Nginx security hardening - include in each server block

# --- TLS Hardening (Mozilla Intermediate profile v5.7) ---
ssl_protocols TLSv1.2 TLSv1.3;
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:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305';
ssl_prefer_server_ciphers off;
ssl_dhparam /etc/nginx/dhparam.pem;

# TLS session settings
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

# --- HSTS ---
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

# --- Security Headers ---
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self';" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;

# --- Buffer Limits ---
client_max_body_size 10m;
client_body_buffer_size 16k;
large_client_header_buffers 4 16k;

# --- Timeouts ---
client_body_timeout 30s;
client_header_timeout 30s;
send_timeout 30s;
keepalive_timeout 65s;

Remember to also set server_tokens off; in the http block of /etc/nginx/nginx.conf and create the default deny server block separately.

Troubleshooting

Nginx won't reload after changes:

sudo nginx -t

Read the error. It tells you the file and line number. Common causes: missing semicolons, typos in directive names, referencing a DH param file that does not exist.

Headers missing on some paths:

The add_header inheritance trap. If a location block has its own add_header, all parent headers are dropped. Include the snippet in that location block or use add_header_inherit merge; on Nginx 1.29.3+.

CSP blocking legitimate resources:

Open the browser developer console (F12). Look for Refused to load errors. They name the blocked resource and which CSP directive blocked it. Add the source to your policy incrementally.

TLS handshake failures:

journalctl -u nginx -f

Watch for SSL errors while connecting. Verify your certificate chain is complete:

openssl s_client -connect your-domain.com:443 -servername your-domain.com </dev/null 2>/dev/null | grep "Verify return code"

Expected: Verify return code: 0 (ok).

Logs location:

# Error log
sudo tail -20 /var/log/nginx/error.log

# Real-time monitoring
sudo journalctl -u nginx -f

Next steps: With the server hardened, set up Nginx Rate Limiting and DDoS Protection to handle abusive traffic patterns. For per-site configurations, see Nginx Server Blocks: Host Multiple Domains on One VPS.

Related: Nginx Administration on a VPS | Nginx Config File Structure Explained | Set Up Let's Encrypt SSL/TLS for Nginx on Debian 12 and Ubuntu 24.04