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 + +