Publishing a Custom Playground on iximiuz Labs: Custom Rootfs, Manifest, and labctl¶
iximiuz Labs boots each playground machine as a Firecracker microVM, and it will boot your OCI image as the root filesystem rather than one of its own. That is the interesting capability, and going from a Dockerfile to a public URL a stranger can click takes three moving parts: an image that boots, a manifest that describes the machines, and two labctl commands.
This is that path end to end, using the five playgrounds I actually publish, plus the parts I got wrong on the way.
Try It Yourself
The manifest walked through below is live: Launch Jenkins Server ↗. Or run labctl playground manifest SilverStack-jenkins-server-63fe430c to print the same YAML straight from the platform.
Prefer to build the image first, hands-on and in a browser? I wrote that as a tutorial on the platform itself.
Start by Reading Someone Else's¶
The fastest way to understand the schema is to print a manifest that already works:
Every official playground in the catalog can be inspected this way, and the more interesting ones are more instructive than the docs:
labctl playground manifest k8s-omni # multi-node Kubernetes
labctl playground manifest flexbox # the multi-machine base
labctl playground catalog prints the catalog. Add --filter base to narrow it to the ones you can actually build on, which is the list that matters later:
labctl playground catalog --filter base # valid --base values
labctl playground catalog --filter my-custom # the ones you authored
catalog, not list
labctl playground list is a different command. It lists your recent sessions, not the catalog. I conflated the two for longer than I would like to admit, and the error is quiet: list returns rows, they just are not the rows you are looking for.
The Manifest¶
A playground is one YAML document with four top-level concerns: what it is called, what machines exist, how they are networked, and what the browser shows.
This is jenkins-server.yml in full, unedited. One machine, which is the right place to start:
kind: playground
title: SilverStack Jenkins Server
description: >
Production-grade Jenkins LTS on Ubuntu 24.04 - systemd-booted,
Nginx-proxied, Cloudflare-ready, zero setup required.
categories:
- linux
- ci-cd
- containers
playground:
networks:
- name: local
subnet: 172.16.0.0/24
machines:
- name: jenkins-server
users:
- name: root
- name: ibtisam
default: true
drives:
- source: oci://ghcr.io/ibtisam-iq/jenkins-rootfs:latest
format: ext4
mount: /
size: 50GiB
network:
interfaces:
- network: local
resources:
cpuCount: 4
ramSize: 10GiB
tabs:
- kind: ide
name: IDE
machine: jenkins-server
- kind: terminal
name: ibtisam-iq
machine: jenkins-server
- kind: http-port
name: "Jenkins UI"
machine: jenkins-server
number: 80
access: public
accessControl:
canList:
- anyone
canRead:
- anyone
canStart:
- anyone
Every machine: reference under tabs has to name a machine that exists in the machines: list above it. That sounds obvious written down, and it is the single easiest thing to get wrong when you assemble a manifest by copying blocks out of a larger one.
One line in there does nothing, and I only noticed by diffing
format: ext4 is not a field. The drive option is spelled filesystem. The platform did not reject it, warn about it, or fail the create. It simply dropped it.
Ask the platform what it actually stored and the line is gone:
drives:
- source: oci://ghcr.io/ibtisam-iq/jenkins-rootfs:latest
mount: /
size: 50GiB # no format, no filesystem
It happens to be harmless here, because ext4 is the default anyway. Had I written format: xfs I would have believed I had XFS for as long as it took something to break. Diff the served manifest against the file you submitted. Unknown keys are discarded quietly, which makes labctl playground manifest the only honest confirmation of what you deployed.
Fields worth understanding¶
kind is always playground.
name is the unique identifier and the URL slug. On create the platform appends a random suffix, so SilverStack-jenkins-server becomes SilverStack-jenkins-server-63fe430c. That full name is what every later command expects.
base does exist as a field, but it is informational in dumped manifests. At creation time the base comes from the --base flag. It matters more than it looks: only flexbox lets you define an arbitrary set of machines. Every other base keeps its own machines, and while you may take a subset or tweak them, new or renamed machines are rejected. That is why all five of mine are --base flexbox.
title, description and categories are what people see on the card. Categories are filter tags: linux, kubernetes, docker, networking, ci-cd, containers.
markdown is the landing-page body, the long-form description shown above the Start button, and cover is the card image. Neither appears in my manifests above because I keep each landing page as a separate CONTENT.md beside its Dockerfile. That, along with the provisioning fields this post does not cover, is in the companion post.
networks defines subnets, and machines attach to them by name. Four fields carry all of it:
networks:
- name: frontend
subnet: 10.0.1.0/24
- name: backend
subnet: 10.0.2.0/24
gateway: 10.0.2.254 # default: first free IP in the subnet
private: true # no NAT out, no default route via this network
machines:
- name: web-01
network:
interfaces:
- network: frontend
address: 10.0.1.10 # default: first free IP
- network: backend # multi-homed, address auto-assigned
private: true is the air-gap switch. A machine sitting only on that network has no route to the internet at all. Networks are isolated from each other, so traffic between two of them flows only through a machine attached to both, which makes web-01 above a deliberate gateway rather than an accident of addressing.
Two details that bite. Interfaces become eth0, eth1 in list order, and a machine's default route goes via the first non-private network it is attached to, not the first one listed. Machines resolve each other both by bare name and by <machine>.<network>.
Omit networks entirely and you inherit the base's, conventionally local on 172.16.0.0/24. That is what all five of mine do: one flat network, no segmentation. This is a capability I am describing rather than one I have needed.
drives is where a custom rootfs enters:
source takes two forms. A bare name like ubuntu-24-04 or rockylinux selects one of the platform's own rootfs images, which is what the official manifests use. The oci:// prefix points at your own image, which is the one that matters here.
Docker Hub is not supported as a drive source
The platform refuses Docker Hub for rootfs images because of its rate limiting. GHCR is the recommended registry, and the image must be publicly pullable since there is no credential to hand the platform at boot. This is the single most likely reason a manifest that looks correct fails to start.
Exactly one drive per machine must have mount: /. Additional drives can be attached at other mount points, auto-formatted and auto-mounted, or left raw as block devices. Sizes run 1 GiB to 100 GiB per drive, capped at 240 GiB across a playground.
users must name users that already exist inside the rootfs image. The platform does not create them. root is always available whether you list it or not; the official base images also ship a laborant user. My images create ibtisam, which is why the manifest can name it. Get this wrong and the machine boots but the terminal cannot log you in.
resources sets cpuCount and ramSize per machine, and the ceilings are per-plan:
| Per VM | Per playground | |
|---|---|---|
| Free tier | 2 vCPU / 4 GiB | 5 vCPU / 8 GiB |
| Paid | 4 vCPU / 10 GiB | 10 vCPU / 16 GiB |
The behaviour when you exceed a budget is worth internalising: requests are scaled down proportionally, not rejected. Your playground starts, quietly smaller than you asked for. Nothing errors, and the first symptom is an application that behaves like it is short on memory, because it is.
tabs are the interface, and there are five kinds, not four: terminal, ide, http-port, web-page, and kexp (a Kubernetes explorer). Defining tabs at all replaces the defaults entirely, so a manifest that declares one terminal tab loses the IDE it would otherwise have had.
Point http-port at your reverse proxy rather than the application port. It keeps working when the application port changes. For apps that are fussy about how they are served, tls, hostRewrite and pathRewrite handle HTTPS backends, Host-header validation, and apps not mounted at /.
accessControl governs discovery, viewing and starting, separately. canStart: [anyone] is what makes a playground launchable by a stranger. Other principals are owner, authenticated, user:<...> and student:<training-name>.
All three lists must be present and non-empty
When a manifest goes in through labctl, canList, canRead and canStart all have to be there with at least one principal each. Omitting one is rejected. Playgrounds cloned from a base without a manifest inherit the base's settings instead, which is why a clone can feel like it needed no access block at all.
Going Multi-Machine¶
Nothing structural changes. machines: becomes a longer list, tabs: gains entries pointing at the new names, and that is the whole difference. cicd-stack.yml is the same document as above with four entries instead of one:
machines:
- name: dev-machine
drives:
- source: oci://ghcr.io/ibtisam-iq/dev-cicd-rootfs:latest
mount: /
size: 30GiB
resources: { cpuCount: 1, ramSize: 1GiB }
- name: jenkins-server
drives:
- source: oci://ghcr.io/ibtisam-iq/jenkins-rootfs:latest
mount: /
size: 40GiB
resources: { cpuCount: 3, ramSize: 4GiB }
- name: sonarqube-server
drives:
- source: oci://ghcr.io/ibtisam-iq/sonarqube-rootfs:latest
mount: /
size: 40GiB
resources: { cpuCount: 3, ramSize: 6GiB }
- name: nexus-server
drives:
- source: oci://ghcr.io/ibtisam-iq/nexus-rootfs:latest
mount: /
size: 40GiB
resources: { cpuCount: 3, ramSize: 5GiB }
Abbreviated: each machine also carries the same users and network blocks as the single-machine version.
Two things worth noticing. The resource numbers are smaller than the standalone Jenkins manifest, which asks for 4 vCPU and 10 GiB on its own. Sharing a playground means sharing one fixed pool, so composition costs each machine something. That allocation is worked through in detail separately.
The other is a ceiling worth knowing before you design around it. The flexbox base describes itself as letting you choose up to five VMs:
Four machines is comfortably inside that. Eight is not a thing you can build.
Start or Create¶
This is the part I got wrong the first time. The manifest does not decide whether a playground is throwaway or persistent. The command does.
start for an ephemeral session¶
Runs immediately. Destroy it and everything is gone. Right for iterating on a manifest, where you will do this thirty times in an afternoon.
The positional argument is the base type and must be a catalog name.
create for a persistent playground¶
Registers the playground under your account without starting it. It appears in the dashboard under My Custom, not under Running, which confused me for a while. It prints the URL and the assigned name:
Creating playground from .../iximiuz/manifests/jenkins-server.yml
Playground URL: https://labs.iximiuz.com/playgrounds/SilverStack-jenkins-server-63fe430c
SilverStack-jenkins-server-63fe430c
The name you pass gets a random suffix appended. That suffix is part of the permanent URL, so capture the output rather than assuming you can reconstruct the link later. All five of mine were created this way, one command each, and the terminal log is in the repo.
Launch it afterwards by the full name, suffix included:
--base takes the same catalog names, and has a short form, -b.
update is not create with a different verb¶
The two commands read manifests differently, and this is the one that will waste an afternoon:
| What it accepts | |
|---|---|
create -f | A partial spec. Networks, tabs, categories and resources are inherited from the base when omitted. |
update -f | A complete manifest. Networks, tabs, categories and accessControl must all be present. |
So a file that created a playground perfectly will strip things out of it when passed to update. The reliable loop is to dump first and edit that:
labctl playground manifest <full-name> > manifest.yaml
# edit
labctl playground update <full-name> -f manifest.yaml
Free tier holds one custom playground
Free accounts can own a single custom playground at a time. If create refuses and you already have one, that is the reason, not a fault in your manifest.
Session lifecycle¶
| Action | Result |
|---|---|
| Stop a running session | VMs shut down, snapshot preserved in remote storage |
| Restart a stopped session | Resumes from the saved snapshot |
| Destroy the playground | Snapshot and all state permanently deleted |
Stop and restart is the one to know. A stopped session is not a lost session, which is what makes these usable for work that spans days rather than only for demos.
You can also pass a manifest through stdin, which is convenient when generating one:
Command Reference¶
| Command | Purpose |
|---|---|
labctl playground catalog | List playgrounds in the catalog |
labctl playground list | List your recent sessions (-a includes stopped) |
labctl playground manifest <name> | Print any playground's manifest |
labctl playground start <base> -f manifest.yaml | Ephemeral session from a manifest |
labctl playground create --base <base> <name> -f manifest.yaml | Register a persistent playground |
labctl playground start <name> | Start a saved playground |
labctl playground stop <id> | Stop, keeping the snapshot |
labctl playground restart <id> | Resume from snapshot |
labctl playground persist <id> | Make a running session persistent |
labctl playground destroy <id> | Delete a session and its data |
labctl playground remove <name> | Remove a playground you authored |
labctl playground update <name> -f manifest.yaml | Update a saved manifest |
labctl playground machines <id> | List a session's machines |
labctl playground tasks <id> | List a session's init tasks |
catalog -f my-custom filters to the ones you authored, which is the fastest way to find a name you forgot.
Flags That Save Real Time¶
Test what a free-tier user experiences. Your account has whatever limits it has, and a visitor's may be different. This runs your playground under free-tier constraints so you find out before they do:
If you publish a playground for other people, run this at least once. It is the difference between "works for me" and "works".
This matters more than a normal compatibility check because of how the platform enforces budgets. A free-tier visitor gets 5 vCPU and 8 GiB per playground against my 10 and 16, and going over does not produce an error. The request is scaled down proportionally and the playground starts anyway. My four-node stack asks for exactly the paid ceiling, so on a free account every machine silently shrinks to roughly half, and SonarQube's 6 GiB becomes about 3, which is under what Elasticsearch needs to finish starting. The playground boots. The service does not come up. Nothing in the output says why.
Debug a boot that never completes. Normally the CLI waits for initialisation before handing you a session, which is exactly wrong when initialisation is what is broken:
You land on a machine mid-boot and can read the journal while it fails.
Skip a step:
labctl playground start <name> --ssh -m jenkins-server # straight into a shell
labctl playground start <name> --ide code # straight into the editor
Custom Rootfs Requirements¶
A rootfs image is not an application image. It becomes the root filesystem of a whole VM, so it has to be a bootable Linux userland. The platform's requirements are short:
linux/amd64. This is why all six of my images build amd64 only.- No kernel. The platform supplies that; your image provides everything from
/sbin/initup. - An init system. systemd for most distributions, OpenRC for Alpine.
sshdlistening on0.0.0.0:22at boot. Terminals andlabctl sshare SSH.- The users your manifest names must already exist in the image.
- Publicly pullable, and not on Docker Hub.
The easy way and the way I did it¶
The official route is to inherit all of that:
FROM ghcr.io/iximiuz/labs/rootfs:ubuntu-24-04
RUN apt-get update && apt-get install -y postgresql-16 && apt-get clean
Everything above is already handled. For most people this is the correct answer and the post could end here.
I went the other way. ubuntu-24-04-rootfs is FROM ubuntu:24.04, unminimized, with systemd, sshd, users, cleared machine IDs and deleted host keys all handled explicitly, because I wanted to understand what the platform base was doing for me rather than inherit it. That is strictly more work, it is where most of the failures came from, and it is the subject of a separate post.
Pick the first option unless the second is your goal.
You may not need a Dockerfile at all
Two lighter paths exist, and I would have used one of them for a simpler environment.
The Playground Constructor UI sets a custom OCI image as a drive source without touching a manifest file. And there is a click-ops route with no image build whatsoever: start an off-the-shelf playground, install everything by hand, stop the instance, and save it as a custom playground.
The manifest-and-CLI route earns its keep when the environment has to be rebuilt reproducibly from source, which is the whole point of mine. It is not the only way in.
The official iximiuz/labs repository holds the rootfs image definitions and bake recipes, and is worth reading before you write your own. It is also where iximiuz/labs-playgrounds moved when that repository was archived in February 2026, so older links and older blog posts will point you at a frozen copy.
If you want the build-and-prove-it half worked through step by step, I turned it into a hands-on tutorial that runs in a browser playground: build a deliberately broken rootfs, watch it fail each requirement, then write a script that validates an image without booting it.
What I Would Do Differently Starting Out¶
Read three official manifests first. labctl playground manifest k8s-omni taught me more in five minutes than the schema documentation did, because it shows which fields are actually load-bearing.
Use start until the manifest is right, then create once. I created persistent playgrounds early and ended up with a dashboard full of half-working entries.
Get one machine booting before adding a second. Multi-machine failures are hard to attribute. A single machine that boots, has the user you expect and shows the tab you configured is a foundation you can extend.
Run --as-free-tier-user before sharing anything. Mine needed resizing.
Where This Leads¶
Once one image boots, the pattern repeats for anything: write the Dockerfile, publish the image, point a manifest at it. That is how five playgrounds got built from one base image, and it is why adding a sixth is now an evening rather than a project.
The composed four-machine stack is the most complete example, and the manifest behind it is a working file rather than an illustration.
Source¶
- All manifests: five single-machine and one four-machine composition
jenkins-server.yml, the file quoted in full abovecicd-stack.yml, the four-machine version- The terminal log of all five
createcommands and the URLs they returned - All six rootfs images
- labctl
- Official custom-playground docs, including the full manifest reference
- Official rootfs image definitions (the
labs-playgroundsrepository was archived in February 2026 and moved here)
Companion post
Next: Three Places to Put Provisioning: Init Tasks, Startup Files, or the Image, covering everything that happens after the machines boot, plus the landing page.
Series: Building a Self-Hosted CI/CD Stack from Scratch (Part 6 of 6)
- Previous: Four Machines, One Playground
- Full series index: Why I Built My Own CI/CD Stack