Self-host the web app with Docker; publish the image to ghcr.io (#2033)

Thanks @rpromas.
This commit is contained in:
rpromas authored and GitHub committed 2026-10-17 13:49:07 -04:00
1 parent 4a70c03deb
commit 6597ffe072
7 files changed
+282

No files matched your search

+44
View File
@@ -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
+7
View File
@@ -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
+26
View File
@@ -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
+33
View File
@@ -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;
}
}
+41
View File
@@ -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"