diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d85df36a1..12705171d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -128,6 +128,9 @@ jobs:
- name: Lint
run: pnpm lint
+ - name: Typecheck
+ run: pnpm typecheck
+
- name: Run tests
run: pnpm test:run
@@ -336,6 +339,34 @@ jobs:
ARCHS=arm64 ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO
+ # Validate the web/admin production Dockerfiles (build + nginx config check)
+ # without pushing, so a broken image can't reach a release tag unnoticed. The
+ # release workflow is the only other place these build, and only on tags.
+ frontend-images:
+ name: Frontend Image (${{ matrix.service }})
+ needs: changes
+ if: needs.changes.outputs.web == 'true' || needs.changes.outputs.admin == 'true'
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ service: [web, admin]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build image (no push)
+ uses: docker/build-push-action@v6
+ with:
+ context: ./${{ matrix.service }}
+ file: ./${{ matrix.service }}/Dockerfile
+ push: false
+ platforms: linux/amd64
+ cache-from: type=gha,scope=ci-${{ matrix.service }}
+ cache-to: type=gha,mode=max,scope=ci-${{ matrix.service }}
+
security:
name: Security Scan
runs-on: ubuntu-latest
@@ -354,7 +385,7 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-latest
- needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, security]
+ needs: [changes, go-ci, web-ci, admin-ci, site-ci, make-ci, rust-ci, elixir-ci, ios-ci, frontend-images, security]
if: always()
steps:
- name: Check CI status
@@ -364,6 +395,7 @@ jobs:
[[ "${{ needs.admin-ci.result }}" == "failure" ]] || \
[[ "${{ needs.site-ci.result }}" == "failure" ]] || \
[[ "${{ needs.make-ci.result }}" == "failure" ]] || \
+ [[ "${{ needs.frontend-images.result }}" == "failure" ]] || \
[[ "${{ needs.rust-ci.result }}" == "failure" ]] || \
[[ "${{ needs.elixir-ci.result }}" == "failure" ]] || \
[[ "${{ needs.ios-ci.result }}" == "failure" ]] || \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0830fd10b..00a025e6f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,6 +69,48 @@ jobs:
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}
+ # Frontends (web dashboard, admin panel): the pnpm build runs once on the
+ # build platform and its static output is served from nginx, so a single
+ # buildx build produces both arches without a per-arch rebuild.
+ build-frontend:
+ name: Build ${{ matrix.service }}
+ needs: validate-tag
+ strategy:
+ fail-fast: false
+ matrix:
+ service: [web, admin]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: ./${{ matrix.service }}
+ file: ./${{ matrix.service }}/Dockerfile
+ push: true
+ tags: |
+ ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }}
+ ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }}
+ ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }}
+ ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod
+ platforms: linux/amd64,linux/arm64
+ cache-from: type=gha,scope=${{ matrix.service }}
+ cache-to: type=gha,mode=max,scope=${{ matrix.service }}
+
# Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated
# arm64 build under QEMU runs for an hour or more. Build each arch on a
# native runner and merge the digests into one manifest (the
@@ -170,7 +212,7 @@ jobs:
create-release:
name: Create GitHub Release
- needs: [validate-tag, build-go, merge-native]
+ needs: [validate-tag, build-go, build-frontend, merge-native]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -220,6 +262,8 @@ jobs:
| Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` |
| Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` |
| Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` |
+ | Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` |
+ | Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` |
## Deployment
diff --git a/admin/.dockerignore b/admin/.dockerignore
new file mode 100644
index 000000000..65b8937ab
--- /dev/null
+++ b/admin/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.git
+.env
+.env.*
+Dockerfile
+.dockerignore
diff --git a/admin/Dockerfile b/admin/Dockerfile
new file mode 100644
index 000000000..d9dec80ee
--- /dev/null
+++ b/admin/Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+
+# Stage 1: build the static admin panel once on the build platform. The output
+# is plain JS/CSS/HTML (arch-independent), so both target arches reuse it and
+# the heavy pnpm build runs only once.
+FROM --platform=$BUILDPLATFORM node:22-alpine AS build
+WORKDIR /app
+RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+COPY . .
+# No VITE_* is baked here on purpose: URLs come from the runtime config below,
+# so this one image works for any deployment.
+RUN pnpm build
+
+# Stage 2: serve the built SPA from nginx with a history fallback. The
+# entrypoint renders /config.js from container env at startup.
+FROM nginx:1.27-alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+RUN nginx -t
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY docker-entrypoint.sh /docker-entrypoint.d/40-warmbly-config.sh
+RUN chmod +x /docker-entrypoint.d/40-warmbly-config.sh
+EXPOSE 80
diff --git a/admin/docker-entrypoint.sh b/admin/docker-entrypoint.sh
new file mode 100644
index 000000000..6831b4c7f
--- /dev/null
+++ b/admin/docker-entrypoint.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# Render the runtime config from container env so a single built image serves
+# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
+set -eu
+
+cat > /usr/share/nginx/html/config.js <
+
+
+
diff --git a/admin/nginx.conf b/admin/nginx.conf
new file mode 100644
index 000000000..f420e4bf7
--- /dev/null
+++ b/admin/nginx.conf
@@ -0,0 +1,22 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # SPA history fallback: unknown paths serve the app shell so client-side
+ # routing works on hard reloads and deep links.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Never cache the shell or the runtime config, so a redeploy or an env
+ # change is picked up on the next load. The hashed assets they point to are
+ # cached immutably below.
+ location = /index.html { add_header Cache-Control "no-store"; }
+ location = /config.js { add_header Cache-Control "no-store"; }
+
+ location /assets/ {
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+}
diff --git a/admin/public/config.js b/admin/public/config.js
new file mode 100644
index 000000000..027d957aa
--- /dev/null
+++ b/admin/public/config.js
@@ -0,0 +1,4 @@
+// Dev/default stub. In a production image the container entrypoint overwrites
+// this file with values from environment variables. Empty here so the build's
+// baked VITE_* values are used during local development.
+window.__WARMBLY_ENV__ = window.__WARMBLY_ENV__ || {};
diff --git a/admin/src/components/captcha/TurnstileModal.tsx b/admin/src/components/captcha/TurnstileModal.tsx
index fa6dbf1fa..5c63e1f69 100644
--- a/admin/src/components/captcha/TurnstileModal.tsx
+++ b/admin/src/components/captcha/TurnstileModal.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, type ComponentProps } from "react";
import Turnstile, { type BoundTurnstileObject } from "react-turnstile";
+import { TURNSTILE_KEY } from "@/lib/env";
interface Props {
visible: boolean;
@@ -101,7 +102,7 @@ export function TurnstileModal({ visible, onToken, onError }: Props) {
const turnstileProps = {
ref: turnstileRef,
- sitekey: import.meta.env.VITE_TURNSTILE_KEY!,
+ sitekey: TURNSTILE_KEY,
onVerify: handleVerify,
onExpire: () => {
tokenRef.current = "";
diff --git a/admin/src/lib/env.ts b/admin/src/lib/env.ts
index aaf603e37..bece8fcb2 100644
--- a/admin/src/lib/env.ts
+++ b/admin/src/lib/env.ts
@@ -1,14 +1,19 @@
// Environment helpers shared across the admin app. Centralized so the
// API base URL and the "PROD / STAGING / DEV" pill always agree.
+//
+// These read the container-injected runtime config first (so one built image
+// works for any deployment), then the value Vite baked at build time.
+import { runtimeEnv } from "./runtimeConfig";
-export const API_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
+export const API_URL: string = runtimeEnv("API_URL", import.meta.env.VITE_API_URL, "http://localhost:8080");
-export const DASHBOARD_URL: string =
- import.meta.env.VITE_DASHBOARD_URL ?? "http://localhost:5173";
+export const DASHBOARD_URL: string = runtimeEnv("DASHBOARD_URL", import.meta.env.VITE_DASHBOARD_URL, "http://localhost:5173");
+
+export const TURNSTILE_KEY: string = runtimeEnv("TURNSTILE_KEY", import.meta.env.VITE_TURNSTILE_KEY);
export type EnvLabel = "production" | "staging" | "development";
-const RAW_ENV_LABEL = (import.meta.env.VITE_ENV_LABEL as string | undefined)?.toLowerCase();
+const RAW_ENV_LABEL = runtimeEnv("ENV_LABEL", import.meta.env.VITE_ENV_LABEL as string | undefined).toLowerCase();
export const ENV_LABEL: EnvLabel =
RAW_ENV_LABEL === "production" || RAW_ENV_LABEL === "prod"
diff --git a/admin/src/lib/runtimeConfig.ts b/admin/src/lib/runtimeConfig.ts
new file mode 100644
index 000000000..d3afd79f5
--- /dev/null
+++ b/admin/src/lib/runtimeConfig.ts
@@ -0,0 +1,31 @@
+// Runtime configuration override.
+//
+// Vite bakes `import.meta.env.VITE_*` at build time, which would tie one built
+// image to one deployment's URLs. To ship a single reusable image, the
+// production container serves `/config.js` (generated from container env at
+// startup) that sets `window.__WARMBLY_ENV__`. `runtimeEnv` prefers that value,
+// then the value Vite baked at build time, then a fallback. In dev the
+// `public/config.js` stub leaves the window value empty, so the baked value is
+// used and behavior is unchanged.
+
+type RuntimeEnv = Record;
+
+declare global {
+ interface Window {
+ __WARMBLY_ENV__?: RuntimeEnv;
+ }
+}
+
+function fromWindow(key: string): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ const value = window.__WARMBLY_ENV__?.[key];
+ // Ignore empties and any unsubstituted "${VAR}" placeholder.
+ if (!value || value.startsWith("${")) return undefined;
+ return value;
+}
+
+// runtimeEnv returns the container-injected value, else the build-time value,
+// else the fallback.
+export function runtimeEnv(key: string, buildTime?: string, fallback = ""): string {
+ return fromWindow(key) ?? (buildTime ? buildTime : undefined) ?? fallback;
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 63d65377e..9d702d2f5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -247,45 +247,35 @@ services:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
+ # Production build of the dashboard (static SPA served by nginx). URLs come
+ # from the WARMBLY_* env at container start via /config.js, so the same image
+ # the release publishes to GHCR is what runs here. For hot-reload development
+ # use `make app` / docker-compose.dev.yml instead.
web:
- image: node:22-alpine
- working_dir: /app
- volumes:
- - ./web:/app
- - web_node_modules:/app/node_modules
- ports: ["5173:5173"]
- command: >
- sh -c '
- until corepack enable; do echo "corepack retry..."; sleep 2; done;
- pnpm install || { echo "pnpm install failed — fix package.json then restart web"; exit 1; };
- pnpm dev --host
- '
+ build:
+ context: ./web
+ dockerfile: Dockerfile
+ ports: ["5173:80"]
environment:
- VITE_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
- VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
- VITE_TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
- # Captcha is off server-side (CAPTCHA_PROVIDER=none); the dev bypass token
- # keeps the widget satisfied without a Cloudflare key.
- VITE_TURNSTILE_KEY: "1x00000000000000000000AA"
- VITE_TURNSTILE_BYPASS_TOKEN: "warmbly-local-turnstile-bypass"
+ WARMBLY_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
+ WARMBLY_APP_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
+ WARMBLY_TRACKING_DOMAIN: ${TRACKING_DOMAIN:-${PUBLIC_HOST:-localhost}:3000}
+ # Captcha is off server-side (CAPTCHA_PROVIDER=none); this Cloudflare test
+ # key keeps the widget satisfied without a real key.
+ WARMBLY_TURNSTILE_KEY: "1x00000000000000000000AA"
depends_on:
backend: { condition: service_healthy }
admin:
- image: node:22-alpine
- working_dir: /app
- volumes:
- - ./admin:/app
- - admin_node_modules:/app/node_modules
- ports: ["5174:5174"]
- command: >
- sh -c '
- until corepack enable; do echo "corepack retry..."; sleep 2; done;
- pnpm install || { echo "pnpm install failed — fix package.json then restart admin"; exit 1; };
- pnpm dev --host --port 5174
- '
+ build:
+ context: ./admin
+ dockerfile: Dockerfile
+ ports: ["5174:80"]
environment:
- VITE_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
+ WARMBLY_API_URL: ${API_PUBLIC_URL:-http://${PUBLIC_HOST:-localhost}:8080}
+ WARMBLY_DASHBOARD_URL: ${APP_URL:-http://${PUBLIC_HOST:-localhost}:5173}
+ WARMBLY_ENV_LABEL: ${ENV_LABEL:-development}
+ WARMBLY_TURNSTILE_KEY: "1x00000000000000000000AA"
depends_on:
backend: { condition: service_healthy }
@@ -309,5 +299,3 @@ volumes:
redis_data:
nats_data:
blobs:
- web_node_modules:
- admin_node_modules:
diff --git a/docs/content/docs/development/deployment-guide.mdx b/docs/content/docs/development/deployment-guide.mdx
index 7bd3ea86f..fd9ab664a 100644
--- a/docs/content/docs/development/deployment-guide.mdx
+++ b/docs/content/docs/development/deployment-guide.mdx
@@ -46,18 +46,23 @@ Each subsystem is an env-var switch. No-cloud defaults on the left:
Every service already binds to `0.0.0.0` via the Docker port mappings, so it
listens on all interfaces. The app's URLs default to `localhost`, so to use it
-from another machine set one variable in `.env` — the API URL, CORS origins,
-websocket URL, tracking domain, and the frontend `VITE_*` URLs all derive from it:
+from another machine set one variable in `.env`, and the API URL, CORS origins,
+websocket URL, and tracking domain all derive from it:
```bash
PUBLIC_HOST=192.168.1.50 # your machine's LAN IP, or a domain
```
-Then open `http://192.168.1.50:5173`. For a public domain over HTTPS, run a
-reverse proxy (Caddy or nginx) in front terminating TLS, point `PUBLIC_HOST` (or
-the individual `APP_URL` / `CORS_ALLOW_ORIGINS` / `WEBSOCKET_URL` /
-`API_PUBLIC_URL` / `TRACKING_DOMAIN` vars) at your domain, and serve the
-dashboard and admin as static builds rather than the Vite dev servers.
+Then open `http://192.168.1.50:5173`. The dashboard (`web`) and admin panel
+(`admin`) run as production static builds served by nginx, not Vite dev servers.
+Their URLs are injected at container start from `WARMBLY_*` env (rendered into a
+`/config.js`), so the published image is deployment-agnostic: compose derives
+`WARMBLY_API_URL` / `WARMBLY_APP_URL` / `WARMBLY_TRACKING_DOMAIN` from
+`PUBLIC_HOST` and `API_PUBLIC_URL`, and the same image runs anywhere. For a
+public domain over HTTPS, run a reverse proxy (Caddy or nginx) in front
+terminating TLS and point `PUBLIC_HOST` (or the individual `APP_URL` /
+`CORS_ALLOW_ORIGINS` / `WEBSOCKET_URL` / `API_PUBLIC_URL` / `TRACKING_DOMAIN`
+vars) at your domain.
## Scaling
@@ -194,7 +199,7 @@ ghcr.io//warmbly/worker:vX
ghcr.io//warmbly/worker:prod
```
-And the same set of tags for `backend`, `consumer`, `tracking`, `realtime`. The `build-push.yml` workflow on `main` publishes `:{sha}` and `:dev`.
+And the same set of tags for `backend`, `consumer`, `tracking`, `realtime`, and the two frontends `web` (dashboard) and `admin`. The `build-push.yml` workflow on `main` publishes `:{sha}` and `:dev`.
For self-hosters: this works out of the box on any fork as long as you have `packages: write` permission enabled in repo settings.
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 000000000..65b8937ab
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.git
+.env
+.env.*
+Dockerfile
+.dockerignore
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 000000000..f46bb3643
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,24 @@
+# syntax=docker/dockerfile:1
+
+# Stage 1: build the static dashboard once on the build platform. The output is
+# plain JS/CSS/HTML (arch-independent), so both target arches reuse it and the
+# heavy pnpm build runs only once.
+FROM --platform=$BUILDPLATFORM node:22-alpine AS build
+WORKDIR /app
+RUN corepack enable && corepack prepare pnpm@11.9.0 --activate
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
+RUN pnpm install --frozen-lockfile
+COPY . .
+# No VITE_* is baked here on purpose: URLs come from the runtime config below,
+# so this one image works for any deployment.
+RUN pnpm build
+
+# Stage 2: serve the built SPA from nginx with a history fallback. The
+# entrypoint renders /config.js from container env at startup.
+FROM nginx:1.27-alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+RUN nginx -t
+COPY --from=build /app/dist /usr/share/nginx/html
+COPY docker-entrypoint.sh /docker-entrypoint.d/40-warmbly-config.sh
+RUN chmod +x /docker-entrypoint.d/40-warmbly-config.sh
+EXPOSE 80
diff --git a/web/docker-entrypoint.sh b/web/docker-entrypoint.sh
new file mode 100644
index 000000000..40c8dea4a
--- /dev/null
+++ b/web/docker-entrypoint.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# Render the runtime config from container env so a single built image serves
+# any deployment. Runs before nginx starts (nginx /docker-entrypoint.d hook).
+set -eu
+
+cat > /usr/share/nginx/html/config.js <
Warmbly
+
+