Skip to content

fix(ui): discover the nginx resolver at run time instead of hard-coding Docker's - #19

Merged
aboutte merged 1 commit into
duplocloud:devfrom
duplo-darren:fix/ui-nginx-resolver
Sep 30, 2026
Merged

aboutte merged 1 commit into
duplocloud:devfrom
duplo-darren:fix/ui-nginx-resolver

Conversation

@duplo-darren

Copy link
Copy Markdown

What this changes

The UI proxies API path prefixes to the studio so the browser stays same-origin. Since
63e6d2f that proxy_pass goes through a variable to force per-request DNS
resolution, which requires an explicit resolver — and that resolver was pinned to
127.0.0.11, Docker's embedded DNS. It does not exist under podman, whose
aardvark-dns sits on the network gateway (10.89.0.x, varying by network).

The failure mode is the worst shape available. nginx starts, the SPA is served
normally, and every proxied API call dies with no response at all — including
POST /api/Account/PasswordLogin, so the login page renders and simply does nothing.
The only clue is buried in the UI container's log:

[error] recv() failed (111: Connection refused) while resolving, resolver: 127.0.0.11:53

Rather than branching on the runtime, ask the network. Any container attached to
the project network is handed the correct nameserver by the runtime itself, so a
busybox init service reads its own /etc/resolv.conf, substitutes the value into
nginx/default.conf, and writes the result to a volume duplo-ui mounts. There is no
docker-vs-podman case to keep in sync, and it stays correct for any runtime added
later.

It cannot be done inside the UI image: its entrypoint is nginx directly — no
/docker-entrypoint.d/, no /etc/nginx/templates — and it runs as nonroot 65532, so
the usual envsubst-on-a-template trick is unavailable. nginx has never read
/etc/resolv.conf for its own resolver directive, so substitution has to happen
before nginx starts. The init-container shape mirrors init-perms, already in this
file.

Based on fix/ui-nginx-port-8080, not dev — deliberately. The resolver fix is
untestable until nginx can start at all, and on dev it cannot: the nonroot UI image
cannot bind port 80. Please merge that one first; this PR's diff reduces to its single
commit once it lands.

1 commit · 3 files · +101 / −5 · new: tests/test-ui-nginx.sh

How it was verified

  • ./run.sh --reset, then built and deployed a sample end to end
  • ./scripts/build-extension.sh on the affected sample, then deployed it and created one in the UI
  • npm ci && npm run build in the affected frontend/
  • ./stop.sh --wipe && ./run.sh from a clean slate
  • Ran /duplo-extension and confirmed the guidance still matches reality
  • Docs only — no runtime behaviour changed

Box 4 is the row CONTRIBUTING.md maps to docker-compose.yml / nginx/, which is
what this PR touches. Run on Docker via stop.sh/run.sh, and on podman via
compose down -v / compose up -d — this branch predates the runtime abstraction, so
its run.sh is Docker-only. No frontend/ is touched, so the npm box is n/a.

Output
docker — ./stop.sh --wipe && ./run.sh   (clean slate, exit 0)
  stop.sh --wipe    removed devkit_{platform,extension_studio,claude_sessions,qdrant,mongo}_data
                    + devkit_nginx_conf + devkit_default
  init-nginx-conf   nginx resolver = 127.0.0.11
  rendered conf     resolver 127.0.0.11 valid=10s ipv6=off;
  __RESOLVER__ remaining in rendered conf: 0
  duplo-ui          Up (healthy)
  ✔ Platform ready
  GET  /                              200
  GET  /v1/aiservicedesk/admin/...    401
  POST /api/Account/PasswordLogin     401
  nginx "while resolving" / emerg     0

podman — compose down -v && compose up -d
  init-nginx-conf   nginx resolver = 10.89.0.1
  rendered conf     resolver 10.89.0.1 valid=10s ipv6=off;
  GET  /                              200
  GET  /v1/aiservicedesk/admin/...    401     (was 000 before this fix)
  POST /api/Account/PasswordLogin     401     (was 000 before this fix)
  nginx "while resolving" errors      0       (was many)

tests/test-ui-nginx.sh   6 passed, 0 failed

401 is the pass: nginx resolved the upstream, reached the studio, and got an auth
challenge. 000 was the failure — nginx never reached it. Same image, same compose
file, correct address discovered on both runtimes with no branching.

The clean-slate run also confirms stop.sh --wipe removes the new nginx_conf volume,
so the init container renders into a fresh one rather than inheriting a stale
default.conf that would mask a rendering failure.

tests/test-ui-nginx.sh is static, so it runs on any machine with no runtime
installed: the conf must not pin a runtime-specific address, the init service must read
/etc/resolv.conf rather than a literal, duplo-ui must wait for it via
service_completed_successfully, and shell variables in the init command must be
written $$ — compose interpolates $VAR in command: before the shell sees it, and
an interpolated-away variable yields an empty resolver and an nginx that will not load
its config at all. That last check was mutation-tested ($$R → $R; confirmed the
test fails, then restored).

Note the volume replaces the whole /etc/nginx/conf.d directory. The image ships only
default.conf there, and the test guards that assumption.

Blast radius

  • This changes .env.example — image tags or ports here are adopted into every user's .env on their next ./run.sh after an upgrade. Say so plainly above.
  • This changes scripts/upgrade_dev_kit.sh or scripts/init-project.sh — the adoption path
  • This changes a workflow under .github/workflows/
  • None of the above

Checks

  • No .env, tokens, keys, or customer identifiers in the diff
  • No build output committed — dist/, node_modules/, **/bin/, **/obj/, backend/sdk-packages/
  • Commits are conventional, imperative, and focused
  • If a vendored ng-common-lib tarball was added or bumped, its NOTICE sits beside it — n/a, none added

…ng Docker's

The UI proxies API prefixes to the studio so the browser stays same-origin, and
since 63e6d2f that proxy_pass goes through a variable to force per-request DNS
resolution — which requires an explicit `resolver`. That resolver was pinned to
127.0.0.11, Docker's embedded DNS, which does not exist under podman: its
aardvark-dns sits on the network gateway (10.89.0.x, varying by network).

The failure mode is the worst shape available. nginx starts, the SPA is served
normally, and every proxied API call dies with no response at all — including
POST /api/Account/PasswordLogin, so the login page renders and simply does
nothing. The only clue is "recv() failed (111: Connection refused) while
resolving, resolver: 127.0.0.11:53" buried in the UI container's log.

Rather than branching on the runtime, ask the network. Any container attached to
the project network is handed the correct nameserver by the runtime itself, so a
busybox init service reads its own /etc/resolv.conf, substitutes the value into
nginx/default.conf, and writes the result to a volume that duplo-ui mounts. There
is no docker-vs-podman case to keep in sync, and it stays correct for any runtime
added later.

It cannot be done inside the UI image: its entrypoint is `nginx` directly — no
/docker-entrypoint.d/, no /etc/nginx/templates — and it runs as nonroot 65532, so
the usual envsubst-on-a-template trick is unavailable. nginx has never read
/etc/resolv.conf for its own `resolver` directive, so substitution has to happen
before nginx starts. The init-container shape mirrors init-perms, already in this
file.

Verified on both runtimes, same image and same compose file:

    podman   nginx resolver = 10.89.0.1     SPA 200, /v1/... 401, login 401
    docker   nginx resolver = 127.0.0.11    SPA 200, /v1/... 401, login 401

401 is the pass: nginx resolved the upstream and the studio answered with an auth
challenge. Before the fix the same calls returned 000 — nginx never reached it.
Zero resolver errors in the UI log on either runtime.

tests/test-ui-nginx.sh covers it statically, so it runs on any machine with no
runtime installed: the conf must not pin a runtime-specific address, the init
service must read /etc/resolv.conf rather than a literal, duplo-ui must wait for
it via service_completed_successfully, and shell variables in the init command
must be written $$ — compose interpolates $VAR in `command:` before the shell
sees it, and an interpolated-away variable yields an empty resolver and an nginx
that will not load its config at all.

Note the volume replaces the whole /etc/nginx/conf.d directory; the image ships
only default.conf there, and the test guards that assumption.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aboutte
aboutte changed the base branch from fix/ui-nginx-port-8080 to dev September 30, 2026 13:14
@aboutte
aboutte merged commit bc5ef99 into duplocloud:dev Sep 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants