TutorialsMetals UI › Running it Locally

Metals UI · Section 2 of 2

Running the UI in Its Container

The UI is a folder of static files, but it isn't served by a static file server any more — it ships as an Nginx container that serves the pages and forwards /api/ calls to the API. That one change removes the cross-origin problem entirely and makes local development match Azure exactly. This page covers starting it, pointing it at a different API, and the errors specific to running it this way.

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Start command → docker compose up -d --build Open → http://localhost:8888 Server → Nginx, port 8080 in-container API calls → proxied at /api

Purpose of this page

Get metals_ui/ on screen in a browser, talking to a real Metals API, without guessing at start order.

Codey typing on a laptop
one command, whole stack

Before you start it prerequisites

  • Docker Desktop (or Docker Engine plus the Compose plugin) installed and running. No Python or Node is needed for the UI.
  • A .env file at the project root with JWT_SECRET_KEY set — Compose refuses to start the API without it, and the UI without an API isn't much use.
  • Ports 8888, 5000, and 5432 free on your machine. The first two are configurable via UI_PORT and API_PORT.

Starting it day-to-day

Run from the project root. This starts all three services — database, API, and UI — in dependency order; the UI isn't started until the API reports healthy.

docker compose up -d --build
CommandWhat it does
docker compose up -d --buildBuilds the images and starts everything. The --build is what picks up your edits.
docker compose up -d --build uiRebuilds and restarts just the UI — enough after changing anything under metals_ui/.
docker compose logs -f uiNginx's access and error logs, which is where a failing proxy call shows up.
docker compose psHealth status of all three services.

Then open http://localhost:8888 and sign in with admin / password. Note the port mismatch is deliberate: Nginx listens on 8080 inside the container, and Compose publishes that to 8888 on your machine. Full detail on the compose file is in the Docker tutorial.

Pointing the UI at an API one variable

The JavaScript never learns the API's address. It always calls the relative path /api, and Nginx decides where that actually goes — from the API_UPSTREAM environment variable.

Codey holding up a sticky note
retarget without rebuilding
The config baked into the imagedocker/runtime-config.js
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",
};

The image copies this over the repository's own runtime-config.js, so the containerized UI always uses the relative path. There's nothing environment-specific in the JavaScript at all.

Where each environment sets itthree places, same variable
  • Image defaultENV API_UPSTREAM=http://api:5000, so it works with no configuration at all under Compose.
  • Compose — restates API_UPSTREAM: http://api:5000 explicitly, so the wiring is visible in one file.
  • Azure — an app setting holding the API Web App's real https:// hostname.

To point your local UI at a deployed API, override the variable rather than editing any file: docker compose run -e API_UPSTREAM=https://<api>.azurewebsites.net ui.

Why there's no CORS problem what the proxy buys you

Serving the UI on one port and calling an API on another used to mean two different origins, which triggers the browser's cross-origin rules: preflight requests, required response headers, and blocked responses when anything is missing. The proxy removes the condition that causes all of it.

Codey thinking with a rubber duck
one origin, no preflight
What the browser sees nowone origin

The page comes from http://localhost:8888 and its API calls go to http://localhost:8888/api/… — same scheme, host, and port. Same-origin, so the cross-origin machinery never engages. Nginx then makes the real call server-to-server, where those browser rules don't apply.

The API still sends CORS headersbelt and braces

metals_api/app.py still sets Access-Control-Allow-Origin: * on every response. Through the proxy that's simply unused — but it keeps the API callable directly from http://localhost:5000 with curl, Postman, or a browser tab during debugging.

Editing files while it runs the one workflow change

Files are copied in, not mountedexpect this

The image COPYs index.html, css/, js/, and assets/ at build time. Editing them on disk changes nothing in the running container until you rebuild — docker compose up -d --build ui. Refreshing the browser first and concluding “my change didn't work” is the single most common confusion here.

If you're making lots of small UI edits, mounting the folder as a bind volume during development is a reasonable local tweak — just don't commit it, since the whole point of the image is that it contains its own files.

Common errors and how to fix them

What you seeLikely causeHow to fix it
Page loads, but every API call returns 502 Bad GatewayNginx can't reach the API — it's unhealthy, or API_UPSTREAM points somewhere wrong.docker compose ps to check the API's health, then docker compose logs api.
Your edits don't appear after refreshingThe container is still running the previously built image.docker compose up -d --build ui.
404 for a CSS or JS fileThe file wasn't copied into the image, or metals_ui/.dockerignore excluded it.docker compose exec ui ls /usr/share/nginx/html to see what's actually in there.
Port 8888 already in useAnother container or process holds it.Set UI_PORT in .env to a free port, or stop the other process.
Login succeeds but the catalog is emptyThe database started without the seed data.Reset it: docker compose down -v then up -d --build — see the database tutorial.
host not found in resolver in the UI's logsThe configured DNS resolver doesn't exist in this environment.Locally this should be Docker's 127.0.0.11; see the UI image page.
Codey giving a thumbs up

UI running in its container — see how that image is actually built in the Docker tutorial.