TutorialsDocker › The UI Image

Docker · Page 5 of 6

metals_ui/Dockerfile and the Nginx Proxy

The UI has no runtime to install — it's plain HTML, CSS, and JavaScript. What it needs is a web server, and this image gets one by inheriting almost everything from Nginx's own hardened base. The interesting part is what it adds: a config template that lets one built image point at a different API in every environment, and a reverse proxy that makes the API look like it lives at the same address as the UI.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Image name → metals-ui Base → nginx-unprivileged:stable-alpine Port → 8080 Proxies → /api/ → API_UPSTREAM Build context → metals_ui/

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.

Codey typing on a laptop
mostly inherited

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.

Notice what isn't here: no RUN, no USER, and no CMD. Nothing needs installing, the base image already runs unprivileged, and its existing startup command is exactly what we want. The entire file is “copy files into the right folders and set two defaults.”

Why this base image nginx-unprivileged

Not the plain nginx imagea security choice

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-alpinethe smallest sensible option

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.

What the COPY lines land ontwo destinations

/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.

Codey holding up a sticky note
one image, many environments
How /etc/nginx/templates worksinherited behavior

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.

The two variables it substituteswith defaults in the image

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";
    }
}
location = /healthanswered by Nginx itself

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.

try_files $uri $uri/ =404hash routing needs no fallback

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.

What the browser seessame-origin requests

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.

runtime-config.jshow the JS knows

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.

The prefix is preserveda subtle nginx rule

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.

Codey holding a bug-hunting net
right locally, wrong in Azure
127.0.0.11 is Docker-onlythe trap

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 fix: make it a variable too168.63.129.16 in Azure

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.

Where that value gets set per environment: the ENV default in this Dockerfile, nothing extra under Compose, and an explicit NGINX_RESOLVER app setting on the UI Web App created by both the manual script and Terraform.

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

That third command is the quickest way to prove the templating works at all: the file in conf.d/ is generated, so whatever you see there is what Nginx is really running — variables already substituted.

Common errors and how to fix them

What you seeLikely causeHow to fix it
UI loads, but every API call returns 502 Bad GatewayNginx 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 logsThe 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 appearThe 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 fileThe 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 useAnother 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.
Codey giving a thumbs up

Both images built — last step is wiring them together with the database in one command.