Self-host the web app with Docker; publish the image to ghcr.io (#2033)
Thanks @rpromas.
This commit is contained in:
1 parent
4a70c03deb
commit
6597ffe072
7 files changed
+282
No files matched your search
@@ -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/<owner>/solidcraft-web:latest the latest published release
|
||||||
|
# ghcr.io/<owner>/solidcraft-web:0.14.0 a release (also 0.14); pre-releases get only their version
|
||||||
|
# ghcr.io/<owner>/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 }}
|
||||||
@@ -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
|
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.
|
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
|
### 3D mouse in the browser
|
||||||
|
|
||||||
The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API,
|
The web app reads a 3Dconnexion SpaceMouse or SpaceNavigator through the browser's Gamepad API,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+41
@@ -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"
|
||||||
Reference in new issue
Block a user