Scope. This page is how each artefact is produced: getting the source, toolchain prerequisites and the per-platform build, signing and packaging steps. The Deployment guide is where each artefact runs - delivery models, hosting and routing for the web shell and what the optional MCP and CA services need. Compilers, SDKs and store submission here; hosts, rewrites and rollout there.
How to build Lolly for each distribution target: standalone CLI binary, desktop app (macOS / Windows / Linux), mobile apps (iOS / Android) and the web shell as a container image for Kubernetes.
Prerequisites (all targets)
- Node.js ≥ 22.18 (the 22 LTS line) or ≥ 24, and npm 10+. The repo's scripts run TypeScript sources directly (
node scripts/foo.ts), which relies on Node's unflagged type-stripping - added in Node 22.18 and 24. Node 20 and early 22.x fail atnpm install..nvmrcpins22, so nvm users can just runnvm installin the repo. - The repo and its submodules checked out, dependencies installed - see below
Getting the source
Lolly is a parent repo plus a set of git submodules. The parent owns engine/, schemas/, scripts/, tests/, api/, brands/lolly-start/ and profiles.json; everything else is mounted from its own repository under github.com/lolly-tools:
| Path | Repository | Contains |
|---|---|---|
community/ | lolly-tools | the brand-agnostic tool definitions |
docs/ | lolly-docs | this documentation |
shells/web | lolly-web | the PWA |
shells/cli | lolly-cli | the scriptable CLI |
shells/tui | lolly-tui | the interactive terminal shell |
shells/tauri-desktop | lolly-desktop | the desktop app |
shells/tauri-mobile | lolly-mobile | the iOS / Android app |
shells/chrome-extension | lolly-chrome-extension | the browser extension |
services/mcp | lolly-mcp-server | the MCP server |
services/ca | lolly-ca | the Content Credentials CA |
brands/suse | suse-lolly | private - the SUSE tool pack and catalog |
All of those except brands/suse are public, so a read-only contributor gets a complete, buildable checkout with the script (or single command) below. Write access is enforced by the host at push time, not by the checkout, so the setup is identical whether you're a maintainer of a given repo or just reading it.
The setup script
On macOS and openSUSE, one script takes a fresh clone to a running state - it detects your package manager (Homebrew or zypper), installs git and Node if they are missing or too old, initialises every public submodule, runs npm install and selects a content profile:
git clone https://github.com/lolly-tools/lolly.git
cd lolly
./setup.sh # public setup: community tools + the blank "lolly-start" brand
npm run dev:web # web shell at http://localhost:5173
./setup.sh is idempotent - safe to re-run any time: after a git pull, or to repair a half-finished checkout. Flags:
--susealso mounts the private SUSE brand pack and selects the SUSE profile (needs repo access)--profile <suse|lolly-start>forces a content profile after install--skip-nodeleaves Node entirely to you - the nvm path:nvm installin the repo (it reads.nvmrc), then./setup.sh --skip-node--helplists everything
By hand
If you'd rather run the steps yourself, prefer SSH remotes with each submodule on a branch from the start or are on a distro the script doesn't cover:
git -c url."git@github.com:".insteadOf=https://github.com/ \
clone --recurse-submodules git@github.com:lolly-tools/lolly.git && \
cd lolly && \
git config url."git@github.com:".insteadOf https://github.com/ && \
git submodule foreach 'git checkout main' && \
npm install
What each step is for:
url.insteadOfrewrites the HTTPS URLs recorded in.gitmodulesto SSH. SSH clones work for public repos regardless of write access, so this is safe for everyone - and it means the submodules you can push to are already set up to push, with no per-repo remote fiddling later. Repeating it as agit configafter the clone makes the rewrite stick for futuregit submodule updateruns. Drop both lines if you'd rather authenticate over HTTPS.--recurse-submoduleschecks out every submodule in the table exceptbrands/suse, which is markedupdate = nonein.gitmodulesprecisely so that public clones and CI skip the private pack and fall back to the neutrallolly-startprofile.git submodule foreach 'git checkout main'puts each submodule on a branch. Submodules clone in detached HEAD, and a commit made there is easy to lose. Every submodule's default branch ismain. Note this moves each one to the remote tip, which can sit ahead of the commit the parent recorded;git submodule updatereturns them to the recorded commits if a build starts behaving oddly.npm installmust run after the submodules exist, because the npm workspaces resolve against apackage.jsonin each one. Itspostinstallrunsscripts/use-profile.ts --auto, which builds the gitignoredtools/andcatalog/profile views - see Configuration.
Verify the result with npm run profile (shows the active profile) and npm run cli (lists the tools it can see).
The private SUSE brand pack
brands/suse holds the SUSE tool pack and its catalog, including licensed fonts and music, so it lives in a private repository. If you have access, opt in explicitly:
git submodule update --init --checkout brands/suse
npm run profile:suse
(./setup.sh --suse does both in one go.) Without it, npm run profile reports lolly-start as active - the blank brand, which is the correct default for anyone not doing SUSE-specific work, and the profile the public site and CI build against.
If something fails
npm installdies with a syntax error in a.tsfile - your Node is too old for type-stripping; you need ≥ 22.18 or ≥ 24 (node -v). With Homebrew, notenode@22is keg-only: addexport PATH="$(brew --prefix node@22)/bin:$PATH"to your shell profile.Cannot find module '@lolly-tools/…'or a workspacepackage.jsonis missing - the submodules weren't checked out beforenpm install. Rungit submodule update --init --recursive, thennpm installagain.brands/susewon't clone - it's private. Drop--suse; you land onlolly-startand everything still builds and runs.- A tool edit doesn't show up, or ends up in the wrong repo -
tools/andcatalog/at the repo root are gitignored symlink views into the packs, so edits flow through to the pack checkout and commits belong inside the owning submodule - see the next section.
Working across submodules
A change inside a submodule is always two commits: one in the submodule repository, and one in the parent to move the recorded pointer. Push the submodule first, or the parent will point at a commit nobody else can fetch.
One case deserves care. catalog/tools/index.json is generated per brand, and every brand's index lists the community tools - so editing a community tool.json leaves every other brand's index stale, and the single-profile npm run build:catalog can't see the drift because it only ever looks at the active view. After any community tool change, run the all-profiles variants instead:
npm run build:catalog:all # rebuild every mounted profile, then restore the active one
npm run validate:catalog:all # validate every mounted profile; exits 1 on drift (the CI guard)
Day-to-day submodule workflow - syncing, status across all repos, verification - lives in scripts/subrepo/README.md in the parent repo.
CLI
Development use (no build needed)
The CLI shell runs directly from the repo with Node.js:
# List available tools
npm run cli
# Show inputs for a tool
npm run cli -- qr-code
# Run a tool and write output
npm run cli -- qr-code --url=https://suse.com --color=#0c322c --output=./qr.svg
# Explicit format
npm run cli -- quotes --quote="Open source wins." --name="Andy" --export=png --output=./quote.png
The CLI supports SVG, EMF, EPS, HTML and the text/data formats (JSON, CSV, ICS, VCF, MD, TXT) natively - hydrated by the engine with no browser engine needed (SVG/EMF only for tools with an <svg>-based template, since the lean CLI has no layout engine). PNG from an <svg>-based tool is also browser-free: resvg rasterises the engine's own SVG (Tier A). The remaining raster formats - JPG, WebP, PDF and video (GIF, WebM, MP4), plus HTML-layout PNG - render through the CLI's own scoped headless Chromium (Tier B): install it once with lolly install-browser, then they export straight from the CLI. (ZIP is the one format the lean CLI leaves out - no zip dependency - so its batch writes a folder instead.)
Standalone binary
To distribute the CLI without requiring Node.js installed:
1. Bundle to a single CJS file:
cd shells/cli
npx esbuild bin/lolly.ts \
--bundle \
--platform=node \
--target=node20 \
--format=cjs \
--outfile=dist/lolly.cjs
2. Package with @yao-pkg/pkg (includes a Node runtime):
npx @yao-pkg/pkg dist/lolly.cjs \
--targets node20-macos-arm64,node20-macos-x64,node20-linux-x64,node20-win-x64 \
--output dist/lolly
Output binaries land in shells/cli/dist/ - one per platform target.
The
tools/andcatalog/directories must ship alongside the binary. The CLI resolves them relative to the binary location, so the expected layout is: ``lolly ← binary tools/ ← tool definitions catalog/ ← asset + tool catalogs``
TUI
Development use (no build needed)
The interactive terminal shell runs straight from the repo - it needs a real TTY, so run it in your terminal rather than a captured pipe:
npm run tui
It's the CLI's engine and render path under an interactive, keyboard-first UI (built on Ink, run through tsx). The DOM-free formats - SVG, EMF, EPS, HTML and the text/data formats - render with nothing extra. State (saved sessions, project folders, profile) persists on disk in the directory all three local shells share - $LOLLY_STATE_DIR, else the desktop app's data directory when the app is installed here, else ~/.lolly; exports default to ~/Desktop. See the TUI guide for the full key map and views.
Browser render tier (raster / PDF / video / URL capture)
Unlike the bare CLI, the TUI can produce browser-bound formats via a scoped headless Chromium - the same one the MCP server uses. Set it up once:
npm run install:browser # Chromium → services/mcp/.browsers (shared with services/mcp)
npm run build:web # a built web shell the TUI drives for pixel-identical raster/pdf/video
With those present, raster (PNG/JPG), PDF, video and the url-shot live-URL capture all export from the terminal; without them, those formats fail with a clear setup message and the TUI writes HTML instead. The browser is lazy - it launches only on the first such export, never at startup. Override the browser with LOLLY_BROWSER_CHANNEL / LOLLY_BROWSER_PATH, or point at a running/prebuilt web shell with LOLLY_WEB_BASE / LOLLY_WEB_DIST.
No standalone-binary recipe yet - the TUI ships as a repo/dev surface today. Package it like the CLI (esbuild +
@yao-pkg/pkg) once a target calls for it.
Desktop app (macOS / Windows / Linux)
Prerequisites
Rust toolchain:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup update
Tauri CLI (Node package - installed per-shell):
cd shells/tauri-desktop
npm install
Platform build tools:
| Platform | Required |
|---|---|
| macOS | Xcode Command Line Tools (xcode-select --install) |
| Windows | Microsoft C++ Build Tools or Visual Studio with C++ workload |
| Linux | build-essential, libgtk-3-dev, libwebkit2gtk-4.1-dev, libappindicator3-dev |
Full list: https://tauri.app/start/prerequisites/
Icons
Tauri requires icon files at src-tauri/icons/. Generate them from a 1024×1024 source PNG (the build will fail with a missing-file error if this step is skipped):
cd shells/tauri-desktop
npx @tauri-apps/cli icon path/to/icon-1024.png
This writes all required sizes and formats (32x32.png, 128x128.png, 128x128@2x.png, icon.icns, icon.ico, etc.) to src-tauri/icons/.
Placeholder icons committed to the repo are solid-green squares - replace them with production artwork before releasing.
Development
cd shells/tauri-desktop
npm run dev
# or from repo root:
npm run dev:desktop
Tauri opens a native window. The Vite dev server runs in the background; hot reload works. The state bridge uses the filesystem override (bridge-overrides/state.ts) - saved states go to $APPDATA/Lolly/saved-state/.
Production build
cd shells/tauri-desktop
npm run build
# or from repo root:
npm run build:desktop
This runs vite build (producing dist/) then tauri build. Output:
| Platform | Artifact | Location |
|---|---|---|
| macOS | .app + .dmg | src-tauri/target/release/bundle/macos/ |
| Windows | .msi + .exe NSIS installer | src-tauri/target/release/bundle/ |
| Linux | .deb + .AppImage | src-tauri/target/release/bundle/ |
Cross-compilation
Tauri does not support cross-compilation out of the box. Build each platform on its native OS, or use a CI matrix (GitHub Actions macos-latest / windows-latest / ubuntu-latest).
Mobile apps (iOS / Android)
Prerequisites
In addition to the Rust toolchain and Tauri CLI above:
Android
- Install Android Studio
- In SDK Manager, install:
- Android SDK Platform (API 33 or higher)
- NDK (Side by side) - version 26+
- Android SDK Command-line Tools
- Set environment variables:
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
export NDK_HOME=$ANDROID_HOME/ndk/$(ls $ANDROID_HOME/ndk | tail -1)
- Add Android Rust targets:
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
i686-linux-android \
x86_64-linux-android
iOS (macOS only)
- Install Xcode from the App Store (the full app, not just the Command Line Tools)
- Accept the license:
sudo xcodebuild -license accept - Install CocoaPods -
tauri ios initgenerates a Podfile and runspod install:brew install cocoapods - Add iOS Rust targets:
rustup target add \
aarch64-apple-ios \
aarch64-apple-ios-sim \
x86_64-apple-ios
See Building for iOS for the full iOS walkthrough - prerequisites, one-time init, the simulator dev loop, code signing and camera permissions.
First-time platform init
Run once to generate the native project files (gen/android/ or gen/apple/):
cd shells/tauri-mobile
npm install
# Android
npm run tauri android init
# iOS
npm run tauri ios init
The gen/ directory contains the generated Gradle / Xcode projects. It is gitignored - regenerate it with the init command on a fresh checkout.
Icons (mobile)
cd shells/tauri-mobile
npx @tauri-apps/cli icon path/to/icon-1024.png
Development
Android (emulator or connected device with USB debugging enabled):
cd shells/tauri-mobile
npm run dev:android
# or from repo root:
npm run dev:android
iOS (macOS only - requires Simulator or provisioned device):
cd shells/tauri-mobile
npm run dev:ios
# or from repo root:
npm run dev:ios
Production build
# Android - outputs APK + AAB
npm run build:android
# or: npm run build:android from repo root
# iOS - outputs .ipa
npm run build:ios
# or: npm run build:ios from repo root
Android signing - set these env vars before building for release:
export ANDROID_KEY_STORE=/path/to/keystore.jks
export ANDROID_KEY_STORE_PASSWORD=...
export ANDROID_KEY_ALIAS=...
export ANDROID_KEY_PASSWORD=...
iOS signing - configure your Development Team in Xcode:
cd gen/apple
open Lolly.xcodeproj
Set the team in the project's Signing & Capabilities tab, then build from CLI or Xcode.
Open Build Service (OBS)
Open Build Service is SUSE's source-to-package build system. It compiles a single source definition into native packages for many distributions at once, in clean and reproducible network-isolated chroots, and publishes them through signed, hosted repositories. One Lolly package definition on OBS can target the whole Linux matrix below from the same source.
What OBS can build for Lolly
| Format | Distributions | Lolly artifact packaged |
|---|---|---|
| RPM | openSUSE Leap / Tumbleweed, SLE / SLES, Fedora, RHEL / CentOS / Alma / Rocky, Mageia, openEuler | CLI binary and/or Tauri desktop app |
| DEB | Debian, Ubuntu, Raspbian | CLI binary and/or Tauri desktop app |
| Arch | Arch Linux (PKGBUILD) | CLI binary / desktop app |
| Flatpak | distro-agnostic sandboxed desktop app | Tauri desktop app |
| AppImage | distro-agnostic portable app | reuses Tauri's .AppImage output |
| Container images | OCI / Docker (built via Kiwi or a Dockerfile) | CLI as a container image |
| Appliance / disk images | ISO, VM and cloud images (built via Kiwi) | full preloaded image |
The local Tauri build already emits a .deb and an .AppImage (see the Desktop table above). OBS does not replace that - its value is fan-out across the rest of the matrix (every RPM- and deb-based distro, Arch, Flatpak, containers, appliances) plus signed, hosted repositories that users can add and update from like any other system package.
How it fits Lolly's artifacts
OBS packages one of the two Linux artifacts this guide already produces:
- The standalone CLI binary - the esbuild +
@yao-pkg/pkgoutput from the CLI section above. Thetools/andcatalog/directories must ship alongside the binary (the CLI resolves them relative to its own location), so that layout carries straight into the package's%files(RPM) ordebian/install(deb) list. - The Tauri desktop app - the
tauri buildoutput, packaged as RPM / DEB / Flatpak / AppImage for desktop delivery.
Build-environment constraints
No network at build time. OBS builds inside clean, network-isolated chroots, so every build input must be present up front. For a Node + Vite + Rust/Tauri app this is the main porting effort: vendor the npm and Cargo dependencies (an offline npm cache /
cargo vendor) or supply them through OBS source services, and declare the toolchain asBuildRequires- e.g.nodejs>=20,npm,rust,cargo, plus the desktop build's GTK/WebKit-develpackages (libgtk-3-dev,libwebkit2gtk-4.1-dev,libappindicator3-devand equivalents).
Illustrative project layout
The snippets below are illustrative starting points, not a production-tested recipe - they show what an OBS package for Lolly looks like.
A _service file fetches and versions the source from git at build time:
<services>
<service name="obs_scm" mode="manual">
<param name="scm">git</param>
<param name="url">https://github.com/lolly-tools/lolly.git</param>
<param name="revision">v1.0.0</param>
<param name="versionformat">@PARENT_TAG@</param>
</service>
<service name="set_version" mode="buildtime"/>
<service name="tar" mode="buildtime"/>
</services>
A trimmed RPM .spec declares the toolchain, builds the artifact and installs it with the required tools/ + catalog/ layout:
Name: lolly
Version: 1.0.0
Release: 0
Summary: Lolly - template-driven creative asset generator
License: MPL-2.0
URL: https://lolly.tools
Source0: %{name}-%{version}.tar.gz
BuildRequires: nodejs >= 20
BuildRequires: npm
BuildRequires: rust
BuildRequires: cargo
%build
npm ci --offline
# CLI binary: bundle with esbuild, then wrap with @yao-pkg/pkg (see CLI » Standalone binary above).
# For the desktop app instead, run `npm run build:desktop`.
npx esbuild shells/cli/bin/lolly.ts --bundle --platform=node \
--target=node20 --format=cjs --outfile=shells/cli/dist/lolly.cjs
npx @yao-pkg/pkg shells/cli/dist/lolly.cjs \
--targets node20-linux-x64 --output shells/cli/dist/lolly
%install
install -Dm0755 shells/cli/dist/lolly %{buildroot}%{_bindir}/lolly
cp -a tools %{buildroot}%{_datadir}/lolly/tools
cp -a catalog %{buildroot}%{_datadir}/lolly/catalog
%files
%license LICENSE
%{_bindir}/lolly
%{_datadir}/lolly/
A matching debian/ directory (control, rules, install) produces the .deb from the same OBS package, and OBS's per-repository configuration maps that single package onto every distribution target you enable.
A Flatpak manifest wraps the Tauri desktop bundle:
app-id: org.lolly.Lolly
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: lolly
modules:
- name: lolly
buildsystem: simple
build-commands:
- npm ci --offline && npm run build:desktop
- install -Dm0755 src-tauri/target/release/lolly /app/bin/lolly
sources:
- type: archive
path: lolly-1.0.0.tar.gz
For readers wiring this up for real, see the OBS documentation and the openSUSE packaging guidelines.
Web shell on Kubernetes (Helm)
The web shell is a static site - npm run build:web produces a dist/ folder of HTML, CSS, JS, the service worker, the HarfBuzz WASM, fonts, the bundled tool catalog and the /info site. Anything that can serve static files can host it (which is why the production site runs behind a CDN). To run it inside your own Kubernetes cluster - air-gapped, on-prem or alongside the rest of your platform - the artefacts ship in this repo: deploy/docker/ bakes dist/ into an nginx image, and deploy/helm/ deploys it. This section is what those files do and how to adapt them; Deployment is where each piece runs.
Nothing in the chart is specific to one cluster: it is plain Kubernetes, so it deploys on any conformant distribution - including SUSE's own RKE2 and k3s - and on any managed service.
The chart is shipped, the images are yours to build.
deploy/helm/is a real chart with three components -web(the PWA, on by default),mcpandca(both opt-in) - onevalues.yamlyou edit and secure defaults on every pod. What it cannot do is invent an image: build and push the threedeploy/docker/images to a registry your cluster can reach, then pin the tags you verified. There is no hosted Lolly chart repository, so you install from a checkout of this repo, and the SUSE Application Collection carries no Lolly chart of its own. Where a curated image or chart does exist there - the nginx runtime base, cert-manager - this section prefers it.
Why the SUSE Application Collection
The SUSE Application Collection is a curated, signed, continuously-rebuilt catalog of open-source container images and Helm charts, all based on SUSE Linux Enterprise Base Container Images (BCI). Pulling the nginx runtime from it - rather than an arbitrary upstream tag - gets you a hardened, attested base with a known CVE posture.
| Host | Role |
|---|---|
apps.rancher.io | Browse / catalog UI (and where you mint credentials) |
docs.apps.rancher.io | Documentation |
dp.apps.rancher.io | OCI distribution registry you pull from - images under /containers/<name>, Helm charts under /charts/<name> |
Access needs a free SUSE Customer Center account (scc.suse.com); sign in to apps.rancher.io and create either a personal access token (Settings → Access tokens) or an organization service account (Settings → Service accounts). Pulls are never anonymous.
Free-tier gotcha for clusters. A user access token on the Free tier allows pulls (≈100/24h), but a Free service account is allowed 0 pulls - so unattended in-cluster pulls through a service-account
imagePullSecretrealistically need a paid (Prime or higher) organization subscription. For builds on your own laptop a personal token on the Free tier is fine. If you can't subscribe, use the SUSE BCI nginx image fromregistry.suse.cominstead (see the Fallback note under step 2) - it's free and needs no login.
Log in - the same host for every client. The username/password pairing differs by credential type: a user account uses your username + an access token; a service account uses its username + a secret (never your account password):
helm registry login dp.apps.rancher.io -u <username-or-sa-username> -p <access-token-or-sa-secret>
docker login dp.apps.rancher.io -u <username-or-sa-username> -p <access-token-or-sa-secret>
1. Build the image
deploy/docker/web.Dockerfile does the static build and the packaging in one multi-stage build, so there is no separate "run npm, then copy dist/" step to perform by hand. Two things it needs from you: the build context must be the repo root, and the content submodules must be checked out in it - a bare clone builds a shell with an empty catalog.
git submodule update --init --recursive
docker build -f deploy/docker/web.Dockerfile \
--build-arg LOLLY_PROFILE=suse \
-t <your-registry>/lolly-web:0.1.0 .
docker push <your-registry>/lolly-web:0.1.0
What the two stages do:
- build -
node:26-bookworm, pinned by digest, runsnpm cithen the realnpm run build:web. Deliberately not the slim variant: the optional native dependencies (sharp, onnxruntime, resvg, playwright) need build tooling slim doesn't carry. - runtime -
nginxinc/nginx-unprivileged, also digest-pinned, running as uid 101 on port 8080.dist/is copied to/usr/share/nginx/html,deploy/docker/nginx.confbecomesconf.d/default.confanddeploy/docker/security-headers.confis copied beside it.
--build-arg LOLLY_PROFILE=suse|lolly-start bakes one brand into the static output - theme colour, PWA chrome and the resolved tools/ + catalog/ content. Nothing is read at serve time, which is why the chart needs no pack, config or volume mounted for the web app; changing brand means rebuilding the image and rolling the deployment.
mcp.Dockerfile and ca.Dockerfile follow the same pattern (repo-root context, digest-pinned node:26-bookworm-slim, non-root node user) and run their entry point on Node directly. Build them only if you enable those components.
2. Swapping in a SUSE nginx base (optional)
The shipped runtime stage uses the upstream unprivileged nginx image so a plain docker build works with no registry credentials. To take the Application Collection's curated build instead, replace that one FROM line and keep the COPY lines untouched:
# ---- runtime stage: SUSE Application Collection nginx ----
# Pin the current tag from https://apps.rancher.io/applications/nginx
FROM dp.apps.rancher.io/containers/nginx:1.29.4
Two things to check against whichever base you pick. The shipped nginx.conf sets root explicitly, so the base image's default document root doesn't matter - but dist/ has to be copied where that root points (or change the directive). And it listens on 8080 because non-root nginx cannot bind below 1024; if your base runs as root and you switch to 80, change web.service.targetPort in the chart to match.
Fallback without an Application Collection subscription: use the free SUSE BCI image
FROM registry.suse.com/suse/nginx:1.27(no login required). Its default document root is/srv/www/htdocs/- copy there instead, or just keep therootdirective from the shippednginx.conf, which wins regardless of the base image's default.
3. What the nginx config already handles
Lolly is a single-page PWA, so the server has real work to do: URL-mode deep links (/?tool=qr-code) must fall back to index.html, the service worker must never be cached, hashed assets should be immutable, the HarfBuzz .wasm and the .webmanifest need correct MIME types and the security headers have to be on every response. deploy/docker/nginx.conf does all of it - tests/security-headers.test.ts pins its CSP against the other two copies in the repo, so edit it rather than writing a fresh one. The parts that matter, quoted from the shipped file:
server {
# nginx-unprivileged cannot bind <1024; 8080 is the image default.
listen 8080;
root /usr/share/nginx/html;
index index.html;
# Security headers on every response (see the $lolly_csp map above).
include /etc/nginx/security-headers.conf;
# ── Service worker: MUST never be cached, and may control the whole scope.
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Service-Worker-Allowed "/";
include /etc/nginx/security-headers.conf;
}
# ── Content-hashed build output: safe to cache forever (immutable).
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
include /etc/nginx/security-headers.conf;
try_files $uri =404;
}
# ── Everything else: serve the file, then its .html twin, then a directory
# index, then fall back to the SPA shell. Covers /t/<id> → /t/<id>.html and
# /info/* real pages while keeping client-routed paths on index.html.
location / {
try_files $uri $uri.html $uri/index.html /index.html;
}
}
That repeated include is not redundancy: nginx drops every inherited add_header inside a location that declares one of its own, so any location setting Cache-Control would otherwise ship with no security headers at all. Keep the include when you add a location. The file also carries a cheap unauthenticated /healthz for the chart's probes, explicit types for .wasm/.avif/.webmanifest, long-cache rules for /ort/, /models/ and /fonts/ and the short URL aliases (/d, /v, /c, /p, /profile) that mirror the hosted deployment's rewrites.
4. The chart
The Application Collection ships charts for stateful services (redis, postgresql, prometheus, cert-manager, …) but not a generic static-web-server chart - nginx lives there as a container image only. Lolly's own chart is deploy/helm/:
deploy/helm/
├── Chart.yaml
├── values.yaml ← the one file you edit
└── templates/
├── _helpers.tpl names, labels, image references
├── web-deployment.yaml the PWA (on by default)
├── web-service.yaml
├── mcp-deployment.yaml MCP server (opt-in)
├── mcp-service.yaml
├── ca-deployment.yaml credential authority (opt-in)
├── ca-service.yaml
├── ca-secret.yaml chart-managed mode only
├── ingress.yaml one Ingress per enabled component
├── networkpolicy.yaml off by default
├── serviceaccount.yaml
└── NOTES.txt
web is enabled by default at 2 replicas - the PWA holds no server-side state (everything lives in the browser's IndexedDB), so replicas are interchangeable and you can raise the count freely. mcp and ca are enabled: false until you turn them on. The security posture is shared and applied to every component: runAsNonRoot, RuntimeDefault seccomp, all capabilities dropped, no privilege escalation, a read-only root filesystem with emptyDirs mounted exactly where nginx needs to write, no ServiceAccount token mounted (none of the components talk to the Kubernetes API), soft pod anti-affinity so replicas spread across nodes without blocking a single-node cluster and an off-by-default NetworkPolicy that restricts ingress to each component's port.
The values you are most likely to touch:
web:
enabled: true
replicaCount: 2
image:
repository: ghcr.io/lolly-tools/lolly-web # ← your registry
tag: "" # empty ⇒ the chart's appVersion
service:
port: 80
targetPort: 8080 # what nginx-unprivileged listens on
ingress:
enabled: false
hosts:
- host: lolly.example.com
paths: [{ path: /, pathType: Prefix }]
# Private registry (see step 5). e.g. [{ name: application-collection }]
imagePullSecrets: []
Read values.yaml top to bottom before a production install - it is written to be read, and the mcp and ca sections document what each service needs.
5. Install
If the image (or its base) is pulled from dp.apps.rancher.io, create the pull secret first and name it in imagePullSecrets:
kubectl create namespace lolly
# the SUSE Application Collection pull secret (skip if your image is on a public registry)
kubectl create secret docker-registry application-collection \
--docker-server=dp.apps.rancher.io \
--docker-username=<username-or-sa-username> \
--docker-password=<access-token-or-sa-secret> \
-n lolly
The minimum viable install is one flag - the image you built in step 1. Port-forward to try it before you publish a hostname:
helm upgrade --install lolly ./deploy/helm -n lolly \
--set web.image.repository=<your-registry>/lolly-web \
--set web.image.tag=0.1.0
kubectl port-forward -n lolly svc/lolly-web 8080:80 # → http://localhost:8080/
To publish it on your own hostname with TLS from cert-manager (drop the imagePullSecrets line if your registry is public):
helm upgrade --install lolly ./deploy/helm -n lolly \
--set web.image.repository=<your-registry>/lolly-web \
--set web.image.tag=0.1.0 \
--set imagePullSecrets[0].name=application-collection \
--set web.ingress.enabled=true \
--set web.ingress.hosts[0].host=lolly.example.com \
--set web.ingress.tls[0].secretName=lolly-web-tls \
--set web.ingress.tls[0].hosts[0]=lolly.example.com
NOTES.txt prints the resulting URLs, which components came up and any secret still missing.
6. The optional services
Both stay off until you ask for them, and both are stateless.
MCP (--set mcp.enabled=true) exposes the tool catalog to AI agents over Streamable-HTTP on port 8790. Its brand content is baked in at build time exactly like the web image. Browser-tier render formats are disabled unless you point it at a renderer - --set mcp.webBase=https://lolly.example.com - and SVG/data plus resvg-PNG work without one, which is why the image ships no Chromium. Probes are TCP socket checks: there is no unauthenticated health route to poll.
CA (--set ca.enabled=true) issues short-lived certificates for on-device C2PA signing on port 8787, under /api/ca. It needs a root key and certificate, generated once with node services/ca/scripts/gen-root.mjs, plus a service secret (openssl rand -hex 32). Two ways to supply them: fill in ca.secrets and let the chart create the Secret, or - the production choice - set ca.existingSecret to one your platform's secret store manages and leave ca.secrets empty. The chart refuses to render rather than deploy a CA with a missing root. Set ca.config.allowedOrigins to your web host, or enrollment tokens are rejected.
TLS, the SUSE-curated way (optional)
For HTTPS, pull cert-manager - which is a real chart in the Application Collection - and let it issue the Ingress certificate:
helm install cert-manager oci://dp.apps.rancher.io/charts/cert-manager \
-n cert-manager --create-namespace \
--set crds.enabled=true \
--set 'global.imagePullSecrets={application-collection}'
Then add a ClusterIssuer (e.g. Let's Encrypt), point the chart's web.ingress.tls[0] at a secret name and annotate the Ingress for cert-manager through web.ingress.annotations (cert-manager.io/cluster-issuer: letsencrypt-prod); mcp and ca take the same three keys under their own sections. Every Application Collection chart accepts the same --set 'global.imagePullSecrets={application-collection}', so the pattern carries across redis, postgresql, prometheus and the rest if Lolly ever grows backing services.
For the authoritative, current registry paths, tags, chart versions and value keys, see the SUSE Application Collection docs and run
helm show values oci://dp.apps.rancher.io/charts/<chart>before relying on any default.
How the Tauri shells relate to the web shell
Both Tauri shells share the web shell's source (shells/web/src/). They build it with a Vite alias that swaps bridge/state.ts for a Tauri filesystem implementation at build time. Everything else - the engine, tools, templates, export logic - is identical to the web build. One render path, three delivery targets.
shells/web/src/ ← canonical source
└── bridge/
└── state.ts ← IndexedDB (web build)
shells/tauri-desktop/
shells/tauri-mobile/
└── bridge-overrides/
└── state.ts ← filesystem via tauri-plugin-fs (Tauri builds)
The Tauri-built frontend is written to shells/tauri-{desktop,mobile}/dist/, which tauri.conf.json references as frontendDist. The web shell's own dist/ is unaffected.