
Most “web-origin” VPS compromises don’t start with a zero-day. They start with permissive Nginx defaults, noisy PHP errors, weak TLS, and bots hammering /wp-login.php thousands of times per hour.
This web server hardening tutorial gives you a production-safe baseline for Nginx + PHP-FPM on Ubuntu or Debian. You’ll get commands and config you can paste, test, and roll back.
You’re not trying to make attacks impossible. You’re raising the cost of common abuse. You’re also limiting blast radius when something slips through. And you’re keeping deploys predictable.
What you’ll build (and what you need)
- Hardened TLS (HTTP/2, modern ciphers, OCSP stapling where applicable)
- Security headers that don’t break common apps
- Request filtering for risky paths and file types
- Rate limits for login endpoints and bot-heavy routes
- PHP-FPM isolation: better defaults for file access and execution
- Safe change workflow: lint, reload, and rollback patterns
Assumptions: Ubuntu 24.04 LTS+ or Debian 12+, Nginx 1.24+ (or vendor build), PHP 8.2/8.3 with PHP-FPM, and root or sudo access.
If you want the same controls without owning the OS chores, start with a managed VPS hosting plan from HostMyCode. Support can then handle kernel updates, service restarts, and recovery work.
Step 1: Baseline your VPS before you harden
Hardening goes faster when you know what’s running now. Capture versions, listening ports, and service status before you change anything.
uname -a
lsb_release -a 2>/dev/null || cat /etc/os-release
nginx -v
php-fpm8.3 -v 2>/dev/null || php-fpm8.2 -v 2>/dev/null || php-fpm -v
ss -lntup
systemctl status nginx --no-pager
systemctl status php8.3-fpm --no-pager 2>/dev/null || systemctl status php8.2-fpm --no-pager
Quick check: if you see Nginx listening on 0.0.0.0:8080 or a random port you forgot you opened, stop and inventory your sites and services.
An accidental open listener beats “missing headers” as a real-world risk.
Step 2: Patch the OS and reduce exposed services
Patch first. Hardening on top of an unpatched system just gives attackers more time.
sudo apt update
sudo apt -y full-upgrade
sudo apt -y autoremove
sudo reboot
After reboot, confirm you’re only exposing what you intend to expose. For most VPS web stacks, that’s 22/tcp (or a custom SSH port), plus 80/tcp and 443/tcp.
sudo ss -lntp | awk 'NR==1 || /:22 |:80 |:443 /'
If your firewall policy is still loose, follow HostMyCode’s step-by-step guide: iptables/nftables tutorial for a production firewall.
Step 3: Install (or standardize) Nginx, PHP-FPM, and tooling
Distro packages cover most workloads. Add a few small tools so testing and troubleshooting stays quick.
sudo apt -y install nginx curl openssl ca-certificates unzip
sudo apt -y install php-fpm php-cli php-curl php-mbstring php-xml php-zip php-gd
Tip: keep one PHP version per server unless you truly need multiple.
Every extra runtime is another place to miss a patch. It’s also another pool to misconfigure.
Step 4: Create a hardened TLS profile for Nginx
Put TLS settings in an include file instead of copying them into every vhost. This keeps behavior consistent across domains. It also makes changes easier to audit.
Create /etc/nginx/snippets/tls.conf:
sudo nano /etc/nginx/snippets/tls.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
# Modern curves. Keep it simple.
ssl_ecdh_curve X25519:secp256r1;
# HSTS: enable only when you're sure you won't need HTTP.
# Start with a small max-age; increase later.
add_header Strict-Transport-Security "max-age=86400; includeSubDomains" always;
# OCSP stapling (requires a valid resolver and full chain).
ssl_stapling on;
ssl_stapling_verify on;
resolver 1.1.1.1 9.9.9.9 valid=300s;
resolver_timeout 5s;
Common pitfall: setting long HSTS (max-age=31536000) on day one.
If any subdomain still uses HTTP (or you plan to move webmail or legacy services), you can lock yourself into ugly failures.
Start at one day. Extend only after you’ve verified the full subdomain list and renewals.
If you need a clean path for certificates and renewal troubleshooting, use: SSL certificate setup guide tutorial.
Step 5: Add security headers that won’t break most sites
Headers won’t “secure” a vulnerable app. They do remove common foot-guns and reduce browser-side risk.
Keep them in one file so every site gets the same baseline.
Create /etc/nginx/snippets/security-headers.conf:
sudo nano /etc/nginx/snippets/security-headers.conf
# Avoid MIME sniffing
add_header X-Content-Type-Options "nosniff" always;
# Limit referrer leakage (safe default)
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Clickjacking protection (may affect iframed apps)
add_header X-Frame-Options "SAMEORIGIN" always;
# Basic XSS protections (legacy but harmless)
add_header X-XSS-Protection "0" always;
# Permissions Policy: conservative; extend per app needs
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
About CSP: Content-Security-Policy can be extremely effective. It can also break checkout flows, embedded widgets, and analytics fast.
Add CSP per application. Test it in report-only mode, then tighten it over time.
Step 6: Tighten Nginx core config (safe defaults)
Next, set a few global defaults. Hide version strings and keep request sizes sane.
Set timeouts. Also make request parsing less forgiving.
Edit /etc/nginx/nginx.conf:
sudo nano /etc/nginx/nginx.conf
Add or confirm these within the http { } block:
server_tokens off;
# Basic DoS friction. Adjust for your app.
client_max_body_size 32m;
client_body_timeout 10s;
client_header_timeout 10s;
keepalive_timeout 30s;
send_timeout 30s;
# Safer parsing
ignore_invalid_headers on;
# Logging: keep a real client IP if you're behind a proxy/CDN (configure properly per provider)
# set_real_ip_from 127.0.0.1;
# real_ip_header X-Forwarded-For;
Checklist: if you run WooCommerce with big imports, you may need client_max_body_size at 64m or 128m.
Avoid setting 1G “just in case.” Huge limits tend to become expensive mistakes.
Step 7: Build a hardened server block (HTTP → HTTPS, safe locations)
Pick one “known good” vhost and reuse it as a template. The example below is a practical baseline for a PHP site (WordPress-friendly).
It blocks common probes and refuses dotfiles.
Create /etc/nginx/sites-available/example.com:
sudo nano /etc/nginx/sites-available/example.com
server {
listen 80;
server_name example.com www.example.com;
# ACME challenge for Let's Encrypt
location ^~ /.well-known/acme-challenge/ {
root /var/www/example.com/public;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.php index.html;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/nginx/snippets/tls.conf;
include /etc/nginx/snippets/security-headers.conf;
# Prevent serving hidden files (.env, .git, etc.)
location ~ /\. { deny all; }
# Block common backup/config file extensions
location ~* \.(bak|conf|dist|ini|log|old|orig|sql|swp|zip|tar|gz)$ { deny all; }
# Basic WordPress/PHP routing
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
# Adjust socket for your PHP version
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
# Hard stop on path traversal and weird PATH_INFO behavior
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_intercept_errors on;
# Avoid huge timeouts. Keep them realistic.
fastcgi_read_timeout 60s;
}
# Static files: cache and reduce load
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|ico|woff2)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
access_log off;
try_files $uri =404;
}
}
Enable the site and run a config test:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
If validation passes, reload so active connections stay up:
sudo systemctl reload nginx
Step 8: Add targeted rate limits for login endpoints and bot pressure
Global rate limiting looks attractive. Then it quietly breaks APIs, payment callbacks, and legitimate traffic spikes.
Aim at hotspots instead.
Good targets are login forms, XML-RPC (if you still use it), and endpoints that trigger expensive work.
In /etc/nginx/nginx.conf inside http { }, define zones:
limit_req_zone $binary_remote_addr zone=login_zone:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=general_zone:10m rate=120r/m;
Then inside your HTTPS server block:
# General friction for obvious floods
location / {
limit_req zone=general_zone burst=60 nodelay;
try_files $uri $uri/ /index.php?$args;
}
# WordPress login
location = /wp-login.php {
limit_req zone=login_zone burst=5 nodelay;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
# If you don't use XML-RPC, block it.
location = /xmlrpc.php { deny all; }
For a deeper pattern (plus notes on avoiding collateral damage), see: Nginx rate limiting tutorial.
Step 9: Harden PHP-FPM to reduce blast radius
Nginx is only the front door. PHP-FPM decides what PHP can touch, what it logs, and how it behaves under load.
Open your pool config (common path on Ubuntu/Debian):
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Apply these baseline settings. Then tune them to fit your server and apps:
; Don't expose PHP version in headers
php_admin_value[expose_php] = Off
; Stop PHP from executing files outside your docroot via path tricks
security.limit_extensions = .php
; Limit upload sizes at PHP layer (should align with Nginx)
php_admin_value[upload_max_filesize] = 32M
php_admin_value[post_max_size] = 32M
; Avoid leaking paths and SQL details to users
php_admin_value[display_errors] = Off
php_admin_value[log_errors] = On
php_admin_value[error_log] = /var/log/php8.3-fpm/www-error.log
; Basic resource safety (tune per server size)
pm = dynamic
pm.max_children = 30
pm.start_servers = 6
pm.min_spare_servers = 6
pm.max_spare_servers = 12
pm.max_requests = 500
; Reduce risky functions (validate your app first)
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
Create the PHP log path and set permissions:
sudo touch /var/log/php8.3-fpm/www-error.log
sudo chown www-data:adm /var/log/php8.3-fpm/www-error.log
sudo chmod 0640 /var/log/php8.3-fpm/www-error.log
Reload PHP-FPM:
sudo systemctl reload php8.3-fpm
Reality check: disabling proc_open and friends can break PDF generators, image tools, and some backup plugins.
If you host multiple customers, validate per plan. Also write down what you block and why.
Step 10: Basic WAF coverage with ModSecurity (optional, controlled rollout)
On multi-site VPS setups, a WAF can soak up generic exploit noise. The common failure mode is enabling aggressive rules without exclusions.
Then you discover the admin UI can’t save posts.
For cPanel servers, HostMyCode covers an OWASP CRS rollout in WHM here: cPanel ModSecurity setup guide.
On a pure Nginx stack (no cPanel), start with what you already implemented. That means focused rate limiting and strict location rules.
Add ModSecurity only after your logging is clean and you understand baseline traffic.
If you deploy CRS, keep it in “DetectionOnly” for at least a week. Review alerts before you block.
Step 11: Safe deploy workflow: validate, reload, and keep an escape hatch
Hardening stays low-risk when you can undo mistakes fast. Treat config changes like code changes.
Test them, apply them safely, and keep a rollback nearby.
- Validate Nginx config:
nginx -tbefore every reload - Reload, don’t restart:
systemctl reload nginxkeeps connections alive - Keep last-known-good copies: copy config files before edits
# Example: backup then edit
sudo cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date +%F-%H%M)
sudo nginx -t && sudo systemctl reload nginx
If you want fewer surprises, add monitoring that tells you quickly when a reload fails or a certificate is near expiry.
This pairs well: server monitoring setup guide tutorial.
Step 12: Post-hardening verification (real tests you should run)
Don’t eyeball configs and assume you’re done. Run direct tests from your laptop.
Then watch logs while you do it.
Confirm HTTP redirects to HTTPS
curl -I http://example.com/ | head
Confirm headers are present
curl -I https://example.com/ | egrep -i 'strict-transport-security|x-content-type-options|referrer-policy|x-frame-options|permissions-policy'
Confirm blocked dotfiles and backups
curl -I https://example.com/.env
curl -I https://example.com/backup.sql
Confirm TLS basics
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates -issuer -subject
Watch logs while you test
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
Diagnostic tip: if WordPress admin pages trigger upstream sent too big header, it’s usually cookie bloat.
Fix it per vhost by raising fastcgi_buffer_size and fastcgi_buffers. Avoid making it a global change.
Hardening checklist you can keep for future sites
- OS patched, rebooted, and only required ports exposed
- Nginx:
server_tokens off, sensible timeouts, reasonable body limits - TLS: TLS 1.2/1.3 only, HSTS staged, cert renewals monitored
- Headers: nosniff, referrer policy, frame options, permissions policy
- Locations: deny dotfiles, deny backup extensions, restrict PHP execution
- Rate limits: focused on login endpoints and expensive routes
- PHP-FPM: log errors, no display errors, max_requests set, functions reviewed
- Change safety:
nginx -tbefore reload, backups of configs
Rolling these controls out across multiple sites gets easier when your infrastructure behaves predictably and recovery is straightforward. Deploy on a HostMyCode VPS for full root access, or use managed VPS hosting if you want help with patching, service health, and incident response.
FAQ
Will these settings break WordPress or WooCommerce?
The header set is intentionally conservative. The two most common break points are (1) rate limits that catch checkout/API calls and (2) disabled PHP functions that a plugin depends on.
Roll out limits by path, then watch logs during peak traffic.
Should I enable HSTS for a year immediately?
No. Start with a short max-age (like one day). Increase it once you’ve confirmed all subdomains are HTTPS-ready and certificate renewals are stable.
Do I need ModSecurity if I already rate limit?
Rate limiting helps with floods and brute-force pressure. A WAF helps catch common exploit payloads and bots that rotate IPs.
If you can’t spend time on false positives, start with rate limiting and strict locations first.
What’s the fastest rollback if I lock myself out with a bad Nginx change?
Use your VPS console, restore the last known good config copy, run nginx -t, then systemctl reload nginx. Timestamped config backups make this a two-minute recovery.
Summary: a hardened baseline you can reuse
This web server hardening tutorial walked you through a reusable Nginx + PHP-FPM baseline: tighter TLS defaults, a header set that won’t generate constant support tickets, rate limits aimed at common bot pressure points, and PHP-FPM settings that reduce damage when something goes wrong.
If you’d rather focus on your sites than maintenance windows, run this stack on managed VPS hosting from HostMyCode. You keep control of your apps, and you get help handling the operational sharp edges.