Core principle
On a multi-domain server, a misconfiguration in one site affects all of them. The goal of this checklist is not exhaustive hardening — it is eliminating the most common, high-impact vulnerabilities at the Nginx and Cloudflare layer before they become incidents.
All examples assume Ubuntu 24 with Nginx, behind Cloudflare (proxied), serving a mix of Next.js and PHP applications. See Next.js 15 on a Hetzner VPS for the deployment configuration these rules build on.
Nginx configuration
Block sensitive files across all server blocks
The highest-priority rule. .env, .git, composer.json, and related files must never be served publicly under any circumstances.
# Add to every server block — or to a shared include (see below)
location ~* (\.env|\.git|composer\.(json|lock)|package(-lock)?\.json|webpack\.config\.js|Dockerfile) {
deny all;
return 404;
}
location ~ /\. {
deny all;
return 404;
}
Add this to every server block. On a multi-domain setup it is easy to add the rule to three blocks and miss the fourth.
An exposed .env file leaks database credentials, SMTP API keys, payment provider secrets, and third-party service tokens in a single HTTP request. This is one of the most common server-level incidents for self-hosted SaaS — and one of the most preventable.
Disable version exposure
# In nginx.conf, inside http {}
server_tokens off;
Stops Nginx from advertising its version number in headers and error pages. Attackers targeting known CVEs for a specific version need that version number first.
Security headers
Add to each server block or to a shared include file:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
For HTTPS-only domains (all of them):
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Do not add HSTS until every subdomain on the domain is confirmed HTTPS. Once a browser caches the HSTS header it will refuse HTTP connections to any subdomain for the full max-age duration.
Rate limiting
# In nginx.conf, inside http {}
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
# In the relevant server block
location /api/auth/login {
limit_req zone=login burst=3 nodelay;
}
location /api/ {
limit_req zone=api burst=10 nodelay;
}
Disable unused HTTP methods
if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) {
return 405;
}
Adjust to match what your application actually uses.
Upload size limits
client_max_body_size 10M;
Set per application. A dental clinic platform accepting X-ray uploads needs a higher limit than a QR menu system.
Cloudflare configuration
SSL/TLS: Full (Strict)
Under SSL/TLS → Overview, set the mode to Full (Strict). This validates the SSL certificate on your origin server. Flexible mode sends unencrypted traffic from Cloudflare to your server — do not use it.
Force HTTPS
Under SSL/TLS → Edge Certificates:
- Enable Always Use HTTPS
- Enable Automatic HTTPS Rewrites
- Set Minimum TLS Version to TLS 1.2
Lock origin access to Cloudflare IPs
Direct requests to your server IP bypass Cloudflare entirely. Block them:
# Cloudflare IPv4 — verify current list at cloudflare.com/ips
allow 173.245.48.0/20;
allow 103.21.244.0/22;
allow 103.22.200.0/22;
allow 103.31.4.0/22;
allow 141.101.64.0/18;
allow 108.162.192.0/18;
allow 190.93.240.0/20;
allow 188.114.96.0/20;
allow 197.234.240.0/22;
allow 198.41.128.0/17;
allow 162.158.0.0/15;
allow 104.16.0.0/13;
allow 104.24.0.0/14;
allow 172.64.0.0/13;
allow 131.0.72.0/22;
# Cloudflare IPv6
allow 2400:cb00::/32;
allow 2606:4700::/32;
allow 2803:f800::/32;
allow 2405:b500::/32;
allow 2405:8100::/32;
allow 2a06:98c0::/29;
allow 2c0f:f248::/32;
allow 127.0.0.1;
deny all;
Cloudflare's IP ranges change periodically. Review cloudflare.com/ips when change notifications arrive.
WAF rules
Under Security → WAF:
- Enable the Cloudflare Managed Ruleset — covers OWASP Top 10 and known CVEs
- For PHP applications: enable the PHP ruleset
- Create a custom rule blocking requests for
.env,.git, and paths your applications do not use (e.g.wp-adminif you are not running WordPress) - Add a rate-limiting rule at the Cloudflare level targeting authentication endpoints — this catches attack volume before it reaches the server
Bot Fight Mode
Under Security → Bots, enable Bot Fight Mode (available on the free plan). Blocks a large volume of automated scanning traffic upstream.
Multi-domain specific: the shared include pattern
On a multi-domain setup, security rules added manually to each server block drift over time. A new domain gets stood up quickly and the security block gets skipped. A shared include file prevents this:
# /etc/nginx/snippets/security.conf
server_tokens off;
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 Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location ~ /\. { deny all; return 404; }
location ~* (\.env|\.git|composer\.(json|lock)|package(-lock)?\.json) {
deny all;
return 404;
}
Include it in every server block:
server {
server_name example.com;
include snippets/security.conf;
# ... rest of config
}
Every new domain added to the server inherits the full security baseline automatically.
Environment variable isolation
On a server running multiple applications, ensure each .env file is:
- Outside the webroot —
/var/www/app-name/.env, not/var/www/app-name/public/.env - Owned by the application user with permissions
600 - Excluded from version control — confirm with
git check-ignore -v .env
If there is any reason to believe a .env was publicly accessible at any point: revoke and regenerate every credential in it immediately, review access logs for the exposure window, and check for unauthorised outbound email activity — spam sent via a compromised SMTP key is the most common immediate exploit.
Quick reference checklist
Nginx
[ ] server_tokens off
[ ] Sensitive file blocking (.env, .git, composer.json)
[ ] Dotfile blocking
[ ] Security headers (X-Frame-Options, HSTS, nosniff)
[ ] Rate limiting on login and API endpoints
[ ] client_max_body_size set per application
[ ] Shared include file used across all server blocks
Cloudflare
[ ] SSL/TLS set to Full (Strict)
[ ] Always Use HTTPS enabled
[ ] Minimum TLS 1.2
[ ] Origin locked to Cloudflare IPs only
[ ] Managed Ruleset (WAF) enabled
[ ] Bot Fight Mode enabled
[ ] Rate limiting rule on authentication endpoints
Summary
The configurations above are not optional hardening for cautious operators — they are the baseline for any SaaS running on a public-facing VPS. An exposed .env file, missing security headers, or an unlocked origin are incidents waiting to happen.
The shared include file pattern is the most leveraged change for a multi-domain setup: every new domain inherits the full security baseline without a manual check.
AKORNET runs four SaaS products across six domains on a single Hetzner VPS. This checklist reflects our production configuration. See our products at akor.net →