Skip to content

feat: Dynamic upstream DNS resolution for proxy hosts#5487

Open
hactazia wants to merge 8 commits into
NginxProxyManager:developfrom
hactazia:feature/dynamic_upstream_resolve
Open

feat: Dynamic upstream DNS resolution for proxy hosts#5487
hactazia wants to merge 8 commits into
NginxProxyManager:developfrom
hactazia:feature/dynamic_upstream_resolve

Conversation

@hactazia
Copy link
Copy Markdown

Summary

Adds a "Dynamic Upstream Resolve" toggle to proxy hosts. When enabled,
nginx uses a resolver directive and a $upstream_host variable so
that the upstream hostname is resolved at request time via Docker's
internal DNS (127.0.0.11), rather than once at nginx startup.

This is useful when the upstream container may not be running when
nginx starts, or when its IP can change (e.g. after a restart).

image

Changes

  • Migration: adds dynamic_upstream_resolve tinyint column to proxy_host
  • Backend model: registers the new field in boolFields
  • API schema: exposes the field in GET/POST/PUT endpoints
  • Nginx templates: adds resolver 127.0.0.11 valid=10s in the
    server block and set $upstream_host in custom locations when enabled
  • nginx.js: propagates dynamic_upstream_resolve to the location
    rendering context (was missing, causing the variable to be ignored)
  • Frontend: adds toggle in the Details tab of the proxy host modal
  • i18n: adds host.flags.dynamic-upstream-resolve key to all locales
  • Tests: adds Cypress API test for the new field

Notes

This feature is Docker-specific. The resolver 127.0.0.11 is Docker's
embedded DNS and is only available inside a Docker network bridge.

When enabled, nginx resolves the upstream hostname at request time
using Docker's internal DNS resolver (127.0.0.11) instead of only
at startup. This prevents nginx from failing when an upstream
container is not yet running or restarts with a new IP.
@jc21
Copy link
Copy Markdown
Member

jc21 commented May 14, 2026

CI is failing because the API schema is missing some examples

10:41:44  cypress-1  |  > results/swagger-schema.json
10:41:44  cypress-1  |  ----------------------------------------------------------------------------------------------------------
10:41:44  cypress-1  | 
10:41:44  cypress-1  | results/swagger-schema.json:3354:19  ▲           missing property 'dynamic_upstream_resolve'                                 
10:41:44  cypress-1  | $.paths['/nginx/proxy-hosts'].get.responses['200'].content['application/json'].examples['default']
10:41:44  cypress-1  | rule: oas3-valid-schema-example  category: Examples
10:41:44  cypress-1  | 
10:41:44  cypress-1  | results/swagger-schema.json:4308:19  ▲           missing property 'dynamic_upstream_resolve'                                 
10:41:44  cypress-1  | $.paths['/nginx/proxy-hosts'].post.responses['201'].content['application/json'].examples['default']
10:41:44  cypress-1  | rule: oas3-valid-schema-example  category: Examples
10:41:44  cypress-1  | 
10:41:44  cypress-1  | results/swagger-schema.json:5077:19  ▲           missing property 'dynamic_upstream_resolve'                                 
10:41:44  cypress-1  | $.paths['/nginx/proxy-hosts/{hostID}'].get.responses['200'].content['application/json'].examples['default']
10:41:44  cypress-1  | rule: oas3-valid-schema-example  category: Examples
10:41:44  cypress-1  | 
10:41:44  cypress-1  | results/swagger-schema.json:6038:19  ▲           missing property 'dynamic_upstream_resolve'                                 
10:41:44  cypress-1  | $.paths['/nginx/proxy-hosts/{hostID}'].put.responses['200'].content['application/json'].examples['default']
10:41:44  cypress-1  | rule: oas3-valid-schema-example  category: Examples

@jc21
Copy link
Copy Markdown
Member

jc21 commented May 14, 2026

Code Review

Thanks for the PR — the overall structure is solid and follows the pattern established by trust_forwarded_proto. The nginx.js fix to propagate dynamic_upstream_resolve into the location rendering context is correct and necessary. The Cypress test is a good addition.

That said, there are a few issues that need addressing before this can merge.


🔴 Critical — Feature doesn't work for the default location

The $upstream_host variable and the dynamic proxy_pass are only injected in _location.conf, which is rendered for custom locations only. The default location / block in proxy_host.conf includes conf.d/include/proxy.conf — a static file that always uses the literal hostname in proxy_pass.

For nginx to resolve dynamically, proxy_pass must reference a variable — the resolver directive alone has no effect when the upstream is a static string. The vast majority of proxy hosts use the default location, so enabling this toggle will add the resolver directive but leave proxy_pass unchanged, silently doing nothing.

Fix: Add the same {% if dynamic_upstream_resolve %} / set $upstream_host / variable proxy_pass logic inside the location / block in proxy_host.conf.


🔴 Critical — Unrelated change to an existing migration file

backend/migrations/20260131163528_trust_forwarded_proto.js is modified to change function declarations to arrow functions. Migration files must be treated as immutable once committed — modifying them can break checksum validation and causes confusion about what ran on existing installs.

Please revert this change. Style cleanup belongs in a separate PR if needed at all.


🟠 High — Resolver is hardcoded to Docker's internal DNS with no user warning

127.0.0.11 is the embedded DNS server on Docker bridge networks only. It is unavailable on:

  • Docker host-network containers
  • Bare-metal / VM installs of NPM
  • Kubernetes or other container runtimes

A user on any non-Docker-bridge deployment who enables this toggle will get a non-functional resolver — nginx may fail to start, or silently fail to proxy. There is no UI warning, tooltip, or documentation callout anywhere in the PR.

At minimum, add descriptive text to the UI toggle explaining this is Docker-specific. Ideally the resolver address would be a configurable field rather than hardcoded.


🟡 Minor — Template indentation in proxy_host.conf

The {% if %} / {% endif %} lines for the new resolver directive are flush left, while all other directives inside the server {} block use 2-space indentation. See how allow_websocket_upgrade is handled in the same file for the expected style.

@jc21
Copy link
Copy Markdown
Member

jc21 commented May 14, 2026

Actually on the topic of

Resolver is hardcoded to Docker's internal DNS

It might be ok to merge this PR with this limitation, but a new PR should probably be created that addresses configuring the DNS to use either through the Settings in app or a Environment variable.

hactazia and others added 5 commits May 14, 2026 05:29
…proto

Migration files must be treated as immutable once committed. Revert
the unrelated style change (arrow functions) introduced in a previous
commit.
Instead of hardcoding 127.0.0.11 (Docker bridge DNS only), the resolver
address is now determined at startup using the following priority:

  1. NGINX_RESOLVER environment variable (explicit override)
  2. First nameserver entry in /etc/resolv.conf (system DNS)
  3. Fallback to 127.0.0.11 (Docker bridge default)

This makes the feature usable outside of Docker bridge networks
(host-network, bare-metal, Kubernetes, etc.) without any code change.

Also fix template indentation for the resolver directive to match the
2-space convention used by other directives in the server{} block.
Add a descriptive sub-line under the toggle explaining that the DNS
resolver is auto-detected but can be overridden via NGINX_RESOLVER.

Also add French translation for both the label and the description.
The field was missing from the Formik initialValues, causing the checkbox
to fall back to native HTML behavior and send ["on"] instead of a boolean,
which failed schema validation with 400 Bad Request.
@hactazia
Copy link
Copy Markdown
Author

Here's what was addressed in this update:

🔴 Migration file (revert):

Tthe file (20260131163528_trust_forwarded_proto.js) is restored to its original form.

🔴 Default location / proxy.conf:

After investigation, conf.d/include/proxy.conf already uses $server as a nginx variable (proxy_pass $forward_scheme://$server:$port$request_uri), which is set via set $server "{{ forward_host }}" at the top of the server block. Since proxy_pass references a variable, the resolver directive is effective and dynamic resolution works correctly for the default location without any further change.

🟠 Hardcoded DNS resolver:

Replaced the hardcoded 127.0.0.11 with a runtime-resolved value. Priority order:

  1. NGINX_RESOLVER environment variable (explicit override)
  2. First nameserver from /etc/resolv.conf (system DNS)
  3. Fallback to 127.0.0.11 (Docker bridge)

This makes the feature work on host-network, bare-metal, Kubernetes, and any other deployment without code changes - just set NGINX_RESOLVER if needed.

🟠 No user warning:

Added a descriptive sub-line under the toggle in the UI explaining the auto-detection behavior and the NGINX_RESOLVER override.

🟡 Template indentation:

Fixed the {% if %}/{% endif %} block around the resolver directive to use 2-space indentation, consistent with the rest of the server {} block.

@nginxproxymanagerci
Copy link
Copy Markdown

Docker Image for build 7 is available on DockerHub:

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

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.

2 participants