This is a pairing that confuses people the first time they hear about it — “wait, why would you run two web servers together?” — but it’s actually one of the most common migration and legacy-support patterns I’ve dealt with. The typical scenario: you’ve got an existing site or application that depends on Apache-specific features — .htaccess files, mod_php, mod_rewrite rules built up over years, maybe a legacy CMS that only documents Apache configuration — and rewriting all of that for Nginx isn’t practical right now. But you still want Nginx’s strengths: fast static file serving, efficient handling of concurrent connections, and a lightweight footprint sitting at the edge.
The answer is to keep Apache doing what it already does well, move it off the public-facing port, and put Nginx in front of it as a reverse proxy. I’ve used this exact setup during a WordPress-heavy legacy migration where dozens of .htaccess rules would have taken real effort to port over, and this let the migration happen incrementally instead of all at once.
Why Combine Them Instead of Just Migrating Fully
A few legitimate reasons this pattern persists:
.htaccessdependency. Some CMSs and legacy apps assume per-directory Apache config files are available and writable by non-root processes (plugins, for instance). Nginx has no equivalent mechanism — its configuration is centralized and requires a reload to apply.mod_phpor specific Apache modules that don’t have a clean Nginx equivalent, or where the existing PHP setup is tightly coupled to Apache-specific behavior.- Incremental migration. You can move traffic to Nginx-served static assets and let Apache handle only the dynamic requests, gradually reducing Apache’s role over time instead of a risky big-bang rewrite.
- Best of both worlds, permanently, for some teams. Static files and TLS at Nginx; dynamic legacy app logic at Apache — some organizations just keep this as their long-term architecture rather than a stepping stone.
Requirements
- Ubuntu 22.04/24.04 server
- Apache installed and already serving your application (I’ll assume it’s currently listening on port 80)
- Sufficient RAM to run both web servers simultaneously — this isn’t heavy, but it’s not zero either
Step 1: Move Apache Off Port 80/443
First, reconfigure Apache to listen on a different, non-public port — commonly 8080. Edit the ports config:
sudo nano /etc/apache2/ports.conf
Listen 127.0.0.1:8080
Binding specifically to 127.0.0.1 (not 0.0.0.0) ensures Apache is only reachable from the local machine — Nginx will be the only path in from the outside world.
Update your virtual host to match:
sudo nano /etc/apache2/sites-available/000-default.conf
<VirtualHost 127.0.0.1:8080>
ServerName example.com
DocumentRoot /var/www/example.com/public
<Directory /var/www/example.com/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
Restart Apache:
sudo systemctl restart apache2
Confirm it’s now only listening locally:
sudo ss -tlnp | grep apache2
You should see 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080.
Step 2: Install Nginx
sudo apt update
sudo apt install nginx -y
Since Apache no longer holds port 80, there’s no conflict.
Step 3: Configure Nginx as the Reverse Proxy
sudo nano /etc/nginx/sites-available/example.com
upstream apache_backend {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name example.com www.example.com;
client_max_body_size 50M;
# Serve static assets directly via Nginx - skip Apache entirely for these
location ~* \.(jpg|jpeg|png|gif|ico|css|js|woff|woff2|svg|ttf)$ {
root /var/www/example.com/public;
expires 30d;
add_header Cache-Control "public";
try_files $uri @apache;
}
location / {
proxy_pass http://apache_backend;
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;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
location @apache {
proxy_pass http://apache_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Walking through the important pieces:
- The static file
locationblock with a regex matching common asset extensions — this is the whole point of adding Nginx in front. Instead of every image, stylesheet, and script request going all the way through Apache (and potentially PHP, ifmod_php-handled directories aren’t scoped carefully), Nginx serves these directly from disk. This alone is often the single biggest performance win of the entire setup. try_files $uri @apache;— if a requested static file doesn’t actually exist at that path (maybe it’s dynamically generated, or the extension match was a false positive), fall back to proxying to Apache rather than returning a 404 directly.X-Forwarded-*headers — critical for Apache to know the real client IP and original protocol, which matters for logging,REMOTE_ADDRin PHP, and any Apache-side logic (likemod_rewriteconditions) that inspects these values.
Enable the site:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Step 4: Fix Apache’s Logging and IP Awareness
By default, Apache will now log every request as coming from 127.0.0.1 (Nginx’s IP, from Apache’s perspective), which is useless for real traffic analysis. Fix this with mod_remoteip:
sudo a2enmod remoteip
sudo nano /etc/apache2/conf-available/remoteip.conf
RemoteIPHeader X-Forwarded-For
RemoteIPInternalProxy 127.0.0.1
sudo a2enconf remoteip
sudo systemctl restart apache2
Now update your Apache log format to use %a (which respects the remoteip module) instead of the default %h:
LogFormat "%a %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" proxied
CustomLog ${APACHE_LOG_DIR}/access.log proxied
Step 5: Add HTTPS at the Nginx Layer
TLS termination belongs at Nginx in this architecture — Apache never needs to know about certificates at all, since it only ever receives plain HTTP from Nginx over localhost.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
Certbot updates the Nginx config with the 443 ssl block and redirect automatically. Confirm your proxy_set_header X-Forwarded-Proto $scheme; line is present in both the HTTP and HTTPS server blocks so Apache-side code (checking $_SERVER['HTTPS'] in PHP, for example) reports HTTPS correctly even though the actual Apache↔Nginx hop is plain HTTP.
If your PHP app or CMS checks for HTTPS status directly, you may also need to configure it to trust the forwarded header — for WordPress specifically, this typically means adding to wp-config.php:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
Testing Your Setup
curl -I https://example.com
Test that both dynamic pages (routed through Apache) and static assets (served directly by Nginx) work correctly:
curl -I https://example.com/some-dynamic-page
curl -I https://example.com/images/logo.png
Check response headers to confirm which server actually handled a static asset — Apache’s presence or absence in the Server header, or simply the absence of Apache in the access logs for that specific request, confirms Nginx served it directly.
Verify Apache is logging real client IPs now, not 127.0.0.1 for every entry:
sudo tail -f /var/log/apache2/access.log
Troubleshooting Common Issues
502 Bad Gateway — Confirm Apache is actually running and listening on 127.0.0.1:8080:
sudo systemctl status apache2
sudo ss -tlnp | grep 8080
All Apache logs show 127.0.0.1 as the client — mod_remoteip isn’t enabled or configured correctly; revisit Step 4.
Infinite redirect loop on HTTPS pages — Almost always the X-Forwarded-Proto header isn’t being read correctly by the application (WordPress, Laravel, etc.), so the app thinks it’s still on HTTP and redirects to HTTPS again, which loops. Check your app-specific “trust the proxy” configuration.
.htaccess rules seem to be ignored — They aren’t ignored, since Apache still processes them exactly as before; but remember any rule that assumes it’s seeing the “real” original request (like certain rewrite conditions based on %{HTTPS} or %{REMOTE_ADDR}) needs the forwarded headers correctly interpreted, which again ties back to mod_remoteip and the X-Forwarded-Proto header.
Static assets 404ing through Nginx but working via Apache directly — Check the root path in the static asset location block actually matches Apache’s DocumentRoot; a mismatch here is an easy copy-paste mistake.
Large file uploads fail — Check client_max_body_size in Nginx and LimitRequestBody (if set) plus upload_max_filesize/post_max_size in PHP’s php.ini, since all three need to agree — the smallest limit in the chain wins.
Security Considerations
- Bind Apache strictly to
127.0.0.1. This is the single most important security step in this whole setup — if Apache is still reachable on a public interface, an attacker can bypass Nginx (and any protections configured there) entirely. - Set
server_tokens off;in Nginx andServerTokens Prod/ServerSignature Offin Apache to minimize version fingerprinting from both layers. - Don’t double-terminate TLS. Keep certificates only at Nginx; there’s no benefit and real added complexity to also configuring TLS on the internal Apache↔Nginx hop for a same-machine setup (if Apache and Nginx are on different machines/networks, that calculus changes — consider an internal TLS hop or a private network in that case).
- Rate limit at Nginx as the first line of defense before requests ever reach Apache/PHP:
limit_req_zone $binary_remote_addr zone=applimit:10m rate=15r/s;
location / {
limit_req zone=applimit burst=30 nodelay;
proxy_pass http://apache_backend;
}
- Keep both servers patched independently. Running two web servers means two sets of CVEs to track — don’t let Apache patching slide just because Nginx is now the public-facing one.
Performance Tips
- Let Nginx handle every static asset it possibly can — this is where most of the performance benefit of this architecture comes from. Audit your app’s asset directories and make sure the regex/location matching in Nginx actually covers everything it should.
- Enable gzip at Nginx rather than (or in addition to) Apache, since it’s already sitting at the edge:
gzip on;
gzip_types text/html text/css application/javascript application/json;
- Consider
proxy_cachein front of Apache for any pages that don’t need to be dynamically regenerated on every request — this can dramatically reduce the number of requests Apache/PHP ever has to handle (see the dedicated caching proxy guide in this series). - Tune Apache’s worker configuration (MPM settings) now that it’s only handling dynamic requests, not the full static+dynamic load it used to — you can often reduce Apache’s worker/process count since Nginx has taken over a meaningful share of the total request volume.
- Use
keepalivebetween Nginx and Apache for high-traffic setups:
upstream apache_backend {
server 127.0.0.1:8080;
keepalive 32;
}
Add proxy_set_header Connection ""; alongside it, same as with any other upstream keepalive configuration.
Real-World Use Cases
- A legacy WordPress migration, where dozens of accumulated
.htaccessrewrite rules from years of plugin installations weren’t worth manually porting to Nginx syntax immediately — Nginx handled all static assets and TLS, Apache handled WordPress/PHP exactly as it always had, with zero rewrite rule changes needed. - An old internal enterprise application built with heavy reliance on
mod_rewriteand specific Apache authentication modules that had no drop-in Nginx equivalent — this pairing let the organization modernize the edge (TLS, caching, rate limiting) without touching the fragile legacy app configuration at all. - A phased migration strategy, where a team used this setup as a deliberate stepping stone: six months of stability at this hybrid architecture, then a slow, careful rewrite of Apache-specific logic into pure Nginx + PHP-FPM once the team had bandwidth, with the public-facing behavior never changing for end users throughout the whole process.
Best Practices Recap
- Always bind Apache to
127.0.0.1— never leave it publicly reachable once Nginx is in front. - Let Nginx serve every static asset directly; only proxy genuinely dynamic requests to Apache.
- Fix Apache’s client-IP logging with
mod_remoteip— don’t accept useless127.0.0.1log entries as a permanent state. - Terminate TLS once, at Nginx, and forward
X-Forwarded-Protoso app-layer HTTPS checks still work correctly. - Keep both servers patched and monitored independently — this is genuinely two services, not one.
- Treat this as a valid long-term architecture where it fits, not only as a temporary migration crutch — plenty of production systems run this pattern indefinitely and reliably.
This setup isn’t the most modern-looking piece of infrastructure you’ll ever build, but it’s a pragmatic, well-tested way to get Nginx’s edge performance benefits without a risky rewrite of everything that currently depends on Apache underneath.
