Skip to content

Three Places to Put Provisioning: Init Tasks, Startup Files, or the Image

Getting a playground published is not the same as getting it usable. Between "the VM booted" and "a stranger can do the thing you built this for" sits a pile of work: packages, repositories, config files, seeded data, services that need to be running. The iximiuz Labs manifest gives you two places to put that work, and your Dockerfile is a third.

I picked the third for all six of my images. This is what the other two do, when each one is right, and the reasoning that made me choose the one I did.

This follows on from the publishing walkthrough

Publishing a Custom Playground on iximiuz Labs covers the image requirements, the manifest basics, and the two labctl commands that put a playground online. This post is only about what happens after the machines boot, plus the landing page a visitor reads before they ever click Start.

The Three Options

Where When it runs Good for
The rootfs image Build time, once, before anyone starts anything Packages, services, anything slow or shared across every session
startupFiles Injected into the filesystem before the machine boots Config files, shell profiles, seed data you already know
initTasks Shell scripts after boot, while the loading screen is up Anything that must be executed: cloning, installing, starting

The distinction that actually matters is the middle column. Everything else follows from it.

Startup Files: Content You Already Know

Up to ten per machine, written into the filesystem before boot:

machines:
  - name: dev-01
    startupFiles:
      - path: /home/laborant/.bashrc
        append: true
        content: |
          export PATH=$PATH:/usr/local/go/bin
      - path: /etc/app/config.yaml
        owner: laborant
        mode: "600"
        content: |
          environment: playground

append: true adds to an existing file instead of replacing it, which is what you want for .bashrc almost every time. Parent directories are created. owner and mode default to root-owned 644, and mode is octal without the leading zero, so "600" and not "0600".

There is one thing here that is not a preference, and it is easy to miss:

Shell configuration cannot go in an init task

Init tasks run after the machine has booted, and a user can get a shell before they finish. Put .bashrc changes in an init task and whether they apply depends on how fast the visitor clicks.

Startup files land before boot, so they are the only reliable place for anything that shapes a login shell.

This one is worth internalising because the failure is intermittent. It works every time you test it and fails for the one person on a slow connection.

Init Tasks: Things That Must Execute

initTasks is a map of named shell scripts. The playground holds a loading screen until they all finish, so the visitor lands in a finished environment:

initTasks:
  init_fetch_app:
    init: true
    machine: dev-01
    user: laborant
    timeout_seconds: 120
    run: |
      git clone https://github.com/example/app.git ~/app

  init_start_services:
    init: true
    machine: dev-01
    needs:
      - init_fetch_app
    timeout_seconds: 180
    run: |
      cd /home/laborant/app && docker compose up -d

Tasks with no needs start concurrently as soon as their machine boots. needs chains them into a dependency graph, and because every task names its machine, that graph spans the whole playground. A task on the app server can wait on a task that seeds the database VM. That is the capability that makes multi-machine playgrounds worth building rather than just possible.

Three gotchas, in the order they will bite you:

Tasks run as root unless you say otherwise. Clone a repository into /home/laborant without setting user: and you have handed the visitor a home directory they cannot write to.

timeout_seconds defaults to 60. Anything touching a package manager or the network will exceed that on a bad day. A timed-out task leaves the playground stuck on the loading screen.

They run once per instance, not per boot. Reboot a machine mid-session and init tasks do not re-run. Anything that must survive a reboot belongs in a systemd unit inside the image, not here.

That last one is the same boundary I hit building the images themselves, arriving from the other direction.

Debugging them

A failing task means a playground that never finishes loading, which tells you nothing on its own. Two things help:

labctl playground tasks <play-id>
NAME                 MACHINE  STATUS     INIT  HELPER
init_fetch_app       dev-01   completed  true  false
init_start_services  dev-01   running    true  false

And you can SSH in while tasks are still running, which is the useful part:

labctl ssh <play-id> -m dev-01

You get to watch the environment being built rather than inspecting the wreckage afterwards.

Init Conditions: One Manifest, Many Playgrounds

This is the feature I would not have found by reading the schema, because its value is not obvious from the field list. initConditions declares parameters the user picks at start time, and tasks can be gated on them:

initConditions:
  values:
    - key: k8s_flavor
      default: k3s
      options: [k3s, kubeadm]

initTasks:
  init_install_kubeadm:
    init: true
    machine: dev-01
    conditions:
      - key: k8s_flavor
        value: kubeadm
    run: |
      ...

The UI prompts for the values; labctl takes them as flags:

labctl playground start web-dev-lab-<suffix> -i k8s_flavor=kubeadm

Tasks whose conditions do not match are skipped entirely and never appear in the task list.

The platform's own k8s-omni playground is the case worth studying: container runtime (containerd or cri-o) and CNI plugin (calico, cilium, flannel, static, or none at all) are both chosen at start time, and every runtime-specific and plugin-specific provisioning step is a separate task guarded by a condition. One manifest covers the entire matrix, including the deliberately CNI-less cluster that makes "install networking from scratch" a practicable exercise.

The manifest is public, so read it rather than my summary of it:

labctl playground manifest k8s-omni

The alternative to this feature is maintaining a dozen near-identical manifests, which is the kind of duplication that is fine on the day you create it and unmaintainable a year later.

Why I Baked Everything Into Images Instead

All six of my images do their provisioning at docker build time. No init tasks, no startup files. That is a real decision and it has real costs, so here is the reasoning rather than a claim that it is generally correct.

My playgrounds are services, not lessons. Jenkins, SonarQube and Nexus have to be running when the tab opens. Installing SonarQube on every start would put several minutes on a loading screen, every session, for every visitor. Baking it in means the install happened once, on a CI runner, months ago.

Init tasks do not survive a reboot; systemd units do. A service that must come back after reboot belongs to the init system inside the image. That is not a preference, it is what the two mechanisms actually guarantee.

The image is the artifact I wanted. Versioned, scanned in CI, published to a registry, and boot-testable outside this platform entirely. A manifest full of provisioning scripts is portable to exactly one platform.

The honest cost: my six images are six GitHub Actions workflows and a base-image inheritance chain to maintain. Someone shipping a single-machine teaching environment gets there faster with FROM ghcr.io/iximiuz/labs/rootfs:ubuntu-24-04 and twenty lines of init tasks, and they should.

The split I keep coming back to across this whole project holds here too. Bake what is static and shared. Reconcile what is per-instance at boot. Init tasks are simply a third place to put the second category, and one that suits lessons better than it suits services.

The Landing Page Is Part of the Manifest

The last piece of publishing is not technical at all, and it is the one I nearly skipped.

A published playground has a page a visitor reads before they click Start, and it is manifest-controlled:

title: SilverStack Jenkins Server
description: A one-paragraph summary shown on the playground card.
markdown: |
  The long-form landing page body.
cover: https://...

markdown takes up to 100,000 characters, and images referenced as __static__/<file> resolve against files uploaded alongside the playground. Dumping a live manifest shows how it ends up stored:

cover: /content/files/playgrounds/SilverStack-jenkins-server-63fe430c/__static__/jenkins-server-ui.png
markdown: |-
    ![](__static__/jenkins-server-welcome.png)

    | Layer | Detail |
    |---|---|
    | OS | Ubuntu 24.04 · systemd PID 1 |
    | Proxy | Nginx :80 → Jenkins :8080 |

I keep each one as a CONTENT.md next to the Dockerfile it describes, so the page and the image that serves it are edited in the same commit and reviewed together.

There is also a per-user welcome banner, the first thing anyone sees in a terminal:

users:
  - name: laborant
    default: true
    welcome: |
      This machine runs Jenkins behind Nginx on :80.
      Give it 60-90 seconds on first boot.

Set welcome: '-' to suppress it on secondary machines. Mine come from the image instead, as a .welcome file the shell prints once and deletes, which is the same bake-versus-declare choice one more time.

Writing this section is what convinced me the documentation is the deliverable. A playground nobody understands on sight is a playground nobody starts.

Two More Fields Worth Knowing

registryAuth puts the built-in registry at registry.iximiuz.com, reachable from inside every playground, behind credentials:

registryAuth: someuser:somepassword

That makes docker login flows and private-registry scenarios practisable without leaving the sandbox, which is otherwise an annoying thing to demonstrate.

backend and kernel.source control the hypervisor and kernel version per machine. The default is Firecracker on 6.1; cloud-hypervisor enables nested virtualisation as a paid feature. If your lab is about virtualisation itself, or depends on a kernel feature with a known version floor, these exist. Most playgrounds should leave them alone.

What I Would Tell Myself

Reach for startup files before init tasks. If the content is known at authoring time, a file is simpler than a script that writes a file, and it lands before boot rather than racing it.

Set timeout_seconds deliberately on anything network-bound. The 60-second default is fine for mkdir and wrong for apt-get.

Use labctl playground tasks early. A stuck loading screen is not a diagnostic. The task list is.

Decide once whether you are shipping a service or a lesson. Services want the image. Lessons want init tasks. Building a lesson the way I build services is a large amount of avoidable work.

Source


Companion post