Micro-Apps Guide
How standalone apps under apps/* (e.g. apps/micro-app-poc) are built,
served, and deployed alongside the root Waldur Homeport app. This is the
systematic explanation the Dockerfile, docker/nginx-tpl.conf,
docker/entrypoint.sh, and .gitlab-ci.yml comments point back to — read
this instead of reconstructing the mechanism from those comments.
What a micro-app is
Any directory under apps/* is a standalone Vite app, its own yarn
workspace member (root package.json's "workspaces" already includes
"apps/*"), consuming packages/* the way a genuinely separate
micro-frontend would — not through the root app's own src/, its
vite.config.ts aliases, or its Tailwind theme overrides. See
apps/micro-app-poc for a concrete example and what building one that
way surfaced about packages/*'s own portability gaps.
A new micro-app that wants the same Sidebar/TopBar chrome, page-content
error boundary, and app bootstrap sequence (auth, font/brand-token,
sidebar-style, i18n, Sentry) doesn't need to rebuild any of that — see
packages/shell (waldur-shell) and apps/micro-app-poc/README.md's
"Shared app-shell" section. Reach for it directly rather than re-deriving
the same wiring per app.
The convention is directory presence, not a manifest. Every apps/*
member is picked up automatically — by yarn install (via the
workspace glob), by the Dockerfile's build loop, by CI's typecheck/build
job, and by the generated nginx routing. A new micro-app needs zero
Dockerfile/nginx/entrypoint.sh/CI changes — see
Adding a new micro-app.
Local development
1 2 3 | |
apps:typecheck/apps:build (root package.json) run via
yarn workspaces foreach -A --include 'apps/*' run <script> — one generic
command instead of a hand-maintained script pair per app.
Testing the subpath locally, with HMR
The root app's own vite.config.ts can proxy a micro-app's subpath to its
dev server, mirroring the production nginx routing below without a full
Docker build — see apps/micro-app-poc/README.md for the exact commands.
This needs the micro-app's own vite.config.ts to set its base to match
(env-var-gated, so standalone dev stays at /) — otherwise Vite's own dev
paths (/@vite/client, /@fs/*) collide between the two dev servers
sharing one origin. Currently hardcoded to micro-app-poc's one proxy
entry/port; generalizing to every apps/* member automatically would need
a small discoverable ports registry (e.g. extending the waldur.deploy
package.json convention below with a devPort field) — not worth it with
only one real example today.
Build & serve pipeline
Every apps/* member ships inside the same production image as the
root app, at its own subpath on the same domain/port — not as a separate
deployment. Three files cooperate, each scanning for apps/* content
rather than naming it:
Dockerfile— after the root app's ownvite build, loops overapps/*/, builds each withvite build <app_dir> --base="$ASSET_PATH<name>/"(the same$ASSET_PATHbuild arg the root app's build uses — see "ASSET_PATH (relocated deployments)" below), and nests the result intodist/<name>/alongside the root app's owndist/. The final image stage is then one singleCOPYof the whole tree — no per-appCOPYlines.docker/entrypoint.sh— at container start, generates one nginxlocationblock perapps/*directory it finds under the html root, into/etc/nginx/conf.d/micro-apps.conf(created empty at build time so theincludebelow is never missing). It also substitutes the realAPI_URLinto every app'sindex.orig.html(root's and every micro-app's — anywhere it finds that filename), the same__API_URL__-placeholder mechanism the root app uses viaVITE_API_URL=__API_URL__at build time.docker/nginx-tpl.conf— justincludes that generated file. No micro-app location block is hand-written here.
ASSET_PATH (relocated deployments)
ASSET_PATH lets an entire deployment (root app + every micro-app) be
relocated under a URL prefix — used by the k8s MR-preview environment,
which sets it to /<mr-id>/ so multiple preview deployments can share one
domain. It exists in two forms that must not be confused:
- Build-time (
ARG ASSET_PATH="/"in the Dockerfile): a full path with a leading slash, e.g./21684/. Baked into every JS/CSS asset URL atvite buildtime, for both the root app and every micro-app. - Runtime (a container env var of the same name, e.g.
21684/, no leading slash — the leading slash comes from the literal/already indocker/nginx-tpl.conf'slocation /${ASSET_PATH}): drives nginx routing andentrypoint.sh's "Handle asset path" step, which duplicates the whole html tree (root app and every micro-app directory) into/usr/share/nginx/html/${ASSET_PATH}/.
A micro-app's own build and its generated nginx location block both key off
$ASSET_PATH for exactly this reason: if the root app moves under a
prefix, a micro-app must move with it, not stay pinned to the domain root.
Get this wrong and a micro-app's location block silently never matches
under a relocated deployment — requests fall through to the root app's own
SPA fallback instead. (This exact bug existed briefly during development;
verified fixed by simulating the full pipeline locally with both an empty
and a set ASSET_PATH.)
Opting out of production: waldur.deploy
A micro-app that should stay buildable/typecheckable/live-testable but
never reach a real deployment (a demo or proof-of-concept, not a
deployable) opts out via its own package.json:
1 2 3 | |
The Dockerfile's apps/* loop checks this (via a one-line node -e
read of the app's package.json) before building/copying it. Absent
this field, an app ships by default — the flag is opt-out, not opt-in,
so a real new micro-app needs no package.json change to be deployed.
Live-testing without shipping
waldur.deploy: false only controls production images. CI/preview
builds override it with a second Dockerfile arg,
INCLUDE_DEPLOY_FALSE_APPS=true, so every apps/* member — including
opted-out ones — is still built, packaged, served, and API_URL-substituted
on every MR:
Job (.gitlab-ci.yml) |
INCLUDE_DEPLOY_FALSE_APPS |
Purpose |
|---|---|---|
Build test docker image (E2E image) |
true |
Image the downstream E2E suite runs against |
Build test docker image (k8s preview) |
true |
Feeds Test k8s deployment's live MR preview |
Publish YOLO docker image |
unset (false) |
Real develop-branch production image |
Publish latest docker image |
unset (false) |
Retags YOLO → latest |
Publish multiarch ... specific version |
unset (false) |
Real tagged-release production image |
So apps/micro-app-poc is reachable at /micro-app-poc/ (or
/<mr-id>/micro-app-poc/ under the preview env's ASSET_PATH) in every
MR's live k8s preview, while never appearing in a real Publish image.
CI coverage
Run apps typecheck and build—yarn apps:typecheck+yarn apps:build, gated on changes underapps/**/*,packages/**/*,package.json,yarn.lock. Vite-level correctness only; doesn't touch Docker.Test docker image build— MR-time smoke build (docker buildx), gated onDockerfile,docker/**/*,apps/**/*(the Dockerfile buildsapps/*into the image now, so a change there affects the built image).Build test docker image+Test k8s deployment— the real end-to-end check: builds the E2E/preview images (withINCLUDE_DEPLOY_FALSE_APPS=true) and deploys the preview one to a live, browsable k8s environment athttps://helm-testing.waldur.com/<mr-id>/.
Adding a new micro-app
- Create
apps/<name>/—package.json(withdev/build/typecheckscripts and its ownvite/typescriptdeps),index.html(with<meta name="api-url" content="%VITE_API_URL%">, same as the root app's),vite.config.ts(own dev port — check.claude/launch.jsonand existingapps/*for ports already taken),tsconfig.json. - Add
waldur-shellas a dependency and call itsbootstrapMicroApp()from your own entry point, and compose<AppShell>for the page chrome — seeapps/micro-app-poc/src/main.tsx/OrgDashboardMock.tsxfor the reference wiring. Skip this only if the new app genuinely needs different chrome, not just different nav content (AppShellalready takes nav items/page content as props). - Decide whether it ships: omit
"waldur": { "deploy": false }to ship by default, or add it if it's a demo/POC that should stay CI/preview-only (see above). - That's it for Dockerfile/nginx/
entrypoint.sh/CI — all four scan forapps/*content, none hand-list app names. - Optional, for local dev convenience: add an entry to
.claude/launch.json(both the repo-root one and, if working from thewaldur-claude-workspacehub, its own hub-level copy — these can drift out of sync since nothing keeps them in sync automatically) sopreview_startcan launch it by name.
Known gaps / follow-ups
.claude/launch.jsonisn't part of the zero-touch convention. It's a static list read by an external tool, one port per entry — a new micro-app still needs a hand-added entry there, and the hub-level and repo-level copies can drift.waldur-shellonly dedupes chrome composition, notpackages/ui's primitives themselves. A genuinely different micro-app (a different chrome shape entirely, not just different nav items) is still real, independent evidence about those primitives' own portability — seeapps/micro-app-poc/README.md's "Shared app-shell" section for whatwaldur-shellcovers versus what stays each app's own responsibility.- One shared image vs. one image per micro-app. Today every micro-app
is coupled to the root app's release cadence (a micro-app change forces a
full homeport rebuild/redeploy). True per-micro-app deploy independence
would mean separate images (still reusing the manifest-prune install
trick) composed at the ingress/Helm layer instead of in this
repo's
nginx-tpl.conf— real infra work, only worth it once independent deploy cadence is an actual requirement.