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

| 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:
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¶
- Init tasks and the manifest reference in the official docs, which are more complete than any blog post including this one
labctl playground manifest k8s-omnifor parameterisation done properly- My six rootfs images and the
CONTENT.mdlanding pages beside them - Building an OCI Image That Boots as a microVM, the image side of the choice this post is about, and the hands-on tutorial that walks the same build in a browser
Companion post
- Start here: Publishing a Custom Playground on iximiuz Labs, which covers custom rootfs requirements, the manifest, and getting a playground online.
- Full series index: Why I Built My Own CI/CD Stack