litestar-build
Auto-activate for uv build, hatch build, pyapp, Hatchling force-include, bundled frontend assets, PyApp onefile binaries, GitHub Actions build matrices, PYAPP_* env vars, cargo-zigbuild, or python-build-standalone. Use when packaging Litestar apps as wheels, onefile binaries, rel
What it does
litestar-build
Build-side packaging patterns for Litestar applications: how to produce a self-contained wheel that embeds the Vite/Bun frontend, how to wrap that wheel in a PyApp onefile binary, and how to wire the whole pipeline into GitHub Actions CI and releases.
This skill is the counterpart to litestar-deployment — build is about producing artifacts, deployment is about running them.
The Core Idea: One Wheel, Self-Contained
A Litestar wheel is the single source of truth for a release. It contains:
- Python code (
src/py/app/orapp/) - SQL migrations, Jinja templates, INI configs
- The built Vite/Bun frontend bundle (JS, CSS, HTML, images)
- Email templates rendered from React/MJX to static HTML
Once produced, that wheel can be:
pip installed into a container (litestar-deployment).- Wrapped in a PyApp binary (
dist/<app>,dist/app-x86_64-linux-gnu) for zero-dep distribution. - Uploaded to PyPI or a private index.
All three paths assume the wheel is already complete — no bun run build happens at deploy/install time.
Why bundle assets into the wheel (and not serve from a CDN)
| Property | Bundled wheel | External CDN |
|---|---|---|
| Deploy artifacts | 1 (.whl or binary) | 2+ (wheel + CDN upload) |
| Version alignment | Atomic — API and UI lock-step | Easy to skew; rollback is painful |
| PyApp onefile | Required — the binary embeds the wheel | Not possible — binary can't fetch CDN URLs at install time |
| Offline/air-gapped | Works | Doesn't |
| Dev server startup | Instant (files on disk next to package) | Fine |
| Frontend-only deploys | Rebuild + redeploy wheel | Push to CDN only |
For most Litestar apps that ship as a product (CLIs, internal tools, enterprise installers), bundled-in-wheel is correct. Projects like litestar-fullstack-inertia and litestar-fullstack all bundle.
Why litestar-vite configs look the way they do in reference apps
This is the piece most developers miss. The Vite/litestar-vite configs in the reference apps are deliberately set up so the Vite output lands inside the Python package directory — because that's what makes the wheel pick them up automatically.
litestar-fullstack (src/js/web/vite.config.ts):
export default defineConfig({
build: {
outDir: path.resolve(__dirname, "../../py/app/server/static/web"), // ← inside src/py/app/ (the Python package)
emptyOutDir: true,
},
plugins: [
litestar({
bundleDir: path.resolve(__dirname, "../../py/app/server/static/web"),
hotFile: path.resolve(__dirname, "../../py/app/server/static/web/hot"),
}),
],
})
litestar-fullstack-inertia — the litestar-vite plugin resolves bundle_dir relative to the project root, and Python settings point it at a package-internal path:
# app/lib/settings.py
return ViteConfig(
paths=PathConfig(
root=BASE_DIR.parent,
bundle_dir=Path("app/domain/web/public"), # ← inside app/ (the Python package)
resource_dir=Path("resources"),
),
)
Advanced reference pattern — same approach: Vite and the offline-report build write to src/py/<app>/server/public/ and src/py/<app>/domain/web/static/reports/offline/, both under the package root.
Contrast with a naïve vite build that writes to ./dist/ at the repo root: those files are outside the package directory listed in [tool.hatch.build.targets.wheel] packages = [...], so Hatchling silently drops them. The wheel ships without a frontend.
Rule: Vite's outDir and litestar-vite's bundle_dir must point inside one of the Python packages that Hatchling is told to include. Everything else flows from that.
Quick Reference
| Topic | Reference | Key Commands |
|---|---|---|
| Wheel build + asset bundling | references/wheel-assets.md | uv build --wheel, [tool.hatch.build.targets.wheel.force-include], ignore-vcs = true |
| PyApp — simple (hatch-binary) | references/pyapp-simple.md | uv run hatch build --target binary |
| PyApp — advanced (offline + custom install dir) | references/pyapp-advanced.md | tools/bundler.py build, cargo zigbuild |
| GitHub Actions CI (test matrix) | references/github-ci.md | astral-sh/setup-uv@v7, oven-sh/setup-bun@v2, composite actions |
| GitHub Actions release | references/github-release.md | matrix onefiles, cargo-zigbuild, gh release create |
| Upgrading Python / PyApp | references/upgrading.md | Files to edit in sync |
Canonical Makefile Build Graph
Every Litestar app with bundled assets has some variant of this:
.PHONY: install build-assets build-wheel build-onefile
install: ## Install Python + JS deps
@uv sync --all-groups
@cd src/js/web && bun install --frozen-lockfile
build-assets: ## Build frontend into the Python package
@uv run app assets install
@uv run app assets build
build-wheel: build-assets ## Self-contained Python wheel
@uv build --wheel
build-onefile: build-wheel ## Single-file PyApp binary
@./tools/scripts/build-onefile-package.sh
The dependency chain is load-bearing: build-onefile depends on build-wheel, which depends on build-assets. Running them out of order produces an empty or broken artifact.
The two-variant story
Real projects have multiple JS build outputs that all need to land in the wheel:
js-build-all: js-build-web js-build-offline-report
build-wheel: generate-licenses build-templates js-build-all
@uv build --wheel
Each js-build-* target emits into a distinct subdirectory of the Python package (src/py/<app>/server/public, src/py/<app>/domain/web/static/reports/offline, etc.). Because they're all inside the package, a single uv build --wheel captures everything.
Workflow
Step 1: Point Vite output inside the Python package
Open vite.config.ts. Set build.outDir to an absolute path inside your Python package (src/py/<pkg>/... or <pkg>/...). Set litestar({ bundleDir, hotFile }) to the same path. Do not let Vite default to ./dist/.
Step 2: Choose a Hatchling bundling strategy
force-include(inertia): List the built-asset directory explicitly under[tool.hatch.build.targets.wheel.force-include]. Built assets stay.gitignored. Explicit, auditable.ignore-vcs = true(SPA): Tell Hatchling to ignore.gitignore. All package files ship. Simpler; requires discipline to keep dev junk out of package dirs.
See references/wheel-assets.md for full config.
Step 3: Wire Makefile targets
Create install, build-assets, build-wheel. Make the wheel target depend on the asset target. Add any secondary generators (build-templates, generate-licenses) as additional wheel prerequisites.
Step 4: Add PyApp (if shipping a binary)
Decide which flavor:
- Simple: add
[tool.hatch.build.targets.binary]to pyproject.toml and runuv run hatch build --target binary. Good when end-users have PyPI access. See pyapp-simple.md. - Advanced: write a
tools/bundler.pythat pre-installs deps into apython-build-standalonearchive, patches PyApp'ssrc/app.rsfor a custom install dir, then runscargo zigbuild. Good for air-gapped distribution or bespoke install locations. See pyapp-advanced.md.
Step 5: Add GitHub Actions CI
Start with a reusable test.yml that accepts python-version + coverage inputs. Call it from ci.yml across a matrix. Use astral-sh/setup-uv@v7 and oven-sh/setup-bun@v2. See github-ci.md.
For larger projects, factor setup-python and setup-node into .github/actions/ composite actions.
Step 6: Add release workflow
Trigger on v* tags. Run the test matrix first. Then build the wheel once. Then build PyApp onefiles in a per-target matrix (x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, Apple, Windows). Upload to gh release create. See github-release.md.
Guardrails
- Vite/bun output must land inside a Python package directory. Otherwise Hatchling drops it. Set
build.outDirandlitestar({ bundleDir })to an absolute path undersrc/py/<pkg>/or<pkg>/. uv buildruns last. Assets, licenses, templates, OpenAPI TypeGen all run beforeuv build --wheel. Hatchling can't build Vite itself.- Pick one bundling strategy.
force-includeorignore-vcs = true, not both. Mixing them causes duplicate-file warnings and unpredictable wheel contents. - PyApp envs are build-time, not runtime.
PYAPP_PROJECT_NAME,PYAPP_PYTHON_VERSION,PYAPP_DISTRIBUTION_EMBEDare consumed whencargo buildcompiles PyApp — not when the resulting binary runs. Setting them at runtime does nothing. - PyApp version upgrades touch multiple files.
pyproject.toml,build-onefile-package.sh,.github/workflows/release.yml,tools/bundler.py. See upgrading.md. cargo-zigbuildfor portable glibc. Plaincargo buildon a modern Linux runner produces binaries that fail on older distros (glibc too new). Usecargo zigbuild --target x86_64-unknown-linux-gnu.2.17to link against glibc 2.17 (CentOS 7-era). Required for broad compatibility.- Static-link native deps in PyApp. Set
BZIP2_SYS_STATIC=1andLZMA_API_STATIC=1beforecargo zigbuild, or patchCargo.tomlto addfeatures = ["static"]. Otherwise the onefile fails to load on systems without matchinglibbz2.so/liblzma.so. - Pin
uvandbunversions in CI. Use exact pinned versions (e.g.,UV_VERSION=0.11.6andBUN_INSTALL_VERSION=bun-v1.3.12). Drift in either breaks reproducible builds. - Create placeholder asset dirs in CI. Hatchling's wheel target fails if
app/domain/web/publicorsrc/py/app/server/static/webdoesn't exist at wheel-build time. CI jobs that don't build the frontend (lint, mypy, pyright) still needmkdir -p <asset-dir>beforeuv sync. - Never commit built frontend output. Keep
bundle_dirpaths in.gitignore. CI rebuilds them on every run. Reason: JS builds are non-deterministic across machines and cause noisy diffs. - Coverage on one Python version only. Multiple versions uploading the same
coverage.xmlsilently stomp each other. Pin it to one version in your matrix (if: matrix.python-version == '3.12'). - Disk cleanup on self-hosted runners. GitHub's
ubuntu-latesthas ~30GB free; building wheels + PyApp + Docker images can blow past that. Aggressive cleanup before the build job is routine.
Validation Checkpoint
Before claiming "the wheel builds":
-
make build-wheelsucceeds in a clean checkout (aftermake install) -
unzip -l dist/*.whl | grep -E '\.(js|css|html)$'shows the built frontend - The wheel installs cleanly (
uv pip install dist/*.whlin a fresh venv) -
python -c "import app; app.run()"(or equivalent) serves assets with no extra steps -
.gitignoreexcludes the built asset directory - Vite's
build.outDiris an absolute path inside a Python package dir - Hatchling config uses exactly one of
force-includeORignore-vcs = true
Before claiming "the PyApp binary works":
-
dist/<app> --helpruns on the build machine - The binary is ≥ 50 MB (much smaller means it's not embedding Python)
- On Linux,
ldd dist/<app>shows ≤ libc / libm / libpthread (nolibbz2, noliblzma) - A network-isolated
docker run --rm --network=none ghcr.io/.../distroless -- <app> --helpsucceeds (proves no runtime PyPI fetches) - The install dir (
~/.<app>/runtime/or similar) is created on first run and re-used on second run
Before claiming "CI works":
- Python matrix covers minimum + stable + latest (e.g., 3.11, 3.12, 3.13)
-
make build-wheelruns in CI and the resulting wheel is uploaded as an artifact - Pre-commit / ruff / mypy / pyright / slotscheck run on every PR
- Release workflow is gated on CI (
needs: [lint, test]) - A tag push produces wheel + onefiles + GitHub release in one run
Example Projects
Everything in this skill is distilled from three production projects. Read these for the full picture:
- litestar-fullstack-inertia — monolithic
app/layout, Inertia.js + React 19,force-includebundling,hatch build --target binaryfor 4-platform PyApp. - litestar-fullstack — nested
src/py/app/+src/js/web/layout, React + TanStack Router SPA,ignore-vcs = truebundling, React Email templates.
Official References
- https://ofek.dev/pyapp/ — PyApp documentation (all
PYAPP_*env vars) - https://github.com/ofek/pyapp — PyApp source (patch target:
src/app.rs) - https://hatch.pypa.io/latest/config/build/ — Hatchling build config
- https://hatch.pypa.io/latest/plugins/builder/binary/ — Hatch binary builder (simple PyApp)
- https://docs.astral.sh/uv/concepts/projects/build/ —
uv buildreference - https://github.com/astral-sh/python-build-standalone/releases — Portable Python archives
- https://github.com/rust-cross/cargo-zigbuild — cargo-zigbuild for portable glibc
- https://bun.sh/docs/cli/install — Bun install and lockfile
Cross-References
- litestar-deployment — runtime deployment (Dockerfiles, K8s, Railway, Cloud Run, systemd) that consumes the artifacts this skill produces
- litestar-vite — Vite plugin config (asset pipeline details, TypeGen, HMR)
- litestar-granian — Granian ASGI server (what the wheel's entry-point starts)
- litestar settings — env-driven
@dataclasssettings that work both in-wheel and as a PyApp binary
Shared Styleguide Baseline
Capabilities
Install
Quality
deterministic score 0.45 from registry signals: · indexed on github topic:agent-skills · 7 github stars · SKILL.md body (14,575 chars)