Skip to content

feat(dashboard): serve the bundled dashboard from NestJS on the API port#275

Merged
rmyndharis merged 1 commit into
rmyndharis:mainfrom
tobiasstrebitzer:feat/same-port-dashboard
Jun 18, 2026
Merged

feat(dashboard): serve the bundled dashboard from NestJS on the API port#275
rmyndharis merged 1 commit into
rmyndharis:mainfrom
tobiasstrebitzer:feat/same-port-dashboard

Conversation

@tobiasstrebitzer

Copy link
Copy Markdown
Contributor

Summary

The dashboard and API currently run as two separate services. In production the dashboard is its
own nginx container (dashboard/Dockerfile.traefik + dashboard/nginx.conf) that serves the SPA
and reverse-proxies /api + /socket.io back to the API.

This PR makes NestJS serve the prebuilt dashboard itself, on the same port as the API (default
2785), via the canonical @nestjs/serve-static
module. A single container/port now serves both the API and the UI, and the dashboard is available
by default wherever the API runs (no extra profile/container).

Heads up: this is a breaking change for existing deployments (the dashboard moves off :2886).
See Breaking changes & migration. It is also the first of two
stacked PRs - PR2 (feat/remove-traefik) removes the now-redundant Traefik service on top of this.

Motivation

  • Reduce technical debt and surface area. Every additional service is something that can drift,
    break, and confuse adopters - extra config to keep in sync, an extra failure mode, more to document.
    The standalone dashboard/nginx service has already caused real bugs: it proxied to the wrong backend
    host (openwa instead of openwa-api), broken for both /api/ and /socket.io/ until fix: Chromium singleton locks, crashpad --database, auth hang, and 502 Bad Gateway #259, and
    needed a follow-up host-alignment fix in fix(docker): align dashboard nginx host + clear stale Chromium singleton locks #260 (2732c5f). Folding the UI into the API deletes that
    whole class of glue-config bug.
  • Running the dashboard and API as one service removes a container, a port, and the nginx/proxy
    plumbing that only existed to glue the two together - simpler to deploy and operate.
  • The codebase already assumes a same-origin setup: dashboard/src/services/api.ts defaults to the
    relative /api, the helmet CSP is all 'self', and a CORS comment already references "the bundled
    dashboard served through the proxy."
  • Container topology was inconsistent between the two compose files: the quick-start
    docker-compose.dev.yml ran the dashboard as a second container (openwa + dashboard on
    :2886), while the production docker-compose.yml gated it behind --profile with-dashboard so a
    plain docker compose up started no dashboard at all. Now the UI is served by the API in both,
    with no separate container.

Net change

This PR is close to net-neutral on lines because it trades the deleted nginx container + configs for
the serve-static wiring, a regression test, docs, and a CHANGELOG entry - but it removes a whole
service:

  • PR1 alone: 25 files, +295 / −333 (≈ −40 net; −29 excluding the package-lock.json churn).
  • Together with PR2 (Traefik removal): 27 files, +301 / −441 (≈ −140 net), and two fewer
    services/containers
    (dashboard nginx + Traefik).

The win here isn't raw line count - it's one fewer moving part. PR2 is where the larger deletion lands.

What changed

Serving

  • src/app.module.ts: registers ServeStaticModule conditionally - only when
    dashboard/dist/index.html exists (mirrors the existing lazy queueModules pattern). It serves the
    SPA with client-side fallback and excludes /api and /socket.io so those keep returning real
    API/WebSocket responses (Express 5 / path-to-regexp v8 wildcard: /api/{*splat}).
  • Opt out with SERVE_DASHBOARD=false.
  • src/main.ts: logs a clear status line at startup - serving / disabled / build-missing - so a
    missing build is obvious instead of a silent 404. Also allows fonts.googleapis.com /
    fonts.gstatic.com in the helmet CSP (the dashboard's webfonts are now governed by the API's CSP;
    without this they are blocked and the UI falls back to system fonts).

Build & packaging

  • Dockerfile: builder stage builds the dashboard (npm ci in dashboard/, reproducible) and the
    runtime image copies dashboard/dist.
  • package.json: adds build:all (API + dashboard) and prod (build then node dist/main) for
    running the production build directly without Docker, plus a dashboard:ci helper.
  • Removes the separate dashboard nginx container (dashboard/Dockerfile, dashboard/Dockerfile.traefik,
    dashboard/nginx.conf) and the dashboard compose services. Traefik now routes everything to the API.

Dev experience is unchanged

  • npm run dev still runs the Vite dev server on :2886 with HMR, proxying /api + /socket.io to
    the API on :2785. serve-static stays inert in dev because no build is present.

Docs / tests

  • CHANGELOG.md entry with breaking + migration notes; README and docs/* updated to the single-port
    model; test/serve-static.e2e-spec.ts regression test.

Breaking changes & migration

  • The dashboard moved from :2886 (separate nginx container) to the API port :2785. Update
    bookmarks, monitoring, and any external reverse-proxy config.
  • The with-dashboard compose profile and the DASHBOARD_ENABLED env var are removed (the dashboard
    ships with the API; silently ignored if still set).
  • SERVE_DASHBOARD=false opts out of serving the UI (API-only).

Split-origin hosting is still supported (not a lock-in): build the dashboard with the API origin
baked in and host dashboard/dist anywhere (CDN, object store, separate container):

VITE_API_URL=https://api.example.com npm run build   # in dashboard/

Then set SERVE_DASHBOARD=false on the API and add the dashboard origin to CORS_ORIGINS.

Testing / verification

  • npm test (436), npm run test:e2e (9, incl. the new serve-static regression), npm run lint,
    docker compose config - all green.
  • New regression test locks: /index.html, client routes → SPA fallback, assets served,
    /api/ping → controller, /api/<unknown> → JSON 404 (not the SPA). Pins the Express 5 wildcard.
  • Headless browser check against the built server: SPA mounts, login screen renders, webfonts load,
    0 console errors, 0 CSP violations, 0 failed requests. (The CSP font allowance above was added
    because this check initially surfaced 3 CSP violations.)

Notes for reviewers

  • The @Mcp()/Silkweave work is untouched; this is purely about serving the SPA.
  • PR2 (feat/remove-traefik) is stacked on this branch. Review/merge this first.

@rmyndharis

Copy link
Copy Markdown
Owner

Thanks @tobiasstrebitzer — this pair (#275 + #276) is a genuinely nice simplification (one service/port, less glue-config). I'm holding it out of v0.2.8 (just shipped) deliberately: it's a breaking deployment change (dashboard moves off :2886, Traefik removed), so it deserves its own release with a live smoke test rather than riding along with this mostly-additive batch.

Tracking it for v0.3.0. I'll review #275 then #276 as a unit and run a real serve-static smoke (SPA served + deep-link routing + /socket.io over the single port) before merging. No changes needed from you for now — leaving both open.

rmyndharis added a commit that referenced this pull request Jun 17, 2026
Strict SemVer for 0.x: breaking changes bump the MINOR (0.3.0), non-breaking
fixes/additions stay in 0.2.x; every breaking change carries a ⚠️ + migration
note. Refresh the release-summary table through v0.2.8, and re-frame v0.3.0 as
the next breaking release (deployment simplification #275/#276 + Puppeteer
config #265), with SDK/observability noted as incremental themes.
@tobiasstrebitzer

Copy link
Copy Markdown
Contributor Author

Thanks @rmyndharis, totally agree 👍

Serve the built dashboard from NestJS via @nestjs/serve-static so a single
container/port (2785) serves both the API and the UI. ServeStaticModule is
registered only when dashboard/dist/index.html exists (opt out with
SERVE_DASHBOARD=false) and excludes /api and /socket.io so they keep returning
real API/socket responses. main.ts logs a clear status line (served / disabled
/ build missing) so a missing build is obvious instead of a silent 404.

The Dockerfile builds the dashboard in its builder stage (npm ci, reproducible)
and copies dist into the runtime image. Remove the separate dashboard nginx
container and its files; Traefik now routes everything to the API.

Dev is unchanged: the Vite dev server on :2886 with HMR proxies /api and
/socket.io to the API on :2785. Split-origin hosting still works via
VITE_API_URL. Add build:all and prod scripts for non-Docker production runs, a
serve-static regression test, a CHANGELOG entry with migration notes, and
updated docs.
@rmyndharis rmyndharis force-pushed the feat/same-port-dashboard branch from 47ab2bb to 83a90ff Compare June 18, 2026 09:53
@rmyndharis rmyndharis merged commit 3eca8c4 into rmyndharis:main Jun 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants