From e0f397e2b0fdc596321d9598c4a578351494a678 Mon Sep 17 00:00:00 2001 From: mannes Date: Thu, 24 Sep 2026 08:18:50 +0200 Subject: [PATCH] Self-hosting: Docker image on GHCR, configurable base path - BASE_PATH env var sets the Vite base, manifest start_url/scope and the SW navigateFallback (defaults to /multimeter/, so the Pages build is unchanged). The DSEG7 font URL is base-relative now (Vite prefixes the base). - Dockerfile: pnpm build of apps/web + unprivileged nginx on :8080, served at / by default (--build-arg BASE_PATH=/multimeter/ for a subpath). - docker.yml: publishes ghcr.io/libreble/multimeter (amd64 + arm64) with GITHUB_TOKEN on main and v* tags; PRs build and run docker/smoke.sh. - README: Self-host section. --- .dockerignore | 8 ++++ .github/workflows/docker.yml | 66 ++++++++++++++++++++++++++ Dockerfile | 21 +++++++++ README.md | 44 +++++++++++++++++ apps/web/src/index.css | 2 +- apps/web/vite.config.ts | 91 ++++++++++++++++++++---------------- docker/nginx.conf.template | 36 ++++++++++++++ docker/smoke.sh | 23 +++++++++ 8 files changed, 249 insertions(+), 42 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 docker/nginx.conf.template create mode 100755 docker/smoke.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5e68690 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/dist +**/coverage +.git +.github +captures +*.tsbuildinfo +*.local diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..7bb2a64 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,66 @@ +name: Container image + +# Publishes ghcr.io/libreble/ (linux/amd64 + linux/arm64) with the built-in +# GITHUB_TOKEN — no registry account or secrets. Pull requests build and smoke-test only. +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + # Build for the runner, run it, and check the page, assets, SW, manifest and SPA fallback. + - uses: docker/build-push-action@v6 + with: + context: . + load: true + tags: smoke:test + cache-from: type=gha + cache-to: type=gha,mode=max + - run: docker run -d --name smoke -p 8080:8080 smoke:test + - run: ./docker/smoke.sh http://localhost:8080/ + - if: failure() + run: docker logs smoke + + - if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - if: github.event_name != 'pull_request' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=gha diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..87321d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 +# The Multimeter web app (apps/web) as a static site: build with Node, serve with unprivileged +# nginx on :8080. +# docker build -t multimeter . # served at / +# docker build --build-arg BASE_PATH=/multimeter/ -t multimeter . # served at /multimeter/ + +FROM node:22-alpine AS build +WORKDIR /app +RUN corepack enable +COPY . . +RUN pnpm install --frozen-lockfile +ARG BASE_PATH=/ +RUN BASE_PATH="$BASE_PATH" pnpm --filter web build + +FROM nginxinc/nginx-unprivileged:1.29-alpine +ARG BASE_PATH=/ +ENV BASE_PATH=$BASE_PATH +COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/apps/web/dist /usr/share/nginx/html${BASE_PATH} +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index eef5ca1..65d954f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,50 @@ pnpm lint # eslint across the workspace > screenshots above. Demo mode needs no Bluetooth, so it runs in any browser (Firefox and > Safari included). +## Self-host + +The hosted app above is the easiest way. If you'd rather run your own copy, it's a static site — +nothing to configure, no backend, no database. + +**Docker** — a prebuilt image (linux/amd64 + arm64) is published to the GitHub Container Registry: + +```bash +docker run -d --name multimeter -p 8080:8080 --restart unless-stopped ghcr.io/libreble/multimeter +# → http://localhost:8080/ +``` + +```yaml +# compose.yaml +services: + multimeter: + image: ghcr.io/libreble/multimeter:latest + ports: ["8080:8080"] + restart: unless-stopped +``` + +The image serves the app at `/`. To serve it under a subpath behind your own proxy, build it +yourself: `docker build --build-arg BASE_PATH=/multimeter/ -t multimeter .` + +**Build and host it yourself** — any static web server works: + +```bash +pnpm install --frozen-lockfile +BASE_PATH=/ pnpm --filter web build # → apps/web/dist/ +# upload apps/web/dist/ to nginx, Caddy, Netlify, Cloudflare Pages, a bucket, … +``` + +Set `BASE_PATH` to the path you serve from (it defaults to `/multimeter/`, the GitHub Pages path). +Serve `index.html` and `sw.js` with `Cache-Control: no-cache` so updates reach installed copies. +[`docker/nginx.conf.template`](docker/nginx.conf.template) is a working nginx example. + +> **HTTPS is required.** Web Bluetooth only works in a secure context. `http://localhost` counts, +> so the app works on the machine running it — but `http://192.168.x.x:8080` from your phone +> will load and then refuse to connect. For phones, put it behind TLS: a reverse proxy with a +> real certificate (Caddy does this automatically for a domain), or `tailscale serve`. + +Self-hosted copies keep their `` pointing at libreble.github.io, so +search engines don't treat them as duplicates. + ## Keyboard shortcuts | Key | Action | Key | Action | diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 3628dc0..eea65da 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -73,7 +73,7 @@ body { from no CDN. `font-display: swap` so the digits render in the fallback mono until it loads. */ @font-face { font-family: 'DSEG7'; - src: url('/multimeter/fonts/DSEG7Classic-Bold.woff2') format('woff2'); + src: url('/fonts/DSEG7Classic-Bold.woff2') format('woff2'); font-weight: 700; font-style: normal; font-display: swap; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 9f7aaf0..cf94eea 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,5 +1,6 @@ /// import { defineConfig } from 'vitest/config'; +import { loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import { VitePWA } from 'vite-plugin-pwa'; @@ -10,47 +11,55 @@ import { VitePWA } from 'vite-plugin-pwa'; // Hosted as a GitHub Pages project site at libreble.github.io/multimeter/, so // `base` is the repo subpath and the service-worker scope + manifest start_url/scope all // mirror it. (Switch to '/' + a CNAME only if it ever moves to a dedicated custom domain.) -export default defineConfig({ - base: '/multimeter/', - plugins: [ - react(), - tailwindcss(), - VitePWA({ - registerType: 'autoUpdate', - includeAssets: ['icon.svg'], - // Generates 192/512 + maskable + apple-touch PNGs from the one SVG master and injects - // the tags and manifest icons array automatically. - pwaAssets: { image: 'public/icon.svg', preset: 'minimal-2023' }, - manifest: { - name: 'Multimeter — Bluetooth DMM logger', - short_name: 'Multimeter', - description: - 'Live readout, charting, recording and CSV/PNG export for Bluetooth multimeters (UNI-T UT60BT).', - theme_color: '#09090b', - background_color: '#09090b', - display: 'standalone', - orientation: 'any', - start_url: '/multimeter/', - scope: '/multimeter/', - categories: ['utilities', 'productivity'], +// Self-hosters override it: `BASE_PATH=/ pnpm build` (the Docker image does this). +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, '.', ''); + const base = `/${(env.BASE_PATH || '/multimeter/').replace(/^\/+|\/+$/g, '')}/`.replace( + '//', + '/', + ); + return { + base, + plugins: [ + react(), + tailwindcss(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['icon.svg'], + // Generates 192/512 + maskable + apple-touch PNGs from the one SVG master and injects + // the tags and manifest icons array automatically. + pwaAssets: { image: 'public/icon.svg', preset: 'minimal-2023' }, + manifest: { + name: 'Multimeter — Bluetooth DMM logger', + short_name: 'Multimeter', + description: + 'Live readout, charting, recording and CSV/PNG export for Bluetooth multimeters (UNI-T UT60BT).', + theme_color: '#09090b', + background_color: '#09090b', + display: 'standalone', + orientation: 'any', + start_url: base, + scope: base, + categories: ['utilities', 'productivity'], + }, + workbox: { + globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], + // SPA: serve the app shell for any in-scope navigation when offline. + navigateFallback: `${base}index.html`, + }, + }), + ], + server: { host: true }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + // Exclude entry point, generated types, and tests. + exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'], }, - workbox: { - globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'], - // SPA: serve the app shell for any in-scope navigation when offline. - navigateFallback: '/multimeter/index.html', - }, - }), - ], - server: { host: true }, - test: { - environment: 'jsdom', - setupFiles: ['./src/test/setup.ts'], - coverage: { - provider: 'v8', - reporter: ['text', 'lcov'], - include: ['src/**/*.{ts,tsx}'], - // Exclude entry point, generated types, and tests. - exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'], }, - }, + }; }); diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template new file mode 100644 index 0000000..2cc7686 --- /dev/null +++ b/docker/nginx.conf.template @@ -0,0 +1,36 @@ +# Static server for the built PWA. BASE_PATH is substituted at container start +# (nginx image envsubst; only defined env vars are replaced, so $uri stays as is). +server { + listen 8080; + server_name _; + root /usr/share/nginx/html; + index index.html; + server_tokens off; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + # Hashed build output: cache forever. + location ${BASE_PATH}assets/ { + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options nosniff; + try_files $uri =404; + } + + location ~ \.webmanifest$ { + default_type application/manifest+json; + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options nosniff; + } + + # index.html, sw.js and everything else: always revalidate so updates land. + # Unknown paths get the app shell (client-side routes, deep links). + location ${BASE_PATH} { + add_header Cache-Control "no-cache"; + add_header X-Content-Type-Options nosniff; + try_files $uri $uri/ ${BASE_PATH}index.html; + } +} diff --git a/docker/smoke.sh b/docker/smoke.sh new file mode 100755 index 0000000..89be7ca --- /dev/null +++ b/docker/smoke.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Smoke-test a running image: ./docker/smoke.sh http://localhost:8080/ +set -eu +url="${1:-http://localhost:8080/}" +origin=$(printf '%s' "$url" | sed -E 's#(https?://[^/]+).*#\1#') +fail() { echo "FAIL: $*" >&2; exit 1; } + +for i in $(seq 1 30); do curl -fs "$origin/healthz" >/dev/null && break; sleep 1; done +curl -fsS "$origin/healthz" | grep -q ok || fail healthz + +html=$(curl -fsS "$url") || fail "index at $url" +echo "$html" | grep -q '