Walkthrough: bty-web in Docker

A pre-built bty-web container is published to ghcr.io/safl/bty-web on every tagged release. It hosts bty-web’s image catalog, machine registry, and browser UI. bty --catalog SOURCE clients (from the USB live env or a workstation) connect to it and pick images for flashing.

This container is HTTP-only: bty-web serves the UI, the per-MAC PXE plans, the boot artifacts, and the images over HTTP on :8080. Targets boot one of three ways:

  • UEFI HTTP Boot targets: DHCP option 67 = http://<bty-web>:8080/boot/ipxe.efi; modern UEFI firmware fetches the binary over HTTP, no TFTP in the path.

  • TFTP PXE targets (legacy BIOS, older UEFI) that can only bootstrap over TFTP: add the bty-tftp sidecar, which serves the ~1 MB iPXE bootfile over udp/69 and is part of the compose / Quadlet deploy under deploy/. This container serves no TFTP itself.

  • boots-from USB sticks: boot a target from a boots-from USB whose embedded iPXE script chains to bty-web’s /pxe-bootstrap.ipxe, replacing the firmware PXE step entirely – no DHCP-PXE options or TFTP needed at all.

Quick start – the canonical container deploy

uvx bty-lab deploy writes the compose stack (bty-web + withcache, plus an optional TFTP sidecar), auto-fills envvars, and brings it up in one shot. No clone needed; uv (or pipx) on the host is enough:

sudo uvx bty-lab deploy /opt/bty
#   bty: :8080/ui  withcache: :3000/   (login: bty-lab / bty-lab)

deploy detects install mode from your euid: as root, the full system install (TFTP sidecar + Quadlet units + systemctl autostart); as a regular user, the compose-only install (no TFTP, no autostart, with a warning listing exactly what was skipped). HOST_ADDR is detected from the host’s outbound-route IP; admin passwords default to bty-lab. Change them before exposing the host past a trusted LAN. Pass --host-addr 192.0.2.10 to override detection, or --force to overwrite an existing envvars.

The sudo is what triggers the full install: Podman Quadlet units land in /etc/containers/systemd/, the services start via systemctl, and the stack survives host reboots. Without sudo the same command lands a compose-only user install (operator-managed lifecycle, no autostart) – useful for development hosts where you don’t want systemd in the loop.

Upgrade against a newer bty release in one shot:

uvx bty-lab upgrade /opt/bty     # auto-detects compose- vs Quadlet-managed

upgrade preserves envvars + data/, regenerates compose against the CLI’s bty version, podman compose pulls, then restarts (or systemctl restarts for Quadlet-managed stacks).

bty-web reads $BTY_WITHCACHE_URL (set by the compose) at boot and auto-wires withcache as its image source – no UI configuration step. For inspect-then-apply control, use bty-lab init instead; it emits the same files without side effects. Full details: deploy/README.md.

Bare docker run (dev / single-container)

For contributor work on the bty-web UI alone – no withcache, no PXE – a bare docker run works too. The container runs bty-web as uid 1000; either pre-chown a bind-mount or use a docker-managed volume so first-boot can write state.db:

docker run -d --name bty-web \
  -p 8080:8080 \
  -v bty-data:/var/lib/bty \
  ghcr.io/safl/bty-web:latest
# -> http://localhost:8080/ui   (UI open; set BTY_ADMIN_PASSWORD to gate it)

This is HTTP-only – legacy TFTP PXE needs the bty-tftp sidecar in the compose deploy above.

Connecting bty

From a workstation or the USB live env:

bty --catalog http://<host>:8080/catalog.toml

The catalog pane fills with whatever the server has imported into its catalog (via /ui/catalog/upload or “Fetch from release”) plus any operator-added URL-only entries. bty-web post-v0.40 is bytes-less: images aren’t kept on the server’s filesystem; they materialise on demand via the withcache cache-through proxy at flash time. Pick an image (Enter), pick a target disk, confirm the flash plan. The server is the catalog source; the write happens on the local machine running bty.

Scripted flash via the plan endpoint (no wizard)

For batch / CI workflows: bind the machine on the server with boot_mode=bty-flash-always + a bty_image_ref + a target_disk_serial, then on the target run bty --server <host> --mac <self-mac>. The wizard skips and the flash runs scripted (mode=flash from GET <host>/pxe/<mac>/plan). The image streams directly from the container’s catalog through the live env to the target’s disk; no operator copy step.

For one-shot ad-hoc flashes (no MAC binding), the wizard’s URL accept covers HTTP and oras:// sources via bty --catalog ....

Gating the operator UI

The operator UI is gated by $BTY_ADMIN_PASSWORD (constant-time compare); when it is unset the UI is open and bty-web logs a startup warning. Set it before exposing past a trusted LAN. Pass it on the docker run:

docker run -d --name bty-web \
  -e BTY_ADMIN_PASSWORD=your-secret \
  -p 8080:8080 \
  -v bty-data:/var/lib/bty \
  ghcr.io/safl/bty-web:latest

Rotate by changing BTY_ADMIN_PASSWORD and restarting the container; the setting survives image rebuilds and pulls since it lives in the run command (or your compose file), not in the image.

Volume layout

The container expects a single volume at /var/lib/bty:

/var/lib/bty/
  state.db           SQLite: machines, MAC -> image bindings, sessions
  session-secret     bty-web cookie key (generated on first start)
  bty.toml           operator-edited config (optional; env still wins)
  catalog.toml       imported/uploaded catalog manifest
  backups/           bty-web's exported backup bundles
  boot/              kernel / initrd / squashfs for the netboot flow

There’s no images/ subdir on a v0.40+ deploy: bty-web doesn’t keep image bytes locally. Images stream from their published source (http(s):// / oras://) through the withcache sidecar at flash time; the catalog only carries the references + sha pins.

state.db is plain SQLite; back it up by stopping the container and copying the file. Migrations run automatically on every start.

Environment variables

The canonical config is a bty.toml file mounted into the container and pointed at via BTY_CONFIG_FILE (the generated compose / Quadlet deploys do exactly this). Individual settings can also be overridden via BTY_<SECTION>_<KEY> env vars; the table below names the ones most operators reach for.

Var

Default

Purpose

BTY_SERVER_HOST

0.0.0.0

Listen address

BTY_SERVER_PORT

8080

Listen port

BTY_PATHS_STATE_DIR

/var/lib/bty

Where state.db and session-secret live

BTY_PATHS_BOOT_DIR

${BTY_PATHS_STATE_DIR}/boot

Kernel/initrd/squashfs (PXE boot artifacts)

BTY_SERVER_SESSION_SECRET

(generated)

Cookie key override; useful for multi-instance

BTY_ADMIN_PASSWORD

bty-lab

Gates the operator UI (constant-time compare); auth is always on – unset falls back to the well-known default bty-lab, with a startup warning until it is changed

Building locally

The Dockerfile expects a wheel under dist/. Build the wheel first, then the image:

uv build
docker build -f docker/Dockerfile -t bty-web:dev .
docker run --rm -p 8080:8080 -v "$PWD/bty-data":/var/lib/bty bty-web:dev

Multi-arch

The published image is linux/amd64 + linux/arm64. Pull from a Pi 4 or Pi 5 as easily as from an x86 host. Pure-Python wheel, so the only per-arch differences are the apt-installed system deps.

Operational notes

  • No bundled TFTP: this container serves only HTTP. Legacy TFTP PXE is the bty-tftp sidecar’s job (see deploy/); the Netboot page’s TFTP daemon controls apply to a host/systemd install, not this container.

  • State on a named volume: bty’s DB and images live under /var/lib/bty; bind-mount or volume-mount it so state survives container re-pulls.

bty runs no DHCP role: the operator’s LAN DHCP server points PXE clients at bty (via option 60/66/67 tagging).