
Putting Nginx in front of Apache is one of the simplest ways to make a busy VPS feel predictable again. This reverse proxy setup guide tutorial uses a production-friendly layout. Nginx handles TLS and static files. Apache (or cPanel’s Apache stack) keeps doing what it already does well: PHP apps and per-site config.
The payoff is practical. You get faster TLS negotiation and fewer long-lived connections inside Apache. You also get cleaner reloads and a single place to manage headers and caching.
Done right, you avoid common traps. That includes proxy loops, broken client-IP logging, and the classic “too many redirects.”
What you’re building (and why it works)
This guide uses a clean split of responsibilities:
- Nginx listens on
80/443(public), terminates SSL, serves static assets, and proxies dynamic requests. - Apache listens on
127.0.0.1:8080(private), serves the application, and keeps your existing virtual hosts logic.
On WordPress and other PHP sites, you’ll usually see fewer Apache worker pileups and calmer CPU usage. Nginx is better at keep-alives and static delivery.
The bigger day-to-day win is operational. TLS policy, caching, and “edge” headers live in one Nginx config. You no longer duplicate them across vhosts.
If you’re hosting multiple sites or planning to scale, start with a HostMyCode VPS. If you’d rather hand off updates and incident response, managed VPS hosting is the low-friction option.
Prerequisites and a quick pre-flight checklist
Assumptions in this guide:
- OS: Ubuntu 24.04/24.10 or Debian 12/13 (commands are compatible). This pattern also works on AlmaLinux/Rocky with small path differences.
- You have root access (or sudo).
- You have at least one domain pointing to the server (or you can test with a hosts file).
Pre-flight checklist (do this before you change ports)
- Confirm SSH access from a second terminal session (don’t lock yourself out).
- Confirm your current web server listens on
:80and/or:443. - Lower DNS TTL to 300 seconds if you plan a quick cutover later.
If you want a safe DNS cutover flow with rollback in mind, use: DNS cutover tutorial for low downtime.
Step 1 — Install Nginx and prepare the firewall
Install Nginx:
sudo apt update
sudo apt install -y nginx
If you use UFW, allow HTTP/HTTPS (and keep SSH allowed):
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw status
If you want a hosting-oriented firewall baseline that avoids the common “locked out” mistakes, follow: UFW firewall configuration tutorial.
Step 2 — Move Apache off ports 80/443 (keep it reachable)
This step decides whether the rollout is smooth or painful. The goal is simple.
Apache must stop binding to public ports so Nginx can take over cleanly.
On Ubuntu/Debian Apache (non-cPanel)
Edit /etc/apache2/ports.conf and change the Listen directives:
sudo nano /etc/apache2/ports.conf
Example:
Listen 127.0.0.1:8080
<IfModule ssl_module>
Listen 127.0.0.1:8443
</IfModule>
Next, update any vhosts that explicitly bind *:80 to 127.0.0.1:8080.
Or remove explicit binds and let ports.conf control the listen port.
Enable the proxy-related modules you’ll likely need:
sudo a2enmod remoteip headers rewrite proxy proxy_http ssl
Restart Apache and confirm what’s listening:
sudo systemctl restart apache2
sudo ss -lntp | egrep ':(80|443|8080|8443)\b'
You should see Apache on 127.0.0.1:8080 (and optionally 127.0.0.1:8443). Nothing should be bound to :80/:443.
On cPanel/WHM servers (Apache + EasyApache)
cPanel setups vary a lot. Many admins front cPanel Apache with Nginx using supported plugin approaches. Others adjust Apache listen ports and service management by hand.
If you’re running WHM and want to tighten the basics first, read: cPanel hardening tutorial.
On cPanel, avoid editing files that EasyApache will overwrite unless you’re sure what’s persistent.
The design stays the same either way. Nginx owns 80/443, and Apache moves to an alternate local port.
Plan on testing during a maintenance window.
Step 3 — Configure Nginx as the public reverse proxy
Create an Nginx server block for your site. On Ubuntu/Debian, the conventional layout is:
/etc/nginx/sites-available/example.com- symlink to
/etc/nginx/sites-enabled/
sudo nano /etc/nginx/sites-available/example.com
Start with HTTP only. Get routing correct first. Then add TLS.
Replace example.com and paths:
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
# If you want Let's Encrypt later, keep this reachable.
location ^~ /.well-known/acme-challenge/ {
root /var/www/letsencrypt;
default_type "text/plain";
}
# Static files: Nginx serves these directly.
location ~* \.(css|js|jpg|jpeg|png|gif|svg|ico|webp|avif|woff2?)$ {
root /var/www/example.com/public;
access_log off;
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri @apache;
}
location / {
try_files $uri @apache;
}
location @apache {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
# Keep proxy timeouts sane for PHP apps.
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_pass http://127.0.0.1:8080;
}
}
Enable it and test syntax:
sudo nginx -t
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/example.com
sudo systemctl reload nginx
Common pitfall: document roots don’t match
In this layout, Nginx serves static files directly from disk.
If Apache’s vhost points at a different DocumentRoot, Nginx won’t find those files. It will hand more requests to Apache instead.
That’s not fatal, but it defeats the static-file offload. If you’re not ready to standardize paths yet, remove the static location block for now and proxy everything.
Add static handling once your directory layout matches.
Step 4 — Fix “real client IP” logging on Apache
Without extra configuration, Apache will see Nginx as the client. It will log 127.0.0.1 for every request.
That breaks rate limits, WAF rules, and basic “who hit this endpoint?” troubleshooting.
Enable and configure mod_remoteip:
sudo a2enmod remoteip
Create a small config file:
sudo nano /etc/apache2/conf-available/remoteip.conf
Use this baseline (trust only the local proxy):
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 127.0.0.1
RemoteIPTrustedProxy ::1
Enable it and reload Apache:
sudo a2enconf remoteip
sudo systemctl reload apache2
Confirm Apache logs show your real public IP. In another terminal:
curl -I http://example.com
sudo tail -n 5 /var/log/apache2/access.log
Step 5 — Add TLS on Nginx (Let’s Encrypt) and force HTTPS safely
Terminating TLS at Nginx keeps renewals and cipher policy consistent across your sites.
Install Certbot with the Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
Issue the certificate:
sudo certbot --nginx -d example.com -d www.example.com
Certbot typically creates an HTTPS server block and adds an HTTP→HTTPS redirect. After that, test renewals:
sudo certbot renew --dry-run
If renewals fail, /.well-known/acme-challenge/ usually isn’t reachable.
Another common cause is incorrect server_name routing.
Keep this nearby: SSL renewal troubleshooting tutorial.
Redirect loops: the fix that actually works
If your app (WordPress, Laravel, etc.) sits behind Nginx and keeps bouncing between URLs, it usually can’t tell the original scheme.
You already set X-Forwarded-Proto. Now make sure the application respects it.
- WordPress: set
WP_HOME/WP_SITEURLtohttps://and ensure your reverse-proxy headers are correct. - Apache: if you run rewrite rules based on HTTPS, you may need to map forwarded scheme into an env var.
Step 6 — Add security headers at the edge (Nginx)
Fronting with Nginx is also a policy win. You define headers once, then reuse them across sites.
Add a shared snippet:
sudo nano /etc/nginx/snippets/security-headers.conf
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;
# Start with report-only for CSP if you’re not sure.
# add_header Content-Security-Policy-Report-Only "default-src 'self'" always;
Include it inside your HTTPS server block:
include /etc/nginx/snippets/security-headers.conf;
If you want a guided set of safer defaults (including HSTS/CSP guidance), follow: HTTP security headers tutorial.
Step 7 — Optional: micro-caching for PHP sites (safe, measurable gains)
If your pages are dynamic but not truly user-specific on every request, micro-caching can take the edge off traffic spikes.
Think of it as a small pressure valve for your origin, not a full-page cache replacement.
Add cache paths in /etc/nginx/nginx.conf inside the http block:
proxy_cache_path /var/cache/nginx/microcache levels=1:2 keys_zone=microcache:50m max_size=2g inactive=60m use_temp_path=off;
Then in your site’s location @apache:
# Micro-cache only successful responses for a short period.
proxy_cache microcache;
proxy_cache_valid 200 301 302 10s;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;
add_header X-Cache $upstream_cache_status;
Don’t micro-cache admin paths or logged-in sessions. If you’re proxying WordPress, add bypass rules:
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($request_uri ~* "/wp-admin/|/wp-login.php|/cart/|/checkout/|/my-account/") { set $skip_cache 1; }
if ($http_cookie ~* "wordpress_logged_in|woocommerce_items_in_cart|wp_woocommerce_session") { set $skip_cache 1; }
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
Validate with:
curl -I https://example.com/ | egrep -i 'x-cache|cache-control|server'
Step 8 — Compression and HTTP/2/HTTP/3 (quick wins, low risk)
Most modern Nginx builds ship with HTTP/2 support. HTTP/3 depends on your distro packaging and Nginx build flags.
If you don’t know what you’re running, start with HTTP/2. Then get compression right.
Enable gzip in /etc/nginx/nginx.conf:
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
Reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
Step 9 — Health checks, rollbacks, and “don’t break prod” habits
Reverse proxies fail in a few predictable ways. The difference between a five-minute fix and a long outage is usually fast diagnosis.
Quick diagnostic commands
- Check what’s listening where:
sudo ss -lntp | egrep ':(80|443|8080|8443)\b'
- Confirm Nginx can reach Apache:
curl -I http://127.0.0.1:8080
- Watch logs during a test request:
sudo tail -f /var/log/nginx/access.log /var/log/nginx/error.log
Rollback plan (keep it boring)
If traffic breaks and you need to revert fast:
- Stop Nginx:
sudo systemctl stop nginx - Move Apache back to port 80 (restore
/etc/apache2/ports.confand vhosts) - Restart Apache:
sudo systemctl restart apache2
This is also why you bind Apache to 127.0.0.1:8080 instead of a public interface.
You can iterate without exposing the origin directly.
Step 10 — Multi-site hosting: a clean pattern for multiple domains
For each new domain, you’ll create:
- An Apache vhost on
127.0.0.1:8080with its ownServerNameandDocumentRoot - An Nginx server block that routes that domain to Apache and optionally serves its static assets
If your Apache vhost layout is messy, fix that first.
This guide pairs well with: Apache virtual hosts tutorial.
Standardized paths and permissions make the proxy layer far easier to maintain.
Step 11 — Performance sanity checks after the cutover
You don’t need a lab-grade benchmark to confirm the proxy is doing its job.
Start with three checks that map to user impact and common failure modes:
- Time to first byte (TTFB) improves or stays stable. If it gets worse, Apache may be overloaded or caching is working against you.
- Apache concurrency drops under load because Nginx holds keep-alives and buffers clients.
- Error rate doesn’t climb (watch 499/502/504 in Nginx logs).
Useful commands:
# Nginx top URLs and status
sudo awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -nr | head
# Check for upstream errors
sudo egrep -R "upstream|502|504" /var/log/nginx/error.log | tail -n 50
If the server is already resource-starved, don’t “tune” your way out of it. Fix the bottleneck first.
A right-sized VPS is often the simplest performance move. For sustained high-traffic workloads, consider dedicated servers to remove noisy-neighbor risk.
Summary: the practical reverse-proxy checklist you’ll reuse
- Nginx owns
:80/:443; Apache moves to127.0.0.1:8080. - Proxy headers are set (
Host,X-Forwarded-For,X-Forwarded-Proto). - Apache logs real client IP via
mod_remoteip. - Let’s Encrypt runs on Nginx; renewals are tested.
- Security headers are applied once at the edge.
- You have a rollback plan that takes minutes, not hours.
If you want a platform that fits this pattern well, start with a HostMyCode VPS. If you’d rather not babysit patching, monitoring, or recovery runbooks, choose managed VPS hosting.
Running Nginx + Apache on one box works best when CPU and disk I/O stay steady and you’ve got headroom for caching. HostMyCode offers HostMyCode VPS plans that match this proxy layout well, plus migration help if you want a clean cutover with a rollback path.
FAQ
Should Apache listen on 8080 or 8081 (or something else)?
Any free port works. Use 127.0.0.1:8080 as a convention, then confirm nothing else binds it with ss -lntp.
Do I need to run Apache over HTTPS on 8443 behind Nginx?
Usually no. Terminate TLS at Nginx and proxy to Apache over localhost HTTP. It’s simpler, and localhost traffic isn’t exposed to the network.
Why do my logs show 127.0.0.1 after moving behind Nginx?
Apache is seeing Nginx as the client. Enable mod_remoteip and trust only 127.0.0.1 as a proxy, then log X-Forwarded-For.
Can I do this on a cPanel server?
Yes, but be cautious about edits that EasyApache may overwrite. Test in a window, and validate WHM/cPanel services after changing ports.
What’s the safest first test before switching DNS?
Use a temporary hosts-file override to hit the new server by domain name, validate SSL, logins, and redirects, then proceed with your DNS cutover.