How to Set Up Nginx with Apache as a Reverse Proxy

How to Set Up Nginx with Apache as a Reverse Proxy

How to Set Up Nginx with Apache as a Reverse Proxy

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:

Requirements

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:

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

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;
}

Performance Tips

gzip on;
gzip_types text/html text/css application/javascript application/json;
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

Best Practices Recap

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.

Exit mobile version