Skip to content

Introduced support for load balancing through upstream hosts and customizable Real IP header source#5413

Open
genticflowlabs wants to merge 18 commits into
NginxProxyManager:developfrom
genticflowlabs:feature/real-ip-header-and-upstream-hosts
Open

Introduced support for load balancing through upstream hosts and customizable Real IP header source#5413
genticflowlabs wants to merge 18 commits into
NginxProxyManager:developfrom
genticflowlabs:feature/real-ip-header-and-upstream-hosts

Conversation

@genticflowlabs

@genticflowlabs genticflowlabs commented Mar 17, 2026

Copy link
Copy Markdown

Closes #5374
Closes #156
Heavily influenced by #5184

Why

This PR addresses two long-standing requests:

  1. Load balancing across multiple backend servers (Respect X-Forwarded-For or X-Real-IP for IP ACLs #5374, Support upstream / load balancing #156) — currently NPM proxies to a single host:port; behind that you have to roll your own HAProxy/nginx upstream block. Native support has been requested for years.
  2. Access Lists broken behind Cloudflare / CDNsreal_ip_header was hardcoded to X-Real-IP, so the IP whitelist on an Access List sees Cloudflare's IPs instead of the real client. Users had to manually edit nginx configs or give up on whitelisting.

To get there it also introduces a small piece of operational hardening: nginx host configs are regenerated from templates on every startup, so template changes ship cleanly across upgrades without leaving stale configs that break nginx on boot.

Upstream Hosts

image image image image image image

First-class entity for reusable nginx upstream groups with three load-balancing methods (round-robin, least connections, ip_hash) and per-server weights. Includes:

  • New tables: upstream_host, upstream_host_server, plus upstream_host_id FK on proxy_host
  • Internal logic, REST API at /api/nginx/upstream-hosts, access-control rules, schema definitions, audit-log entries
  • nginx template (upstream_host.conf); proxy hosts can select an upstream group at the host level or per location
  • Frontend: CRUD page + modal, server list management (host/port/weight), Direct/Upstream radio toggle on the Proxy Host modal and Custom Locations (with a searchable react-select dropdown)
  • Proxy host list shows the upstream host name (and per-location overrides) in the destination column

Real IP Header setting

image

New global setting under Settings → Real IP Header that controls the nginx real_ip_header directive:

  • Options: X-Real-IP (default), CF-Connecting-IP, X-Forwarded-For, or a custom header name (pattern-validated to block directive injection)
  • The directive is moved from the static nginx.conf into the dynamically generated ip_ranges.conf; changing the setting regenerates and reloads
  • Settings PUT schema accepts both default-site and real-ip-header values

Startup config regeneration

On every backend startup, host configs are regenerated in place from current templates. Regeneration is safe (overwrite in place; if a template fails, the previous good config stays on disk), and orphan cleanup runs only for hosts no longer in the DB. This lets the template format evolve across releases without breaking nginx on the user's next container restart.

Translations

New keys translated across all 22 supported locales (bg, cs, de, es, et, fr, ga, hu, id, it, ja, ko, nl, no, pl, pt, pt_br, ru, sk, tr, vi, zh).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Code refactoring
  • API changes
  • Performance improvement
  • Test addition or update

AI Usage

  • AI was used to write this
  • AI was used to review this

AI (Claude) was used as a development assistant for the implementation and for an independent QA pass against the prior round of review comments. All changes have been tested on a deployed instance; additional community QA across different configurations and environments would be welcome.

@DrEVILish

Copy link
Copy Markdown

I might be missing something, but does this only add LB to HTTP and HTTPS connections? Would this be able to LB other ports / TCP and UDP streams i.e.
https://docs.nginx.com/nginx/admin-guide/load-balancer/tcp-udp-load-balancer/
Use case would be adding LB to a DNS cluster, LB'ing / Failover from a single DNS is quicker than the client failing over to secondary DNS.

Any LB should implement health checks to ensure that the upstream hosts are online and available.

@jc21

jc21 commented May 14, 2026

Copy link
Copy Markdown
Member

Code Review — Upstream Hosts + Real IP Header

Thanks for this substantial contribution. The overall structure is solid and the test coverage is appreciated. A few issues need addressing before this can merge, ranging from blockers to low-priority fixes.


🔴 Critical

1. Nginx directive injection via custom real_ip_header

  • Files: backend/schema/paths/settings/settingID/put.json, backend/internal/ip_ranges.js
  • The meta.custom field has no pattern or length validation. An admin could set it to something like X-Real-IP; proxy_set_header X-Injected yes and inject arbitrary nginx directives directly into the rendered config.
  • Fix: Add "pattern": "^[A-Za-z][A-Za-z0-9-]*$" and "maxLength": 128 to the custom header schema. Validate in ip_ranges.js before rendering.
  • The silent catch (_) {} around the DB query in ip_ranges.js is also concerning — errors are swallowed with no log warning.

🔴 High

2. real_ip_header absent when IP_RANGES_FETCH_ENABLED=false

  • The hardcoded real_ip_header X-Real-IP; was removed from nginx.conf, but ip_ranges.conf is only written when the fetch runs. On installs with fetching disabled, real_ip_header is never written to any config file, breaking real IP detection entirely.
  • Fix: Either restore a fallback in nginx.conf, or call generateConfig with empty/cached ranges during setupNginxConfigs.

3. ip_hash + weight are incompatible in nginx

  • File: backend/templates/upstream_host.conf
  • The template renders weight={{ server.weight }} regardless of load balancing method. Nginx rejects weight inside an ip_hash block with a config error, preventing reload.
  • Fix: Conditionally omit weight= when method == "ip_hash".

4. Asset caching broken for upstream hosts

  • File: backend/templates/_assets.conf
  • When an upstream host is selected, the template still renders proxy_pass $forward_scheme://$server:$port where $server is set to 127.0.0.1 by the frontend. Cached assets will be fetched from localhost rather than the upstream pool.
  • There's also a discrepancy: the static assets.conf appends $request_uri but the Liquid template does not.

5. proxy.conf breaking change (undocumented)

  • proxy_pass was removed from the shared conf.d/include/proxy.conf. Any custom server configs that include this file and rely on it for proxy_pass will silently stop forwarding requests after upgrading.

🟡 Medium

6. Startup wipes all configs before regeneration (outage risk)

  • File: backend/setup.js, setupNginxConfigs
  • All .conf files are deleted before regeneration begins. If generateConfig fails mid-loop (template error, DB issue mid-restart), nginx reloads with no configs for that host type — causing an outage window on every container restart.
  • Fix: Write to temp filenames, test with nginx -t, then atomically swap. Or test before deleting anything.

7. Double nginx reload on upstream host update

  • File: backend/internal/upstream-host.js, update()
  • configure() already calls reload() internally; the code then calls reload() again explicitly. The bulkGenerateConfigs call for proxy hosts also bypasses the configure() lifecycle, so errors won't update nginx_err on those records.

8. user-object.jsonadditionalProperties: false removed

  • Removed to allow the upstream_hosts permission field, but this weakens schema validation. The correct fix is to keep additionalProperties: false and explicitly list all valid properties including upstream_hosts.

9. No format validation on server.host field

  • A malformed host value like 10.0.0.1; server 127.0.0.1 injected into the upstream template produces invalid nginx config. The JSON schema should add "pattern": "^[a-zA-Z0-9._\\-\\[\\]:]+$" to the host field.

10. React state mutation during render in LoadBalancingFields.tsx

  • setValues([blankItem]) is called directly in the component body when values.length === 0 — a React anti-pattern that can cause infinite re-render loops in strict mode.
  • Fix: Wrap in useEffect(() => { if (values.length === 0) setValues([blankItem]); }, []).

11. Dual state divergence in UpstreamHostModal.tsx

  • LoadBalancingFields manages its own useState alongside Formik's values. If Formik resets the form, the component's internal state won't reset, causing them to diverge.

🟢 Low

12. Slovak locale replaced with Czech

  • frontend/src/locale/src/sk.json — new load-balancing strings appear to be Czech translations, not Slovak.

13. Garbled translations in bg.json and ko.json

  • "load-balancing.add-server" shows ???????? / ?? ?? — likely an encoding issue during AI-assisted translation.

14. Migration: upstream_host_id uses 0 instead of null

  • Consistent with the rest of the codebase (no FK constraints used), but using 0 as a sentinel instead of null creates semantic ambiguity.

15. Missing test coverage

  • No test that ip_hash + weight generates valid nginx syntax
  • No test for startup config regeneration path after an upgrade
  • No test for asset caching behaviour with upstream hosts

Summary

Issues #1 (directive injection), #3 (ip_hash/weight nginx incompatibility), and #6 (startup outage risk) are blockers. Issue #2 (real_ip_header absent when IP range fetching is disabled) is a significant regression for a real user segment. The translation issues (#12, #13) are easy wins to address now.

The underlying feature design is good and solves real problems — looking forward to seeing a revised version.

genticflowlabs and others added 12 commits June 1, 2026 16:58
Brings the feature branch up to date with upstream NginxProxyManager/develop
so the open PR can merge cleanly. The only real conflict was in
frontend/src/pages/Nginx/ProxyHosts/Table.tsx where upstream added a basic
forwardHost cell renderer while this branch had already replaced it with
the location-aware DestinationCell — kept ours.

The auto-merger also undid the vite-tsconfig-paths plugin fix (because
upstream introduced a duplicate-broken `resolve.tsconfigPaths: true` option
in a separate commit); restored our working version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file got mutated locally (the dev compose's authentik service writes
to its postgres init fixture during boot). Restore it to upstream so the
PR diff doesn't include a noise change to a binary CI fixture.
Drop the vite-tsconfig-paths plugin import — it was added locally to fix
local production builds but was never declared in package.json, which
broke CI on a fresh install. Upstream's CI doesn't run vite build (only
vitest), and vitest happens to not exercise the src/* alias, so its
no-op `resolve.tsconfigPaths: true` is fine for them and now for us too.

The production build will hit the same path-resolution issue if anyone
tries to run `vite build` directly, but that's a separate concern from
this PR; matching upstream behavior so CI is green is the priority here.
Upstream's new test (cbcbd95) asserts en-US → EN, de-DE → DE, etc. —
i.e. flag follows the language, not the region. Our previous version
returned the region whenever one was present (US for en-US), which we
had introduced to make pt-BR show the Brazilian flag.

Rework to keep upstream's language-first behavior and add an explicit
exception for pt-BR (the only locale we register with a non-default
region). en-US, de-DE, fr-FR, ga-IE all fall through to the language
path, matching the test expectations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The configureServer plugin was spawning yarn locale-compile via execFile
(async) and not waiting for it. On CI's slower filesystem the truncate
phase of the second compile-folder run raced with vitest's JSON imports
of the same files and produced "EOF while parsing a value" — the test
file failed to load before any tests could run.

Switch to execFileSync so the locale rewrite always completes before
imports start. Behaviour is identical for `yarn dev`; just blocks
briefly on startup instead of running in the background.
The 201 example included id/created_on/modified_on/user_id inside
the permissions object, but the API explicitly omits those four keys
from the response (backend/internal/user.js omissions list). Adding
additionalProperties: false on the permissions sub-schema (PR review
fix-up) made the stale example fail Vacuum's oas3-valid-schema-example
rule in the Cypress Swagger Schema Linting test.

Drop the DB-only fields from the example so it matches the actual
response shape; add the new upstream_hosts permission to the example
while we're at it.
In LocationsFields the targetType for each location row was derived purely
from upstreamHostId (> 0 → "upstream", else "direct"). When the user
clicked the Upstream radio on a fresh location with no upstream selected
yet, handleTargetTypeChange wrote upstreamHostId: 0, so the next render
immediately snapped the radio back to Direct — and only the hover
highlight was visible because the input toggled briefly and then reverted.

Track the user's explicit choice in a per-index override map, falling
back to the derivation only when no choice has been made yet. Compact
the map on remove so indices line up after deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…time

When a Custom Location targets an upstream host, the user never fills in
forward_scheme/host/port for that row, so the payload had forward_host:""
on those locations. The backend schema requires forward_host minLength 1
on every location item regardless of upstream_host_id, so saving failed
with 'data/locations/N/forward_host must NOT have fewer than 1 characters'.

Mirror the same defaulting we already do for the proxy host's top-level
forward fields: at submit time, locations with upstreamHostId > 0 get
forward_scheme "http", forward_host "127.0.0.1", forward_port 80. These
placeholders never reach nginx — _location.conf switches to the upstream
block (proxy_pass upstream_host_<id>) when upstream_host_id > 0 — so the
actual routing is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DestinationCell previously had two bugs in the same expression:

1. When the proxy host carried a proxy-level upstream_host_id, the cell
   early-returned with just the upstream button — never reaching the
   block that renders custom locations. So any host using both a proxy-
   level upstream and per-location overrides looked single-target.

2. Even when the early return didn't fire, the per-location render only
   kept locations whose upstream_host_id > 0. Direct-mode custom
   locations (forward_host:port) were never shown.

Rewrite so the cell always shows the proxy-level destination on the
first line (upstream button OR scheme://host:port) and every custom
location underneath as `<path> → <upstream-button | scheme://host:port>`.
If `/` is itself a custom location the proxy-level line is suppressed,
since every request matches an override and the proxy-level forward is
unreachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a proxy host has custom locations, show the proxy-level (default)
destination as '/ → <dest>' so it reads uniformly alongside the other
`<path> → <dest>` rows. When the host has no custom locations, keep the
old tight rendering (just the destination, no '/' prefix).

The '/' row is still suppressed when the user has explicitly defined a
'/' custom location — that override fully replaces the default, so
showing both would be misleading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@genticflowlabs

Copy link
Copy Markdown
Author

Addressed PR comments and extended the proxy host view to show custom locations too:
image

@nginxproxymanagerci

Copy link
Copy Markdown

Docker Image for build 17 is available on DockerHub:

nginxproxymanager/nginx-proxy-manager-dev:pr-5413

Note

Ensure you backup your NPM instance before testing this image! Especially if there are database changes.
This is a different docker image namespace than the official image.

Warning

Changes and additions to DNS Providers require verification by at least 2 members of the community!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Respect X-Forwarded-For or X-Real-IP for IP ACLs Support upstream / load balancing

3 participants