# Azaion UI — Containerization > Synthesis output of `/document` Step 3d (containerization). Derived from > `Dockerfile`, `nginx.conf`, and `00_discovery.md` §3. ## 1. Image **Multi-stage build** (`Dockerfile`): | Stage | Base image | Role | |-------|------------|------| | 1 (builder) | `oven/bun:1.3.11-alpine` | `bun install --frozen-lockfile` + `bun run build` (= `tsc -b && vite build`) → `dist/` | | 2 (runtime) | `nginx:alpine` | Serves `/usr/share/nginx/html` (`dist/`); listens on `:80` | **Why this shape**: - Bun gives a fast install + build vs. npm/yarn/pnpm. - nginx alpine is a sub-25 MB runtime that already has reverse-proxy routing for `/api`. - No Node runtime in production → smaller attack surface, faster startup, lower memory. **Image labels** (OCI, set by Woodpecker CI): - `org.opencontainers.image.revision = $CI_COMMIT_SHA` - `org.opencontainers.image.created = $CI_BUILD_CREATED` - `org.opencontainers.image.source = ` **Environment**: - `AZAION_REVISION = $CI_COMMIT_SHA` — accessible at runtime for diagnostics. - No other env vars consumed at runtime by the SPA bundle (the bundle is fully static). ## 2. nginx routing (`nginx.conf`) The image's nginx config strips `/api//` and reverse-proxies to the matching suite service inside the container network. | Public path | Upstream (intra-cluster) | Service | |-------------|--------------------------|---------| | `/api/annotations/` | `http://annotations:8080/` | `annotations/` | | `/api/flights/` | `http://flights:8080/` | `flights/` | | `/api/admin/` | `http://admin:8080/` | `admin/` | | `/api/resource/` | `http://resource:8080/` | `resource/` | | `/api/detect/` | `http://detect:8080/` | `detect/` | | `/api/loader/` | `http://loader:8080/` | `loader/` | | `/api/gps-denied-desktop/` | `http://gps-denied-desktop:8080/` | `gps-denied-desktop/` | | `/api/gps-denied-onboard/` | `http://gps-denied-onboard:8080/` | `gps-denied-onboard/` | | `/api/autopilot/` | `http://autopilot:8080/` | `autopilot/` | | `/` (any other path) | static fallback to `/index.html` (SPA routing) | — | **Body size cap**: `client_max_body_size 500M` — tlog + video uploads in GPS-Denied Test Mode and large image uploads in Annotations both ride this limit. **Headers passed to upstream**: standard `Host`, `X-Real-IP`, `X-Forwarded-For`, `X-Forwarded-Proto` (assumed — verify in `nginx.conf`). **SSE handling**: `proxy_buffering off` MUST be set on `/api/detect/` and any other path that streams (Step 4 verification — confirm in `nginx.conf`). ## 3. Resource sizing (recommended, not enforced) | Resource | Recommendation | Rationale | |----------|----------------|-----------| | CPU | 100 m (0.1 vCPU) | nginx is near-idle; 99 % of work is suite services | | Memory | 32 Mi | nginx + ~5 MB of static assets | | Storage | ephemeral 50 Mi | bundle is sub-5 MB gzipped today; some headroom | | Replicas | 1+ | trivially horizontal; HA only matters if the ingress sits in front | **Bundle size budget**: `vite build` output should stay under ~2 MB gzipped initial JS. Currently `chart.js` and `leaflet` are the dominant chunks; `AltitudeChart` is a lazy-load candidate (finding in `05_flights`). ## 4. Health checks **Today: none.** Recommended (Step 4 / Step 6 surface): - **Liveness**: `GET /index.html → 200` - **Readiness**: same (the SPA has no warm-up) - **Container health**: `wget --spider -q http://localhost/index.html` The suite-level orchestrator (parent suite docker-compose / k8s) is expected to handle ingress health-checking; individual UI replicas don't need their own.