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:
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):
-
Create the directory and Dockerfile:
apps/myapp/DockerfileARG BASE_IMAGEFROM ${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 || trueUSER 1000:1000BASE_IMAGEis injected by CI with the parent's same-arch tag — never hardcode a parent ref. -
Add
apps/myapp/manifest.yaml(same as above, withoutrecipe:). -
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.desktopfor a full desktop instead.WAAS_APPandWAAS_STARTUPare mutually exclusive — the entrypoint refuses to start with both set. -
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 withUSER 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 underreadOnlyRootFilesystem: true. Render mutable config into tmpfs at boot (the base entrypoint shows the pattern). WAAS_*env contract — the base entrypoint already handlesWAAS_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/sudoallowed on-devprofiles). - Boot-time customization without rebuilding: workspaces can mount
a ConfigMap at
/etc/waas/init.d/— every*.shis 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.
How the template form consumes your recommended block
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
kasmvncwith 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 therequiresclosure — a relevant hint pulls its required siblings into the offer even when their own scope doesn't match. - A hint with a
defaultmerges 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[].requiresstays 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
versionfor any change: tags are immutable, CI refuses to move an already-published<version>tag. - The optional
icon:is a dashboard-icons slug; verifyhttps://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/<slug>.svganswers 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.