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-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:

apps/myapp/manifest.yaml
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):

  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 — the pinned core ref, or the parent's same-arch tag from this pipeline — 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) 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 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 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/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". 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.

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-* images never reach the catalog — they are build parents.
  • No per-image version to bump: releases are promotions of an already-built main commit to the next X.Y.n, and CI refuses to move an existing release 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 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.