Purpose of this page
Understand how a static site becomes a container that serves itself and forwards API calls — and how one image is retargeted between local Docker and Azure without rebuilding.
The whole file start here
metals_ui/DockerfileFROM nginxinc/nginx-unprivileged:stable-alpine
# The base image substitutes these variables into the Nginx template at startup.
ENV API_UPSTREAM=http://api:5000
# Docker's embedded DNS. Azure Web Apps must override this to 168.63.129.16.
ENV NGINX_RESOLVER=127.0.0.11
COPY docker/default.conf.template /etc/nginx/templates/default.conf.template
COPY index.html /usr/share/nginx/html/index.html
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
COPY assets/ /usr/share/nginx/html/assets/
COPY docker/runtime-config.js /usr/share/nginx/html/runtime-config.js
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/health || exit 1
# Use the base image's entrypoint and foreground Nginx command.
Why this base image nginx-unprivileged
The standard nginx image starts as root so it can bind port 80, then drops privileges for its worker processes. nginx-unprivileged is the same Nginx repackaged to run entirely as a non-root user, which is why it listens on 8080 — ports below 1024 require privileges this container doesn't have.
That's the whole reason the UI's port is 8080 rather than 80. It isn't arbitrary; it's the consequence of not running as root.
stable is Nginx's conservative release line, and alpine is the tiny Alpine Linux base. The musl-vs-glibc concern that pushed the API toward -slim doesn't apply here — nothing in this image compiles — so the smallest variant is a free win.
/usr/share/nginx/html/ is the document root the inherited config serves from, so the UI's index.html, css/, js/, and assets/ go there. /etc/nginx/templates/ is the special folder covered next.
Because the build context is metals_ui/ itself, all these source paths are relative to that folder — hence docker build -t metals-ui:local metals_ui with no -f needed.
The config template trick the clever bit
An image is immutable, but the API's address isn't: locally it's another container named api, in Azure it's an https://….azurewebsites.net hostname. Rather than build two images, this one ships a config template and fills in the blanks at startup.
The official Nginx images ship an entrypoint script that, on every container start, looks in /etc/nginx/templates/ for *.template files, runs envsubst over each one to replace ${VARIABLE} references with real environment values, and writes the result into /etc/nginx/conf.d/ before starting Nginx. We get that for free by not overriding the entrypoint.
API_UPSTREAM and NGINX_RESOLVER are declared with ENV so the image works out of the box under Compose. Both are plain defaults — Compose overrides the first, and Azure overrides both, as covered on the Deployment pages.
Here's the template itself, which is the real configuration for the whole UI container:
metals_ui/docker/default.conf.templateserver {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the API upstream at request time so a recreated container (local
# Docker) or a new App Service instance (Azure) is picked up without a
# config reload. 127.0.0.11 is Docker's embedded DNS; Azure Web Apps have
# no such address and must override NGINX_RESOLVER to 168.63.129.16.
resolver ${NGINX_RESOLVER} valid=10s ipv6=off;
set $api_upstream "${API_UPSTREAM}";
location /api/ {
proxy_pass $api_upstream;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
}
location = /health {
access_log off;
default_type application/json;
return 200 '{"status":"ok"}';
}
location / {
# Hash routing requires no server-side fallback. Missing assets are 404s.
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
}
The UI's health endpoint is a hardcoded 200 response — no file, no application code. It reports “this web server is up,” which is exactly the right question for a container health check and for Azure's health probe. It deliberately says nothing about whether the API behind it is reachable.
Single-page apps using path routing need every unknown URL rewritten to index.html. This UI routes with the URL hash (#/elements), which the browser never sends to the server — so every real request is for a file that exists, and a genuine missing asset should honestly return 404 rather than silently serving the homepage.
One origin, two services why proxy at all
The UI could call the API directly at its own address. Proxying instead means the browser only ever talks to one origin, which removes a whole class of problems.
The page is served from http://localhost:8888 and its API calls go to http://localhost:8888/api/…. Same protocol, host, and port — so they're same-origin, and the browser's cross-origin rules (CORS preflights, blocked responses) never come into play. Nginx is the one that reaches across to the API, server-to-server, where those rules don't apply.
The image overwrites the UI's config file with a container-specific version whose API base URL is simply /api — a relative path, so it resolves against whatever host the page was loaded from:
metals_ui/docker/runtime-config.js// The container serves the UI and proxies API requests on the same origin.
globalThis.METALS_ATLAS_CONFIG = {
apiBaseUrl: "/api",
};
This is why the JavaScript needs no knowledge of environments. Local or Azure, the answer is always /api; only Nginx's API_UPSTREAM changes.
Because proxy_pass is given a bare scheme and host with no path, Nginx forwards the original URI untouched — /api/auth/login arrives at the API as /api/auth/login. That's exactly what's wanted here, since every Flask blueprint is already registered under an /api/… prefix. Had the target included a path, Nginx would have stripped the matched /api/ instead.
The resolver that bit us a real bug, found late
proxy_pass here targets a variable ($api_upstream), not a literal. That's what allows the address to come from the environment — but it changes when Nginx resolves the name. With a literal, Nginx resolves once at startup. With a variable, it resolves per request, and that requires an explicitly configured DNS resolver.
Docker runs an embedded DNS server at 127.0.0.11 inside every container on a user-defined network — that's what makes the hostname api resolve to the API container. Azure App Service has no such address. Hardcoding it works perfectly under Compose and then fails in Azure, where every /api/ request would return 502 Bad Gateway because the upstream name can't be resolved.
The resolver address is itself substituted from NGINX_RESOLVER, defaulting to Docker's 127.0.0.11 so local development is unaffected. Azure's Web Apps set it to 168.63.129.16 — a fixed, Azure-wide virtual IP that provides DNS to App Service and virtual machines. One variable, two environments, no second image.
valid=10s re-checks the name every ten seconds, so a replaced API container or a scaled App Service instance is picked up without restarting Nginx; ipv6=off avoids pointless AAAA lookups.
Building and running it standalone
The UI image is genuinely useful on its own — it will serve the pages and answer /health with no API present.
# context is metals_ui, so no --file flag is needed docker build -t metals-ui:local metals_ui # serve it on http://localhost:18080 docker run --rm -d --name metals-ui-test -p 18080:8080 metals-ui:local # confirm the template rendered: expect "resolver 127.0.0.11 valid=10s ipv6=off;" docker exec metals-ui-test cat /etc/nginx/conf.d/default.conf # try an Azure-style override docker run --rm -d --name metals-ui-azure -e NGINX_RESOLVER=168.63.129.16 -p 18081:8080 metals-ui:local
Common errors and how to fix them
| What you see | Likely cause | How to fix it |
|---|---|---|
| UI loads, but every API call returns 502 Bad Gateway | Nginx can't resolve or reach API_UPSTREAM. | Check the API container is healthy (docker compose ps), and that the resolver address suits the environment — 127.0.0.11 for Docker, 168.63.129.16 for Azure. |
| host not found in resolver in the container's logs | The configured resolver address doesn't exist in this environment. | Override NGINX_RESOLVER to match where the container is running. |
| Edits to index.html or js/ don't appear | The running container still uses the previously built image. | docker compose up -d --build ui — the files are baked in at build time, not mounted. |
| 404 on a CSS or JS file | The asset wasn't copied in, or metals_ui/.dockerignore excluded it. | Confirm with docker exec <name> ls /usr/share/nginx/html, then check the COPY lines and the ignore file's ! entries. |
| Port 8888 already in use | Another process (often a previous container) holds the published port. | docker ps to find it, then stop it — or change the left-hand side of the port mapping. |