Skip to main content
Version: Next 🚧

Build your own image

The most common customization: a workspace image with your application in it. There are two supported paths in the waas-images repo, both ending with an image that satisfies the platform contract and is discovered by CI automatically.

How the repo is organized

Three layers, each a directory of image families, each family described by a manifest.yaml:

base/ core-* images — Xvnc + openbox, optional xrdp/sshd (internal parents)
desktop/ XFCE on top of a core — the published OS desktops
apps/ single-app kiosks on the bare core — what you'll usually add
(devtools is the exception: a full desktop on the XFCE parent)
images.yaml global build config: OS matrix, default archs, scan gate

CI (ci/generate_pipeline.py) discovers every manifest.yaml, topo-sorts on from: and builds base → desktop → apps in one pipeline, natively on amd64 and arm64. Adding an image means adding a directory — no CI config to edit.

A manifest looks like this:

name: myapp # image family
layer: apps
description: MyApp in a single-app kiosk session.
version: "1.0.0" # bump on ANY change — tags are immutable
from: core-ubuntu-noble # the parent image (bare core = kiosk style)
icon: myapp # optional dashboard-icons slug for the picker
variants:
- name: myapp # one published image per variant
smoke: { vnc: true }

Path 1 — declarative recipe: (no Dockerfile)

When the app is "a list of apt packages plus a session command", skip the Dockerfile entirely:

apps/myapp/manifest.yaml
name: myapp
layer: apps
description: MyApp in a single-app kiosk session.
version: "1.0.0"
from: core-ubuntu-noble
recipe:
apt: [myapp, myapp-plugins]
app: myapp # single-app kiosk (compiles to ENV WAAS_APP)
# autostart: <cmd> # alternative: XFCE autostart entry (XFCE parent only)
# startup: <cmd> # alternative: raw session command, no WM (WAAS_STARTUP)
# program: <cmd> # alternative: long-running service under supervisord
# repo: # third-party apt repo (the Chrome/Firefox pattern):
# url: https://repo.example.com/deb/
# keyUrl: https://repo.example.com/signing-key.pub
# fingerprint: "<40-hex-gpg-fingerprint>" # verified at build time
# pin: "myapp*"
variants:
- name: myapp
smoke: { vnc: true }

In app: kiosk mode the session runs openbox with a dedicated kiosk config: the application is undecorated and maximized, with no desktop, panel or window-manager keybindings behind it — the style every published apps/* image now uses.

ci/recipe_compiler.py compiles this into a Dockerfile.generated (gitignored — regenerated by CI and make recipes) from a template that bakes the non-negotiable hardening: --no-install-recommends, cache purge, re-asserted setuid strip, final USER 1000:1000. You cannot forget them because the compiler always emits them.

Rules:

  • A directory with both a recipe: and a Dockerfile is refused — one source of truth.
  • Recipe packages are deliberately not version-pinned: they ride the distro archive at every rebuild. A package that needs a tracked pin is exactly the hand-written case.

Path 2 — hand-written Dockerfile

For anything beyond apt-simple (custom repos with pinned versions, non-apt installs, extra services — see apps/firefox and apps/devtools as references):

  1. Create the directory and Dockerfile:

    apps/myapp/Dockerfile
    ARG BASE_IMAGE
    FROM ${BASE_IMAGE}
    USER 0
    # install, then strip any new setuid/setgid bits (copy the firefox layer):
    RUN apt-get update \
    && apt-get install -y --no-install-recommends myapp \
    && apt-get clean && rm -rf /var/lib/apt/lists/* \
    && find / -xdev -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true
    USER 1000:1000

    BASE_IMAGE is injected by CI with the parent's same-arch tag — never hardcode a parent ref.

  2. Add apps/myapp/manifest.yaml (same as above, without recipe:).

  3. Session: set ENV WAAS_APP="<command>" for a single-app kiosk on the bare base (the default for app images); on the XFCE parent, ship an /etc/xdg/autostart/waas-app.desktop for a full desktop instead. WAAS_APP and WAAS_STARTUP are mutually exclusive — the entrypoint refuses to start with both set.

  4. Push — the pipeline discovers the directory automatically.

A new OS/distro (rather than a new app) is a new directory under base/ plus an entry in images.yaml's os: map.

The mandatory contract

Whatever the path, your image must respect the contract. The parts that concern a derived image in practice:

  • User 1000:1000 (waas_user) — end every Dockerfile with USER 1000:1000; never chown at startup (fsGroup handles the PVC).
  • Writable paths only /home/waas_user, /tmp, /run — anything your app writes elsewhere will break under readOnlyRootFilesystem: true. Render mutable config into tmpfs at boot (the base entrypoint shows the pattern).
  • WAAS_* env contract — the base entrypoint already handles WAAS_DESKTOP_PASSWORD (required, fail-closed), resolution, protocol toggles; don't interpret non-WAAS_ variables at runtime.
  • Health = TCP probe on the protocol port — Xvnc listening on 5901 is your readiness; don't ship an app that must be "warm" before VNC accepts.
  • No new setuid/setgid binaries — the smoke test fails on any (exactly /usr/bin/sudo allowed on -dev profiles).
  • Boot-time customization without rebuilding: workspaces can mount a ConfigMap at /etc/waas/init.d/ — every *.sh is sourced at boot as UID 1000, after the image's own hooks.

Local dev loop

Same scripts as CI, so "works locally" means "works in CI":

make build IMAGE=myapp # build (compiles the recipe first if any)
make run IMAGE=myapp # run it — VNC on localhost:15901, password "devpassword"
make smoke IMAGE=myapp # protocol handshake + hardening checks
make lint # hadolint + shellcheck over the whole tree

make smoke is the same gate CI applies: the image must boot with --read-only --cap-drop ALL --security-opt no-new-privileges, answer a real RFB/X.224 handshake, and carry an empty setuid set. If your image passes smoke locally, the pipeline will accept it.

Add your image name to the repo Makefile's wired-up IMAGE values for convenience — the CI pipeline itself is driven purely by manifest discovery.

From manifest to catalog to picker

Nothing to write by hand: on every default-branch build, ci/generate_catalog.py regenerates catalog-waas-images.yaml from the same manifest discovery as the pipeline — the catalog cannot drift from the build matrix. Your entry gets displayName (the manifest description, truncated), icon, architectures (from archs:), a profile and a recommended deployment block derived from the hardening doctrine.

The WaaS api-server periodically syncs that file (see catalogs.waasImages in the chart values), so a merged image shows up in the workspace-creation picker on its own. Two things are still admin-side in the platform: the WorkspaceImage approval if you run your own curated catalog, and allowedGroups gating for -dev profile images.

When an admin building a template selects a registry-discovered image that carries a recommended block, the form shows an "Apply catalog recommendations" button next to the image field. It is never an automatic prefill: nothing is injected on selection, so an admin never saves a securityContext they didn't consciously see — clicking the button also expands the (collapsed by default) workload YAML section so the injected values are visible before saving.

The env part of the prefill is protocol-aware:

  • On a template with no protocols yet, the entry's supported protocols are added first, with their registry default ports. A list mixing kasmvnc with guacd protocols keeps only the guacd ones (kasmvnc exclusivity); a template that already has protocols keeps them untouched.
  • Env hints are filtered to those protocols (env[].protocols; unscoped = always relevant), plus the requires closure — a relevant hint pulls its required siblings into the offer even when their own scope doesn't match.
  • A hint with a default merges into the template's env by name, never overwriting an entry already present. A hint without a default is only offered — a greyed suggestion row the admin adopts with a click — so an empty literal never sneaks into the CR and supersedes the operator's own credential injection.
  • env[].requires stays purely descriptive server-side: it shapes the offer, it is never validated or enforced.

Notes:

  • core-* variants never reach the catalog — they are internal build parents.
  • Bump version for any change: tags are immutable, CI refuses to move an already-published <version> tag.
  • The optional icon: is a dashboard-icons slug; verify https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/<slug>.svg answers 200 — an invalid slug fails silently (generic OS icon).

Hardening expectations for contributions

Read HARDENING.md before touching base/ or the entrypoint — it is a verifiable checklist enforced by the smoke test and the Trivy scan gate. In short: non-root with no path to root, no secrets in layers, minimal packages, protocol auth always on by default. The -dev reduced profile (baked sudo) is a deliberate, narrowly-scoped exception with its own rules — don't add one lightly.