vps·web
EN

Apache mod_remoteip Behind Nginx Proxy Manager

Apache mod_remoteip + Nginx Proxy Manager — Real IPs in Logs

Server · Apache mod_remoteip Behind Nginx Proxy Manager

Apache mod_remoteip Behind Nginx Proxy Manager — Real Client IP in Logs

Apache sitting behind Nginx Proxy Manager logs every request as coming from the proxy's IP. Your access log fills up with 192.168.x.x, fail2ban bans the proxy instead of attackers, and $_SERVER['REMOTE_ADDR'] in PHP returns the wrong address. The fix is mod_remoteip — an Apache 2.4 module that rewrites the connection's client IP based on the X-Forwarded-For header coming from a trusted upstream. This guide gives you a working configuration, explains why each directive matters, and lists the mistakes that will leave you staring at a wrong IP for an hour.

The Nginx & Apache2 Remote IP generator — what it does and who it's for

The Nginx & Apache2 Remote IP generator on vps-web.com is built for one job: produce a ready-to-paste set of commands that enables mod_remoteip on Apache2 and trusts your Nginx Proxy Manager as the upstream that delivers X-Forwarded-For. You give it the IP of the Nginx Proxy Manager server, and it emits five copy-paste blocks — enable the module, create /etc/apache2/conf-available/remoteip.conf, enable that config, patch the default LogFormat so the real IP lands in your access log, then reload Apache after a syntax check.

It's useful in three setups: an Nginx Proxy Manager VM forwarding to an Apache backend (the classic homelab pattern), a Docker stack where Apache lives behind an NPM container, and a small VPS where you use NPM for SSL termination and route to a local Apache. The generator assumes a fixed Nginx upstream — for Cloudflare or other CDNs, the trusted-proxy list looks different and you'd want to use full IP ranges, not a single host.

If you prefer to read the generated output and hand-edit, the form is short — one field for the proxy IP and a Generate button. The output is shell commands, not config snippets you copy into editors. That's deliberate: it sidesteps the most common mistake (forgetting a2enconf after creating the file).

Hands-on — configuring mod_remoteip on Apache2 behind Nginx Proxy Manager

The setup involves four things: enable the module, tell Apache which header to read, tell Apache which upstream to trust, and update the log format so the real IP actually appears in the logs. Skip any one of them and the configuration silently misbehaves.

Step 1: Enable mod_remoteip

sudo a2enmod remoteip

mod_remoteip ships with Apache 2.4 but is disabled by default on Debian and Ubuntu. The command symlinks /etc/apache2/mods-available/remoteip.load into mods-enabled/. No restart yet — you'll reload after the config is in place.

Step 2: Create the remoteip configuration

The two directives that matter are RemoteIPHeader (which incoming header carries the client IP) and RemoteIPTrustedProxy (which upstreams are allowed to set that header). Put them in a dedicated config file under conf-available/:

sudo tee -a /etc/apache2/conf-available/remoteip.conf > /dev/null <<'EOF'
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 192.168.23.10
EOF

Replace 192.168.23.10 with the actual address of your Nginx Proxy Manager. Use the IP that NPM uses on the network where Apache lives — if NPM and Apache are on the same Docker network, that's the container's network IP, not the host.

RemoteIPHeader X-Forwarded-For instructs Apache to look at the X-Forwarded-For header and replace the connection's client IP with the value found there. Nginx Proxy Manager sets this header by default in its generated configs (proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;), so there's nothing to change on the NPM side.

RemoteIPTrustedProxy 192.168.23.10 whitelists which hosts Apache should believe. Without this directive, mod_remoteip trusts anyone who sends X-Forwarded-For — a serious problem, because any client can craft that header and impersonate any IP. With it, Apache rewrites the IP only when the actual TCP connection comes from the listed proxy.

A subtle distinction: RemoteIPTrustedProxy rejects RFC 1918 addresses (10/8, 172.16/12, 192.168/16) in the forwarded header by default — it assumes those are bogus values injected by misconfigured upstreams. If your NPM is on a private subnet and you also want to log a client that genuinely sits inside that subnet, use RemoteIPInternalProxy instead. The Apache mod_remoteip documentation covers the full directive list.

Step 3: Activate the configuration

sudo a2enconf remoteip

This symlinks conf-available/remoteip.conf to conf-enabled/. The file isn't loaded automatically just because it exists in conf-available/. This is the step most tutorials forget — you create the file, restart Apache, and nothing changes, because the file was never activated.

Step 4: Update LogFormat to show the real IP

Apache's default combined LogFormat starts with %h — the hostname or IP of the connecting client. After mod_remoteip does its job, both %h and %a reflect the rewritten IP. But there's a long-standing quirk: %h may still resolve from the original connection in some build/version combinations, while %a is guaranteed to use the post-rewrite IP. The reliable fix is to swap %h for %a in the combined LogFormat:

sudo sed -i.bak 's|^LogFormat "%h %l %u %t \\"%r\\" %>s %O \\"%{Referer}i\\" \\"%{User-Agent}i\\"" combined|LogFormat "%a %l %u %t \\"%r\\" %>s %O \\"%{Referer}i\\" \\"%{User-Agent}i\\"" combined|' /etc/apache2/apache2.conf

The .bak suffix saves a backup of apache2.conf next to the original — handy if the substitution misfires on a customized file. If you want to keep the original IP of the connection (the proxy's IP) somewhere in the log for debugging, use %{c}a — that's documented as "the underlying client IP of the connection" and reads as the literal TCP peer, ignoring mod_remoteip's rewrite.

Step 5: Syntax check and reload

sudo apache2ctl configtest && sudo systemctl reload apache2

configtest parses every active config file and surfaces typos before they take down the server. The && ensures reload only runs if the test passes — a small habit that prevents broken reloads. Reload (not restart) keeps existing connections alive while picking up the new module and config.

After the reload, hit your site through Nginx Proxy Manager and tail the access log:

sudo tail -f /var/log/apache2/access.log

You should now see your real client IP at the start of each line, not the proxy's address. If you're testing from inside the same LAN, use curl --resolve from a phone on cellular or a remote shell to confirm the rewrite works for external addresses — testing only from inside the proxy's subnet hides whether RemoteIPInternalProxy rules apply.

Common mistakes and pitfalls

Forgetting a2enconf after creating the file

You drop remoteip.conf into /etc/apache2/conf-available/, reload Apache, and nothing changes. The file lives in conf-available/ for a reason: it's available, not enabled. Run sudo a2enconf remoteip to symlink it into conf-enabled/. Same logic as a2ensite and sites-available/ — Debian and Ubuntu split the layout to make it easy to toggle configs on and off.

Logs still show the proxy IP after a correct config

Two causes. First, you used the default combined LogFormat that begins with %h, and a non-standard package or build is resolving %h from the raw connection — switch to %a (covered above). Second, your RemoteIPTrustedProxy value doesn't match the actual source of the connection. Run tcpdump -i any -n port 80 while sending a test request through NPM and confirm the TCP source address Apache sees — that's the IP that must appear in RemoteIPTrustedProxy, not NPM's public-facing address.

mod_remoteip rewrites the IP to a private address

If X-Forwarded-For contains 10.0.0.5 and you used RemoteIPTrustedProxy, Apache rejects the rewrite — RemoteIPTrustedProxy treats private-range IPs in the header as untrustworthy and falls back to the proxy's address. For setups where the client genuinely lives on a private subnet (homelab, corporate LAN), switch the directive to RemoteIPInternalProxy 192.168.23.10. That version trusts the full header content, including RFC 1918 ranges.

Nginx Proxy Manager doesn't send X-Forwarded-For

By default, NPM's generated server blocks include proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; for every proxy host. If your logs still show the proxy IP and Apache config is correct, edit the proxy host in NPM, open the Advanced tab, and add the explicit headers — some older NPM versions or custom locations skip the defaults. While there, double-check that X-Real-IP and X-Forwarded-Proto are also set. The Nginx Proxy Manager discussion on real client IPs covers cases where the default location block doesn't propagate them.

Multiple proxies in the chain

If NPM itself sits behind another proxy (Cloudflare, a load balancer), X-Forwarded-For arrives as a comma-separated list: 203.0.113.5, 192.168.23.10. Apache walks this list right-to-left and skips every entry that matches RemoteIPTrustedProxy until it finds an untrusted one — that becomes the new client IP. If you want Cloudflare-originated requests to credit the real client, add Cloudflare's IP ranges to RemoteIPTrustedProxy. Cloudflare publishes its current IP ranges — the file changes often, so script the update rather than pasting once.

apache2ctl configtest returns Syntax error on line N

Usually a stray quote inside the heredoc, or a copy-paste with smart quotes from a blog post. Re-run the tee command with <<'EOF' (single-quoted heredoc) to disable variable expansion and avoid the shell trying to interpret $ inside the config. Then re-test. If configtest complains about an unknown directive RemoteIPHeader, the module isn't loaded — go back to a2enmod remoteip and check /etc/apache2/mods-enabled/ for the symlink.

fail2ban still bans the proxy

fail2ban reads the access log. If you fixed the LogFormat after fail2ban already started, it has cached jail state pointing at the proxy IP. Restart fail2ban (sudo systemctl restart fail2ban) and then verify with sudo fail2ban-client status apache-auth that the banned IPs are real client addresses, not your NPM host. If they still aren't, the regex in the jail's failregex may be anchored to the %h field — adjust to match the new log layout.

FAQ

Do I need mod_remoteip if I'm running Apache directly without a reverse proxy?

No. mod_remoteip only matters when Apache sits behind something — Nginx, HAProxy, a load balancer, a CDN. Without an upstream sending X-Forwarded-For, the directives do nothing harmful but also nothing useful. The connection's TCP source IP is already the real client.

What's the difference between RemoteIPTrustedProxy and RemoteIPInternalProxy?

RemoteIPTrustedProxy rejects RFC 1918 addresses (10/8, 172.16/12, 192.168/16, 169.254/16, 127/8) when they appear inside the X-Forwarded-For header — the assumption is that public clients shouldn't be coming from private ranges. RemoteIPInternalProxy skips that filter and accepts any IP in the header. Use Internal when the client itself sits on a private subnet (homelab, office LAN).

Should I set RemoteIPHeader to X-Real-IP or X-Forwarded-For?

Use X-Forwarded-For. It's the de-facto standard, it carries the full proxy chain (not just the last hop), and Nginx Proxy Manager sets it by default. X-Real-IP is single-valued and loses information when there's more than one proxy. Only switch to X-Real-IP if you control the entire chain and need stricter semantics — and even then, prefer X-Forwarded-For for consistency with the rest of the ecosystem.

Can mod_remoteip be exploited if I forget RemoteIPTrustedProxy?

Yes. Without RemoteIPTrustedProxy (or RemoteIPInternalProxy), mod_remoteip trusts every client that sends X-Forwarded-For. An attacker can forge the header and impersonate any IP — bypassing Require ip rules, poisoning your logs, and tricking PHP applications that read $_SERVER['REMOTE_ADDR']. Always specify the trusted proxy. If you don't know the upstream IP, the module shouldn't be enabled yet.

How do I verify the configuration without restarting?

Use sudo apache2ctl configtest — it parses every active config and reports syntax errors. For a runtime check, write a tiny PHP test file that prints $_SERVER['REMOTE_ADDR'] and $_SERVER['HTTP_X_FORWARDED_FOR'], hit it through NPM from an external network, and compare. REMOTE_ADDR should be your real IP; HTTP_X_FORWARDED_FOR should contain at least your real IP and (if NPM doesn't rewrite the chain) nothing else.

Why is %a better than %h in the LogFormat?

Both should show the same value after mod_remoteip rewrites the connection, but %a is explicitly documented to return the client IP recorded in the connection record — which is the rewritten value. %h historically meant "remote hostname or IP if HostnameLookups is off" and some build/version combinations have surfaced edge cases where it doesn't track the rewrite. %a removes the ambiguity. If you want the original TCP peer for debugging, use %{c}a.

Does this work with HTTPS terminated at Nginx Proxy Manager?

Yes, and it's the most common setup. NPM does SSL termination, talks plain HTTP to Apache on the backend, and forwards X-Forwarded-For and X-Forwarded-Proto headers. Apache uses mod_remoteip for the IP and (optionally) SetEnvIf X-Forwarded-Proto https HTTPS=on if a downstream application needs to know the original request was HTTPS. WordPress in particular wants this to generate correct asset URLs.

Can I use mod_remoteip with Docker containers?

Yes. The same config works inside a Dockerized Apache. The catch is that "the proxy's IP" is now the Docker network address of NPM (or whatever proxy), not its host or public address. Run docker network inspect <network> to find it, set RemoteIPTrustedProxy accordingly, and persist the configuration in your image build or a mounted volume — Apache configuration changes inside a running container don't survive recreation.

Next steps

Generate your configuration with your Nginx Proxy Manager address pre-filled at the Nginx & Apache2 Remote IP generator on vps-web.com — the output is five copy-paste commands. Pair it with the VirtualHost generator on vps-web.com for the matching site config.

There's a video walkthrough of related reverse-proxy and Apache setups on my YouTube channel — with live demos on actual servers. If this guide saved you an afternoon, subscribing helps more guides like it land.