Build your own image
The most common customization: a workspace image with your application in it. There are two supported paths in the waas-workspace-images repo, both ending with an image that satisfies the platform contract and is discovered by CI automatically.
How the repo is organized
Two layers, each a directory of image families, each family described
by a manifest.yaml:
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 built from ubuntu-desktop-noble)
shared/ files several images COPY in (Firefox policies, mise)
images.yaml release minor, pinned cores (tag@digest), default archs, scan gate
The core-* parents are not in this repo: they come from
waas-core-images, pinned by digest under images.yaml's
cores:. That repo also ships the CI toolkit (composite actions) this
one builds with: its pipeline action discovers every manifest.yaml,
topo-sorts on from: and builds 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.
from: core-ubuntu-noble # a pinned core (bare core = kiosk style) or another image here
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.
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.
The toolkit's recipe compiler turns this into a Dockerfile.generated
(gitignored — regenerated by CI and make build) 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 — the pinned core ref, or the parent's same-arch tag from this pipeline — 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) starts as a new core in
waas-core-images, then a cores: + os: entry here.
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 and audio; don't interpret non-WAAS_variables at runtime.- Health = TCP probe on
5901— Xvnc accepting connections is your readiness, and the only listener the platform expects; 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". The
toolkit is read from a sibling checkout of waas-core-images
(../waas-core-images, override with WAAS_CORE_IMAGES_DIR):
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 # RFB banner + hardening checks
make lint # hadolint + shellcheck + actionlint
To try your image on a core you changed locally, build that core in
the waas-core-images checkout and pass
CORE_REF=waas-local/core-ubuntu-noble:dev make build IMAGE=myapp.
make smoke runs the same script CI does: the image must boot with
--read-only --cap-drop ALL --security-opt no-new-privileges, answer a
real RFB banner, and carry an empty setuid set. CI adds a PulseAudio
handshake on 4713 only for the images whose manifest asks for it
(smoke: {audio: true} — the cores and the OS desktops); an apps/
image, where yours will land, is probed on VNC alone. 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: every release, ci/generate_catalog.py
regenerates
catalogs/<X.Y>/catalog.yaml
from the same manifest discovery as the pipeline — the catalog cannot
drift from the build matrix. Your entry gets displayName (the
manifest's displayName:, else derived from the variant id),
description, icon, architectures (from archs:), a profile and
a recommended deployment block derived from the hardening doctrine.
The WaaS api-server syncs that file (see catalogs.waasImages in the
chart values), so a merged image shows
up in the workspace-creation picker once it is released (main
builds alone don't touch the catalog). Note the timing: your image
lands in a catalog file whose URL does not change within a waas
minor, so it
appears on the next periodic pass (6 h by default) or when an admin hits
Sync now — the immediate syncs only fire when a WorkspaceImage's
source itself is created or moved. Details:
Bootstrap governance.
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-*images never reach the catalog — they are build parents.- No per-image version to bump: releases are promotions of an
already-built
maincommit to the nextX.Y.n, and CI refuses to move an existing release 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 the core's
HARDENING.md
(and the workspace repo's own, which lists only the app-specific gaps)
before contributing — 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,
VNC auth always on, and no second remote-access listener — xrdp and
sshd are structurally absent, and no build arg can add them. The
-dev reduced profile (baked sudo) is a deliberate, narrowly-scoped
exception with its own rules — don't add one lightly.