From 6597ffe0722919e793d9468cd33d9e7d3526510e Mon Sep 17 00:00:00 2001 From: rpromas <30498890+rpromas@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:49:07 +0300 Subject: [PATCH] Self-host the web app with Docker; publish the image to ghcr.io (#2033) Thanks @rpromas. --- .github/workflows/docker.yml | 105 +++++++++++++++++++++++ docs/book/src/guide/getting-started.md | 26 ++++++ packaging/docker/Dockerfile | 44 ++++++++++ packaging/docker/Dockerfile.dockerignore | 7 ++ packaging/docker/compose.yaml | 26 ++++++ packaging/docker/nginx.conf | 33 +++++++ packaging/docker/smoke-test.sh | 41 +++++++++ 7 files changed, 282 insertions(+) create mode 100644 .github/workflows/docker.yml create mode 100644 packaging/docker/Dockerfile create mode 100644 packaging/docker/Dockerfile.dockerignore create mode 100644 packaging/docker/compose.yaml create mode 100644 packaging/docker/nginx.conf create mode 100755 packaging/docker/smoke-test.sh diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..20159e17 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,105 @@ +# The web app as a container image on GitHub's container registry, for self-hosting +# (packaging/docker; the book's "Self-hosting with Docker"): +# +# ghcr.io//solidcraft-web:latest the latest published release +# ghcr.io//solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version +# ghcr.io//solidcraft-web:edge main +# +# For linux/amd64 and linux/arm64. The site is built here, as Pages and Release build it, and +# passed to the Dockerfile as its `site` stage. Pull requests that touch the image build and +# smoke-test it without publishing. Releases published by an agent or maintainer trigger this; +# the nightly pre-releases (made with GITHUB_TOKEN) do not. +name: Docker + +on: + push: + branches: [main] + release: + types: [published] + pull_request: + paths: ["packaging/docker/**", ".github/workflows/docker.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + TRUNK_VERSION: v0.21.14 + +jobs: + image: + if: ${{ !github.event.repository.private }} + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: docker-web + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install trunk + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Build the web app + working-directory: apps/solidcraft-web + run: trunk build --release + - name: Image name + id: image + # Registry names are lower case; a fork's owner may not be. + run: echo "name=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/solidcraft-web" >> "$GITHUB_OUTPUT" + - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + id: meta + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=edge,branch=main + type=ref,event=pr + labels: | + org.opencontainers.image.title=SolidCraft (web) + org.opencontainers.image.description=SolidCraft, open-source parametric CAD, in the browser: the web app served by nginx on port 8080 + org.opencontainers.image.licenses=MIT OR Apache-2.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build for the smoke test + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: packaging/docker/Dockerfile + build-contexts: site=dist/web + platforms: linux/amd64 + load: true + tags: solidcraft-web:test + - name: Smoke test + run: bash packaging/docker/smoke-test.sh solidcraft-web:test + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + if: github.event_name != 'pull_request' + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and publish + if: github.event_name != 'pull_request' + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: packaging/docker/Dockerfile + build-contexts: site=dist/web + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index b36c8711..b0f1d873 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -116,6 +116,32 @@ cd apps/solidcraft-web && trunk serve --release --no-autoreload Offline use needs `--no-autoreload`: Trunk's live reload changes the page as it serves it, so it no longer matches the build the browser would keep. Debug builds (`trunk serve`) never keep a copy. +### Self-hosting with Docker + +The web app is also a container image, `ghcr.io/tangency-labs/solidcraft-web`, for Linux on x86_64 +and ARM64: `latest` is the latest release, `0.14.0` or `0.14` a release, `edge` the newest `main`. +It serves the app with nginx on port 8080 and keeps nothing on the server: designs stay in each +visitor's browser. + +```sh +docker run -d --name solidcraft -p 8080:8080 --restart unless-stopped ghcr.io/tangency-labs/solidcraft-web:latest +``` + +Or with Docker Compose, using +[`packaging/docker/compose.yaml`](https://github.com/tangency-labs/solidcraft/blob/main/packaging/docker/compose.yaml) +(`SOLIDCRAFT_PORT` picks another port): + +```sh +docker compose -f packaging/docker/compose.yaml up -d # start +docker compose -f packaging/docker/compose.yaml pull && docker compose -f packaging/docker/compose.yaml up -d # update +docker compose -f packaging/docker/compose.yaml up -d --build # build the image from your checkout instead +``` + +Then open `http://localhost:8080/`. On another machine the browser only saves designs over HTTPS, +so put a reverse proxy with a certificate in front (Caddy, Traefik, nginx); the app works under +any URL path, such as `https://example.com/cad/`. To build the image without Compose, from the +repository root: `docker build -f packaging/docker/Dockerfile -t solidcraft-web .` + ### 3D mouse in the browser The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API, diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile new file mode 100644 index 00000000..5483888e --- /dev/null +++ b/packaging/docker/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1 +# SolidCraft in the browser as a container: the static site (the one packaging/web/package.sh +# zips) served by nginx on port 8080, as an unprivileged user. From the repository root: +# +# docker build -f packaging/docker/Dockerfile -t solidcraft-web . +# docker run --rm -p 8080:8080 solidcraft-web then open http://localhost:8080/ +# +# Or `docker compose up` with packaging/docker/compose.yaml. Saving designs needs HTTPS or +# localhost (the browser's private file storage), so put a TLS reverse proxy in front of it on a +# server; the site works under any URL path. +# +# The site is wasm, the same on every platform, so it is built once on the build machine's own +# platform even for a multi-platform image. CI builds it outside Docker (with its Rust cache and +# git commit for Help › About) and passes it in as the `site` stage: --build-context site=dist/web. + +ARG RUST_VERSION=1.95 +ARG TRUNK_VERSION=0.21.14 + +FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS build +ARG TRUNK_VERSION +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +RUN rustup target add wasm32-unknown-unknown \ + && curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-$(uname -m)-unknown-linux-gnu.tar.gz" \ + | tar -xz -C /usr/local/bin trunk +WORKDIR /src +COPY . . +WORKDIR /src/apps/solidcraft-web +# Trunk downloads the wasm-bindgen and wasm-opt its build needs; its post-build hook writes sw.js. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + trunk build --release + +FROM scratch AS site +COPY --from=build /src/dist/web/ / + +FROM nginxinc/nginx-unprivileged:1.29-alpine +COPY packaging/docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY LICENSE-MIT LICENSE-APACHE NOTICE THIRD-PARTY-LICENSES.txt /usr/share/doc/solidcraft/ +COPY --from=site / /srv/solidcraft/ +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s CMD wget -q --spider http://127.0.0.1:8080/ || exit 1 diff --git a/packaging/docker/Dockerfile.dockerignore b/packaging/docker/Dockerfile.dockerignore new file mode 100644 index 00000000..8b35ded0 --- /dev/null +++ b/packaging/docker/Dockerfile.dockerignore @@ -0,0 +1,7 @@ +# Build context for packaging/docker/Dockerfile (the repository root): the sources the web build +# needs, without build output, git history or local files. +.git +**/target* +dist +plan +docs/screenshots diff --git a/packaging/docker/compose.yaml b/packaging/docker/compose.yaml new file mode 100644 index 00000000..81db14a1 --- /dev/null +++ b/packaging/docker/compose.yaml @@ -0,0 +1,26 @@ +# Self-host SolidCraft in the browser (see "Self-hosting with Docker" in the book's Getting started). +# From this folder, or from anywhere with -f packaging/docker/compose.yaml: +# +# docker compose up -d the published image from GitHub's container registry +# docker compose pull && docker compose up -d update to the latest release +# docker compose up -d --build build the image from this checkout instead +# +# SOLIDCRAFT_PORT picks the host port (default 8080). Saving designs needs HTTPS or localhost: +# on a server, put a TLS reverse proxy (Caddy, Traefik, nginx) in front of it. +name: solidcraft +services: + solidcraft: + image: ghcr.io/tangency-labs/solidcraft-web:latest + build: + context: ../.. + dockerfile: packaging/docker/Dockerfile + ports: + - "${SOLIDCRAFT_PORT:-8080}:8080" + restart: unless-stopped + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL diff --git a/packaging/docker/nginx.conf b/packaging/docker/nginx.conf new file mode 100644 index 00000000..935bd851 --- /dev/null +++ b/packaging/docker/nginx.conf @@ -0,0 +1,33 @@ +# The SolidCraft web app (packaging/docker/Dockerfile). +# +# Caching follows the app's update check (xtask/src/web_offline.rs): index.html, sw.js and +# build.json must always be asked for again, or browsers keep an old build; Trunk's hashed +# files (filehash = true) never change and are kept for a year. +server { + listen 8080; + server_name _; + server_tokens off; + root /srv/solidcraft; + index index.html; + + gzip on; + gzip_min_length 1024; + gzip_types application/wasm application/javascript text/css application/json application/manifest+json image/svg+xml font/ttf; + + location / { + try_files $uri $uri/ =404; + add_header Cache-Control "no-cache" always; + add_header X-Content-Type-Options "nosniff" always; + } + + location ~ "-[0-9a-f]{16}(_bg)?\.(js|wasm|css)$" { + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options "nosniff" always; + } + + location ~ \.webmanifest$ { + default_type application/manifest+json; + add_header Cache-Control "no-cache" always; + add_header X-Content-Type-Options "nosniff" always; + } +} diff --git a/packaging/docker/smoke-test.sh b/packaging/docker/smoke-test.sh new file mode 100755 index 00000000..e2add0c9 --- /dev/null +++ b/packaging/docker/smoke-test.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Start the web app image and check that it serves the app the way browsers need it. +# +# Usage: packaging/docker/smoke-test.sh [IMAGE] (default: solidcraft-web:test) +# +# Runs the container read-only (as compose.yaml does) and checks the page, the service worker +# and the wasm: their status, types and caching (nginx.conf). +set -euo pipefail + +IMAGE="${1:-solidcraft-web:test}" +NAME="solidcraft-smoke-$$" +docker run -d --rm --name "$NAME" --read-only --tmpfs /tmp --cap-drop ALL -p 127.0.0.1::8080 "$IMAGE" >/dev/null +trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT +PORT="$(docker port "$NAME" 8080/tcp | head -n1 | sed 's/.*://')" +BASE="http://127.0.0.1:$PORT" + +for _ in $(seq 1 30); do + curl -fs -o /dev/null "$BASE/" && break + sleep 1 +done + +fail() { echo "error: $*" >&2; docker logs "$NAME" >&2 || true; exit 1; } + +# header URL NAME: the value of response header NAME for URL ("" when missing). +header() { curl -fsI "$1" | tr -d '\r' | awk -v h="$(echo "$2" | tr '[:upper:]' '[:lower:]')" -F': ' 'tolower($1) == h { print $2 }'; } + +curl -fs "$BASE/" | grep -q 'solidcraft_canvas' || fail "index.html is not the app" +[ "$(header "$BASE/" Cache-Control)" = no-cache ] || fail "index.html must not be cached" +[ "$(header "$BASE/sw.js" Cache-Control)" = no-cache ] || fail "sw.js must not be cached" +curl -fs "$BASE/sw.js" | grep -q 'const VERSION = "dev"' && fail "sw.js is the unbuilt template (not a release build)" +[ "$(header "$BASE/build.json" Cache-Control)" = no-cache ] || fail "build.json must not be cached" + +WASM="$(curl -fs "$BASE/" | grep -o 'solidcraft-web-[0-9a-f]*_bg\.wasm' | head -n1)" +[ -n "$WASM" ] || fail "index.html names no wasm" +[ "$(header "$BASE/$WASM" Content-Type)" = application/wasm ] || fail "$WASM is not served as application/wasm" +header "$BASE/$WASM" Cache-Control | grep -q immutable || fail "$WASM should be cached as immutable" +[ "$(header "$BASE/manifest.webmanifest" Content-Type)" = application/manifest+json ] || fail "wrong manifest type" +code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE/no-such-file")" +[ "$code" = 404 ] || fail "a missing file answers $code, not 404" + +echo "smoke test passed: $IMAGE serves $WASM"