VPS Tutorials
VPS Nginx Security Hardening (2026): TLS, HSTS, CSP, Security Headers, Rate Limiting, WAF Checklist
Nginx running doesn't mean it's secure. Common risks on a VPS include outdated TLS configuration, missing security headers like HSTS/CSP, directly exposed origin IP, no request rate limiting, unprotected admin paths, and error logs nobody reads. This article gives an Nginx security hardening checklist suited to small sites and self-hosted services, in deployment order.

Nginx is the most common web entry point on a VPS: WordPress, Next.js, n8n, Gitea, panels, API services — most of them end up behind Nginx.
But many VPS Nginx configurations only achieve "it's reachable": 80 redirects to 443, reverse proxy to the backend, certificate renews. Once actually online, the risks tend to hide in these places: TLS versions too old, missing security headers, no rate limiting on admin paths, origin IP exposed, error pages leaking information, access logs nobody reads, Cloudflare enabled but the origin can still be connected to directly.
This article doesn't cover how to install Nginx, and doesn't repeat troubleshooting for 502, 521, or certificate renewal. There's only one goal: harden Nginx on an ordinary VPS from "it runs" to "better suited for long-term public exposure."
Before going live, check in this order:
- Only open 80/443; backend services should not be directly exposed to the public internet;
- Keep only TLS 1.2 / TLS 1.3;
- Enable HSTS, but don't go straight to preload;
- Add basic security headers: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy;
- Hide the Nginx version number;
- Rate-limit login, search, API, and Webhook requests;
- Limit upload size and timeouts;
- If using Cloudflare, only allow Cloudflare origin IPs;
- Logs should reveal attacks, rate limiting, and abnormal status codes;
- Verify externally with SSL Labs, Mozilla Observatory, SecurityHeaders.
Don't try to max out every security header at once. The goal of security configuration isn't "copy the most hardcore config" but tightening gradually without affecting the business.
Many people think they only exposed Nginx, when in fact Docker, Node, databases, and Redis are also listening on the public internet.
First check listening ports on the VPS:
ss -tulpen
Pay attention to 0.0.0.0 and ::. If you see these, be alert:
0.0.0.0:3000 # 应用端口直接暴露
0.0.0.0:5432 # PostgreSQL 暴露
0.0.0.0:6379 # Redis 暴露
0.0.0.0:3306 # MySQL 暴露
For production, this is more recommended:
- Nginx listens on public 80/443;
- Applications listen only on
127.0.0.1or the Docker internal network; - Databases and Redis don't map public ports;
- Admin panels go through VPN, SSH tunnel, allowlist, or at least Basic Auth.
Don't do this in Docker Compose:
ports:
- "3000:3000"
- "5432:5432"
A safer way is to expose the backend only to Nginx:
services:
app:
expose:
- "3000"
Or bind to localhost only:
ports:
- "127.0.0.1:3000:3000"
If your database is already exposed to the public internet, read this first:
Still using TLS 1.0 / 1.1 in 2026 is not appropriate. Most small sites can simply keep TLS 1.2 and TLS 1.3:
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
Why ssl_prefer_server_ciphers off? Because modern TLS configuration relies more on client-server negotiation, especially since TLS 1.3 no longer works with the old cipher ordering. Unless you know your compatibility requirements very well, don't copy a ten-year-old long cipher list.
If you want to generate a more standard configuration, you can refer to the Mozilla SSL Configuration Generator (now migrated to TLSRef Configurator). When generating, choose based on your site's audience:
| Scenario | Recommended level |
|---|---|
| Ordinary blogs, SaaS, tool sites | Intermediate |
| Modern browsers only | Modern |
| Must support very old devices | Old, but not recommended |
Certificate renewal failure isn't a hardening issue, but it directly makes the site unavailable. If certbot often fails, see:
HSTS tells the browser: from now on, this domain must be accessed over HTTPS, preventing downgrade attacks.
Basic configuration:
add_header Strict-Transport-Security "max-age=31536000" always;
After confirming all subdomains support HTTPS, then consider:
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
Don't add preload right away. Once you're in the browser preload list, if some subdomain later can't do HTTPS temporarily, removal is very slow and troubleshooting costs are very high.
The correct process is:
- First make sure the main domain's HTTPS works properly;
- Enable
max-age=300and test for a few minutes; - Then raise it to one day, one week;
- Finally raise it to one year or two years;
- Only after confirming all subdomains support HTTPS, consider
includeSubDomains.
If you're already hitting an HTTPS redirect loop, troubleshoot here first:
The OWASP Secure Headers Project recommends using HTTP response headers to reduce browser-side risk. Common Nginx syntax is as follows:
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
What these headers do:
| Header | Purpose | Risk |
|---|---|---|
X-Content-Type-Options | Prevents browser MIME sniffing | Usually safe |
X-Frame-Options | Reduces clickjacking risk | Be careful with businesses that embed iframes |
Referrer-Policy | Reduces Referer leakage | Analytics systems may be affected |
Permissions-Policy | Disables unneeded browser capabilities | WebRTC/location businesses need to open them separately |
Note that always is important. In the official Nginx documentation, add_header by default only takes effect for some status codes; with always added, error responses also carry these headers.
Content-Security-Policy is powerful, but it's also the easiest way to break a website. It can restrict where scripts, images, fonts, and iframes load from, but WordPress plugins, analytics scripts, ad scripts, and comment systems can all be blocked by it.
It's recommended to start with a moderate version:
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'; upgrade-insecure-requests" always;
This isn't the strictest CSP, but it's suitable as a first version. After going live, check the browser console for blocked resources, then tighten gradually.
A more stable approach is to use Report-Only first:
add_header Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report" always;
After confirming nothing is broken, switch to the formal CSP.
Hide the Nginx version number:
server_tokens off;
It reduces information exposure, but it's not a core defense. Attackers won't stop scanning just because they can't see the version number. What really matters is updating the system and Nginx promptly:
apt update
apt list --upgradable | grep nginx
If you use Baota, OpenResty, or Nginx Proxy Manager, also pay attention to their own update cadence, not just system packages.
Login pages, search pages, APIs, Webhooks, comment submissions, and password reset endpoints are all suitable for rate limiting.
Define the rate-limit zone in the http block:
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api_zone:10m rate=10r/s;
Use it in specific locations:
location /login {
limit_req zone=login_zone burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
}
location /api/ {
limit_req zone=api_zone burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
}
Nginx's limit_req uses the leaky bucket algorithm. rate=5r/m means an average of 5 times per minute, burst=5 allows short bursts, but exceeding it will be delayed or rejected.
If you're unsure about the threshold, you can dry-run first:
limit_req_dry_run on;
limit_req_log_level notice;
Observe the logs first, then enforce blocking. Otherwise it's easy to block real users too.
If you're already under a CC attack, see:
Many VPS problems aren't caused by sophisticated attacks, but by uploads, slow connections, and timeouts with no boundaries.
Basic configuration:
client_max_body_size 20m;
client_body_timeout 15s;
client_header_timeout 15s;
keepalive_timeout 30s;
send_timeout 30s;
If it's backend image uploads, you can relax only the upload path:
location /admin/upload {
client_max_body_size 100m;
proxy_pass http://127.0.0.1:3000;
}
Don't blindly set client_max_body_size 500m site-wide. The larger the upload, the easier it is to abuse, and the easier it is to trigger disk and memory pressure.
If you hit 413, see:
Many people enable Cloudflare WAF and think the origin is safe. The problem is: if an attacker knows your VPS IP, they can bypass Cloudflare and hit the origin directly.
Hardening approach:
- DNS only exposes Cloudflare proxy records;
- Origin firewall only allows Cloudflare IPs to access 80/443;
- SSH port only allows your own IP or goes through VPN;
- Don't leak the origin IP in emails, historical DNS, or error pages.
UFW example:
ufw default deny incoming
ufw allow OpenSSH
ufw allow from 173.245.48.0/20 to any port 443 proto tcp
ufw allow from 103.21.244.0/22 to any port 443 proto tcp
ufw enable
Cloudflare IP ranges change; for production it's recommended to write a script to sync the official IP list regularly, rather than maintaining it manually long-term.
Below is a starting template suitable for ordinary VPS reverse proxy applications:
server_tokens off;
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api_zone:10m rate=10r/s;
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self'; upgrade-insecure-requests" always;
client_max_body_size 20m;
client_body_timeout 15s;
client_header_timeout 15s;
keepalive_timeout 30s;
send_timeout 30s;
location /login {
limit_req zone=login_zone burst=5 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
limit_req zone=api_zone burst=20 nodelay;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Must test before going live:
nginx -t
systemctl reload nginx
curl -I https://example.com
Hardening doesn't end when the config is written. Verify with at least these tools:
| Tool | What to check |
|---|---|
| SSL Labs Server Test | TLS versions, certificate chain, rating |
| Mozilla Observatory | Security headers, CSP, HSTS |
| SecurityHeaders.com | Whether headers are missing |
| curl -I | Whether actual responses carry headers |
| nginx -t | Whether config syntax is correct |
Local check:
curl -I https://example.com | grep -Ei "strict|content-security|x-frame|referrer|permissions"
If you use Cloudflare, test both:
- The domain accessed through Cloudflare;
- Direct connection to the origin IP (if you can connect directly, origin protection isn't done yet);
- Real business pages, not just the homepage.
No. Headers should serve the business. Headers like CSP, COOP, and COEP can affect third-party scripts, iframes, payments, analytics, and logins. If you don't understand the impact, start with basic headers, then tighten gradually.
Yes. Cloudflare is the first layer; Nginx is the last door at the origin. Especially for business paths like admin, API, and Webhook, local Nginx rate limiting avoids rule gaps or direct origin connections.
For ordinary small VPS sites, it's not recommended to enable it from the start. First confirm all subdomains have HTTPS, then gradually increase max-age. Once preload mistakenly enters the list, recovery is very slow.
It only reduces information exposure; it can't replace upgrades. What really matters is updating Nginx, OpenSSL, the system kernel, and upstream applications promptly.
Caddy does automatic HTTPS very well, but security headers, rate limiting, origin protection, and admin access control still need to be configured per business. What Caddy saves is certificate management, not all security work.
Nginx security hardening on a VPS isn't about copying the "strongest config" from the internet, but closing things down in risk order: first reduce the exposure surface, then fix TLS, then add security headers, then rate-limit high-risk paths, and finally verify with external tools.
For most small sites, self-hosted tools, and independent developers, doing these already avoids a large number of low-cost scans, misconfigurations, and direct origin connection risks. A truly long-term stable VPS isn't one that's never attacked, but one where every layer has boundaries, logs, and room to roll back.