Building & Serving
The frontend ships as a static build embedded into the Go backend binary. One combined image serves the SPA and the API from a single port — no separate frontend container. This page covers building it and how the production image is assembled.
Local build
Section titled “Local build”make frontend-buildThis runs the production build (npm run build: tsc -b type-check, then
vite build), emitting static assets to frontend/dist/. For day-to-day use
make frontend-run-dev instead — see
Frontend Overview.
The combined binary
Section titled “The combined binary”make build-combinedThis mirrors what the release image does, locally:
- Builds the frontend (
make frontend-build). - Copies
frontend/disttobackend/internal/server/dist. - Builds the backend with
go build -tags embedfrontend, producing one binary (backend/bin/vibexp) that serves the SPA and the API from one port.
Local development does not need this — run make backend-run-dev and
make frontend-run-dev as two independent processes.
The embedfrontend build tag
Section titled “The embedfrontend build tag”Whether the SPA is embedded is a compile-time switch in
backend/internal/server/:
spa_embed.go(//go:build embedfrontend) —//go:embedsinternal/server/dist, populated with the frontend build output. Compiled only in the combined release build.spa_noembed.go(//go:build !embedfrontend) — the default build. No frontend is embedded, so the backend compiles and runs with no builtfrontend/distpresent (local dev + CI); the Vite dev server serves the SPA instead.
SPA serving and fallback
Section titled “SPA serving and fallback”backend/internal/server/spa.go registers a catch-all that runs only when no
API/MCP/OAuth route matched. It serves, in order:
/config.js— the runtime frontend configuration (window.__VIBEXP_ENV__), rendered from the backend’sfrontend.*config in every build, embedded or not. See Frontend Configuration.- Embedded static assets — Vite’s content-hashed
assets/files are served with immutable, year-long cache headers; everything else (includingindex.html) isno-cacheso a redeploy is picked up immediately. index.htmlas the fallback — any path that is not a real file is served the SPA shell, so deep links to client-side routes resolve.
Source maps are not generated (sourcemap: false in vite.config.ts), so none
end up in the embedded image.
The Docker image
Section titled “The Docker image”backend/Dockerfile
builds the combined image. The build context is the repo root (not
./backend) so both frontend/ and backend/ are available:
docker build -f backend/Dockerfile .It is multi-stage:
- Frontend stage (
node:22-alpine). Runsnpm ci, thennpm run buildintofrontend/dist. Only deployment-neutral build args are passed:VITE_API_BASE_URL=/api/v1(relative — the API is same-origin in the combined image) and the release stampsVITE_RELEASE_SHA/VITE_RELEASE_DATE. Branding/analytics are not build args — they are injected at runtime via/config.js. - Backend stage (
golang:1.25.12-alpine). Copies the backend source, copiesfrontend/distintointernal/server/dist, and builds with-tags embedfrontend. - Runtime stage (
alpine:3.20). Copies the binary and migrations, bakes a production-neutral default config, and exposes port 8080.
Because @vibexp/api-client and @vibexp/design-system resolve from public
npm, the build needs no registry auth token.
Published image & release flow
Section titled “Published image & release flow”Releases are handled by a single workflow,
release.yml:
publishing a GitHub Release with a vX.Y.Z tag builds the combined image and
pushes ghcr.io/vibexp/vibexp:X.Y.Z (plus :latest for non-prereleases). The
workflow passes the commit SHA as VITE_RELEASE_SHA, and a
workflow_dispatch input is available as a manual escape hatch. This replaces
the earlier split backend-v* / frontend-v* releases — the frontend is no
longer published as a separate image.
The root docker-compose.yml pulls the published image — see
Self-Hosting and
Docker & Compose.