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 same goes for breadcrumbs: use waldur-ui's BreadcrumbTrail (with
BreadcrumbsProvider and useBreadcrumbs once pages set their own crumbs)
rather than copying src/navigation/header/breadcrumb/. It knows no router:
a renderLink on the provider turns a crumb's to into your router's link.
See breadcrumbs.md.
The boot splash is shared too: see Boot splash.
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.
Boot splash
packages/boot-splash (waldur-boot-splash) gives every app the same
branded loading screen as the root app, from the very first frame:
waldurBootSplash()(waldur-boot-splash/vite) goes in the app'svite.config.ts. It inlines the splash's styles into the app's ownindex.html, adds its markup at the end of<body>(outside the app's root, as a fixed layer), and links a smallboot-splash.jsthat runs render-blocking in<head>and brands the splash from the cache before it paints. The script is served in dev and emitted in build as a content-hashed file underassets/, so nginx's immutable caching applies and a new version always gets a new URL. It is an external file, not inline, because production sendsscript-src 'self'.index.htmlitself needs nothing. Other pages built with the same config, such as Storybook'siframe.html, are left alone.saveSplashBranding(WALDUR_CORE, { translate })caches the deployment's name, brand colour, compact logo and dark-theme setting, and the splash's text in the user's language, for the next visit. The logo is redrawn at display size into a small data URI; one that can't be made small enough is dropped, so the splash shows the default mark instead of an empty box.bootstrapMicroApp()calls it once the runtime config and the user's language have loaded; the root app calls it fromafterBootstrap().useBootSplashDismiss(ready)(waldur-boot-splash/react) fades the splash out onceready, or removes it if unmounted first, so a crash never stays hidden behind it.AppShellpasses "bootstrapped" (whenMicroAppBootstrapped()fromwaldur-shell): the splash stays until the tenant's brand, font, sidebar style and language are applied, so the switch from the defaults happens behind it. The root app instead waits for its first route and that route's code chunks (src/BootSplashGate.tsx). For anything else,dismissBootSplash().
Every app on the origin shares the cache (waldur/boot-splash) and the
theme key, so the root app and its micro-apps brand each other's splash: a
user who has opened one gets a branded splash in the others on first visit.
A browser that has never loaded any of them shows the defaultName passed to
the plugin, the Waldur colours and English text.
A micro-app that crashes before AppShell mounts keeps the splash, whose
"Taking longer than usual" notice offers a reload after 8 seconds. Mount
useBootSplashDismiss higher up, or call dismissBootSplash() from the
error screen, if the app has one of its own.
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). AddwaldurBootSplash()fromwaldur-boot-splash/viteto itsvite.config.tsplugins for the shared boot splash (see Boot splash);AppShelltakes it down once the app is bootstrapped.AppShellalso hosts dialogs: page content opens them withuseModal()/confirm()fromwaldur-ui, as in the main app (see modals.md). ForuseManagedMutationfromwaldur-shell, wrap the app in a react-queryQueryClientProvider. - 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.