From f0ae12dd22775895412be44060bcba9f15707bb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20P=C5=82acheci=C5=84ski?= <45981109+fplachecinski@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:10:55 +0200 Subject: [PATCH] Self-host the web app with Docker (nginx, community-maintained) (#1334) A multi-stage Dockerfile builds photocraft-web with Trunk and serves dist/web from an unprivileged nginx on :8080, with /healthz, precompressed gzip, the wasm MIME type and immutable hashed assets. Rust and Trunk follow the release workflow, and the PHOTOCRAFT_BUILD_SHA/DATE build args stamp About. Documented in packaging/web/README.md; not built in CI. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 18 +++++++++++ Dockerfile | 68 ++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ docs/development.md | 10 ++++++ packaging/web/README.md | 59 ++++++++++++++++++++++++++++++++++ packaging/web/nginx.conf | 58 ++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 packaging/web/nginx.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..898584b7b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.agents +.codex +.aws +**/target +dist +corpus +log +plan +secrets +temp +craft-fonts +book/output +**/.DS_Store +**/.env +**/.env.* +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..66734f255 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 +# Community-maintained self-hosting recipe (see packaging/web/README.md#docker); the official +# web build is the `web` job in .github/workflows/release.yml (packaging/web/package.sh). +# +# Source of truth for the toolchain: release.yml and ci.yml build with the latest *stable* Rust +# (dtolnay/rust-toolchain@stable), not a pinned version. This default is the stable release CI +# used when it was last bumped; raise it when CI's stable moves on. It must stay >= the +# workspace `rust-version` in Cargo.toml. +ARG RUST_VERSION=1.99 +ARG NGINX_VERSION=1.28-alpine + +FROM rust:${RUST_VERSION}-slim-bookworm AS builder +# Must match `TRUNK_VERSION` in the `web` job of .github/workflows/release.yml. +ARG TRUNK_VERSION=0.21.14 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && rustup target add wasm32-unknown-unknown +RUN set -eu; \ + case "$(uname -m)" in \ + x86_64) arch=x86_64 ;; \ + aarch64) arch=aarch64 ;; \ + *) echo 'The web builder supports amd64 and arm64' >&2; exit 1 ;; \ + esac; \ + asset="trunk-${arch}-unknown-linux-musl.tar.gz"; \ + url="https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}"; \ + cd /tmp; \ + curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset" -o "$asset"; \ + curl -fsSL --retry 3 -A Photocraft-dev "$url/$asset.sha256" -o "$asset.sha256"; \ + printf '%s %s\n' "$(cat "$asset.sha256")" "$asset" | sha256sum -c -; \ + tar -xzf "$asset" -C /usr/local/bin trunk; \ + rm "$asset" "$asset.sha256"; \ + trunk --version + +WORKDIR /src +COPY . . +WORKDIR /src/apps/photocraft-web +# Docker serving has no static-host per-file size cap. Thin LTO uses less build +# memory than the release zip's fat LTO; retain the profile's other optimizations. +ARG CARGO_PROFILE_WASM_RELEASE_LTO=thin +ARG CARGO_BUILD_JOBS=1 +# Optional build provenance shown in About and `--version` (crates/engine/src/build_info.rs). +# .git is not in the build context, so pass it in, e.g. +# --build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F) +# Left empty, the build reports itself as a dev build. +ARG PHOTOCRAFT_BUILD_SHA= +ARG PHOTOCRAFT_BUILD_DATE= +# index.html selects the wasm-release profile, HEIF support and wasm-opt -Oz. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/src/target \ + --mount=type=cache,target=/root/.cache/trunk \ + trunk build --release --locked \ + && test -s /src/dist/web/index.html \ + && gzip -9 -k /src/dist/web/*.wasm /src/dist/web/*.js /src/dist/web/index.html + +FROM nginx:${NGINX_VERSION} AS runtime +COPY packaging/web/nginx.conf /etc/nginx/nginx.conf +COPY --from=builder /src/dist/web/ /usr/share/nginx/html/ +COPY LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md /usr/share/nginx/html/ +COPY assets/fonts/OFL-*.txt assets/icons/LICENSE-*.txt assets/dict/LICENSE-*.txt \ + crates/ui-egui/src/i18n/LICENSE-translations.txt /usr/share/nginx/html/licenses/ +# Bypass the stock entrypoint: this configuration needs no runtime rewriting. +USER nginx +EXPOSE 8080 +ENTRYPOINT [] +CMD ["nginx", "-g", "daemon off;"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1 diff --git a/README.md b/README.md index 21642ee61..787433909 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,8 @@ Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are Developer, architecture, automation, format, and security documentation is maintained in the [PhotoCraft documentation book](book/). +To self-host the browser version with Docker, see [Docker web hosting](packaging/web/README.md#docker). + ## Security Security architecture, threat modeling, parser hardening, fuzzing, and vulnerability reporting are covered in the [security documentation](book/src/security/) and the repository [security policy](SECURITY.md). diff --git a/docs/development.md b/docs/development.md index 3c7303310..e354a9fd0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -303,6 +303,16 @@ Any static file server works for `dist/web`, for example `python3 -m http.server URL flags: `?webgl` forces the WebGL2 backend, and `?cpu` forces the CPU canvas path. +To build and serve the web app entirely in Docker, run from the repository root: + +```sh +docker build --load -t photocraft-web:local . +docker run --rm -p 8080:8080 photocraft-web:local +``` + +Open http://localhost:8080/. See [Docker hosting](../packaging/web/README.md#docker) for +HTTPS/reverse-proxy deployment and browser limitations. + How the web shell (`apps/photocraft-web/src/web.rs`) differs from desktop: - **Open** uses `rfd::AsyncFileDialog`. The bytes arrive asynchronously in `Services::inbox`, which the app drains every frame. diff --git a/packaging/web/README.md b/packaging/web/README.md index 96225cd82..acbb8afd3 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -12,6 +12,65 @@ static site in `photocraft-web-/`: There is no server-side code. Upload the folder's contents anywhere that serves static files. +## Docker + +> The Docker recipe (`Dockerfile`, `.dockerignore`, `packaging/web/nginx.conf`) is +> **community-maintained**: it is not built in CI or used for releases, so it can lag behind the +> official web build above. Fixes are welcome. + +From the repository root (Docker is the only build prerequisite): + +```sh +docker build --load -t photocraft-web:local . +docker run -d --name photocraft-web --restart unless-stopped \ + -p 8080:8080 photocraft-web:local +``` + +Open **http://localhost:8080/**. The multi-stage `Dockerfile` builds the existing Rust/Wasm +app with Trunk, including HEIF support, then copies only the static site into NGINX. The +runtime runs as the `nginx` user on port **8080**, with a `/healthz` endpoint and Docker +health check. It serves precompressed gzip assets, the Wasm MIME type, immutable hashed +assets, and an HTML page that revalidates after deployments. No Rust installation, Node.js, +database, GPU passthrough, or document volume is needed on the host. + +The first build downloads Rust dependencies and Trunk's Wasm tools and can take several +minutes. Subsequent builds reuse Cargo caches. The builder supports Linux amd64 and arm64; +the default Rust version is the stable release CI used when the `Dockerfile` was last updated +(CI and releases always use the latest stable), and Trunk is pinned to the release workflow's +`TRUNK_VERSION`. `RUST_VERSION`, `TRUNK_VERSION`, and `NGINX_VERSION` can be overridden with +`--build-arg`. To stamp the About dialog with the commit and date instead of "dev build", pass +`--build-arg PHOTOCRAFT_BUILD_SHA=$(git rev-parse HEAD) --build-arg PHOTOCRAFT_BUILD_DATE=$(date -u +%F)`. +The web build does not embed the optional `craft-fonts` checkout. + +The Docker build uses thin LTO and one Cargo build job to reduce peak memory, while keeping +the web profile's size optimizations and `wasm-opt -Oz`. Its Wasm size may differ from the +release zip figures below; NGINX has no 25 MiB file limit. To use the release zip's fat LTO on +a builder with more memory, pass `--build-arg CARGO_PROFILE_WASM_RELEASE_LTO=fat`. +`--build-arg CARGO_BUILD_JOBS=4` enables more concurrent compilation on larger builders. A compiler killed with +`SIGKILL`/`cannot allocate memory` means the Docker/Podman VM needs more memory or swap. + +For a public deployment, put an HTTPS reverse proxy or your hosting platform's TLS endpoint +in front of container port 8080. With a reverse proxy on the same host, bind only loopback +(`-p 127.0.0.1:8080:8080`); with a proxy container, connect both through a Docker network. +HTTPS enables WebGPU and browser clipboard APIs. If hosting under `/photocraft/`, redirect +`/photocraft` to `/photocraft/` and strip that prefix when proxying to the container. Relative +asset URLs then work without rebuilding. For example, in an existing HTTPS NGINX server: + +```nginx +location = /photocraft { return 301 /photocraft/; } +location /photocraft/ { + proxy_pass http://127.0.0.1:8080/; +} +``` + +The editor and image processing run on the visitor's device. Open uses the browser file +picker; Save and Export download files. Preferences use browser localStorage. There is no +server-side document storage, desktop TCP control server, or web autosave/crash recovery; +save/download work before closing or reloading the page. A browser with WebGPU or WebGL2 is +required; `?webgl` forces the fallback when troubleshooting. + +To verify the browser deployment, open the page, open an image and save/export it. + ## Sizes Measured on the 0.2.x build (`packaging/web/package.sh`; gzip `-9`, Brotli quality 11): diff --git a/packaging/web/nginx.conf b/packaging/web/nginx.conf new file mode 100644 index 000000000..3e49455ef --- /dev/null +++ b/packaging/web/nginx.conf @@ -0,0 +1,58 @@ +worker_processes auto; +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + sendfile on; + server_tokens off; + + # All runtime writes go to /tmp, allowing a read-only container filesystem. + client_body_temp_path /tmp/client_temp; + proxy_temp_path /tmp/proxy_temp; + fastcgi_temp_path /tmp/fastcgi_temp; + uwsgi_temp_path /tmp/uwsgi_temp; + scgi_temp_path /tmp/scgi_temp; + + # Precompressed at build time; uncompressed clients receive the original. + gzip_static on; + gzip_vary on; + + server { + listen 8080; + listen [::]:8080; + root /usr/share/nginx/html; + index index.html; + add_header X-Content-Type-Options nosniff always; + + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location = /index.html { + add_header Cache-Control no-cache always; + add_header X-Content-Type-Options nosniff always; + } + + # Trunk gives these files content hashes. Missing assets must be 404s, + # never an HTML fallback or an error cached for a year. + location ~ \.(wasm|js)$ { + types { application/wasm wasm; text/javascript js; } + add_header Cache-Control "public, max-age=31536000, immutable"; + add_header X-Content-Type-Options nosniff always; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ =404; + } + } +}