Workspace template examples
A lab's workspace templates describe the environments students can request. Each template is one workspace flavor: an image, an optional git repo to clone, resources, and anything extra the workshop needs (a database, a Docker daemon, some VS Code extensions).
This page is a cookbook of complete, copy-pasteable examples. For the field-by-field reference and the wizard form, see Configure workspaces.
Using these examples
- Go to New Lab and walk through to the Templates step — or open Add Template on an existing lab's detail page, which is the same editor.
- Choose Paste YAML.
- Paste an example below (or save it as a
.yamlfile and use Upload file). - Click Validate — it checks the document without creating anything.
- Finish the wizard and click Create Lab (or Add Template in the drawer).
YAML wins while it is selected
While Paste YAML is selected, the form fields are ignored. Switching back to Build with a form does not carry your YAML edits over.
Only name is required, and template names must be unique within a lab. Unknown
keys are rejected rather than ignored, so a typo like imagee: fails
validation instead of silently shipping a lab without its image.
Key reference
| Key | Type | Notes |
|---|---|---|
name |
string | Required. Shown in the student template selector; unique per lab. |
ide |
string | Legacy — omit it. Workspaces always run code-server. Older labs carrying openvscode still load; the value is rewritten to code-server. |
image |
string | Container image override. Defaults to codercom/code-server:latest. |
git_repo |
string | Cloned into the workspace on first start, into an empty workspace only. |
git_branch |
string | Clones a single branch. Default branch when unset. |
git_folder |
string | Subfolder of the repo the IDE opens. Repo root when unset. |
git_shallow |
bool | true clones only the latest commit (--depth 1): faster first start on a large repo, but no git history. See Faster first start. |
cpu |
string | Resource request, e.g. 500m. Quote plain numbers: "2". |
memory |
string | Resource request, e.g. 4Gi. |
cpu_limit |
string | Resource limit override. Empty means it matches cpu (the request). |
memory_limit |
string | Resource limit override. Empty means it matches memory (the request). |
disk_size |
string | Volume size, e.g. 10Gi. Defaults to 5Gi — it sizes the volume, it does not decide whether there is one. |
ephemeral |
bool | true drops the persistent volume: student work is lost whenever the pod is rescheduled. See Persistence. |
storage_class |
string | StorageClass for the volume. Empty uses the cluster default, which is correct on OVHcloud and Azure. |
startup_script |
string | Shell commands run before the IDE starts. Best-effort. |
dotfiles_repo |
string | Cloned to ~/.dotfiles; its install.sh / setup.sh / bootstrap.sh runs if present. |
extensions |
list | VS Code extension IDs installed on start. |
vscode_settings |
map | VS Code settings written to the student's user settings.json on first start. See VS Code settings. |
env |
map | Environment variables for the workspace container. |
sidecars |
list | Extra containers in the pod: name, image, ports, env, privileged, capabilities. |
mounts |
list | Existing ConfigMaps/Secrets: type (configmap | secret), name, path. Mounted read-only. |
image_pull_secrets |
list | Names of registry credentials used to pull this template's images. See Private registries and repositories. |
git_auth_secret |
string | Name of a git credential used to clone a private git_repo. http(s) only. See Private registries and repositories. |
devcontainer |
map | Builds the image from the repo's devcontainer.json instead of using image. See Devcontainer workshops. |
Minimal
The smallest valid document. Students get a code-server workspace on the default image with a 5Gi persistent volume.
workspace_templates:
- name: default
Persistence
Every workspace gets a persistent volume unless the template opts out. The volume is mounted over the student's whole home directory, so their files, their IDE settings and the extensions they install all come back when the pod is rescheduled onto another node — which happens routinely, on cluster upgrades, node failures and autoscaler consolidation.
A devcontainer workspace is the exception: its volume holds the project folder and the IDE's settings and extensions, but not the rest of the home directory — see the caveats in that section.
disk_size sizes that volume (5Gi by default). storage_class places it; left
empty the cluster's default StorageClass is used, which is what you want on an
EasyLab-provisioned OVHcloud or Azure cluster.
ephemeral: true throws the student's work away
Setting ephemeral: true replaces the volume with an EmptyDir tied to the
node. Anything the student writes — including their cloned repo, which is
silently re-cloned from scratch — is lost whenever the pod moves. It exists
for two cases: throwaway demo labs, and very large cohorts on Azure, where
hundreds of simultaneous disk attachments can exceed the cluster's attach
limits and leave pods stuck (see Azure). It cannot be combined
with disk_size or storage_class; that is rejected when the lab is saved.
Bring-your-own clusters
A workspace volume is only as durable as the StorageClass behind it. If your
cluster's default provisioner is node-local (local-path, hostPath), the
volume is pinned to one node and the data is lost on a reschedule regardless
of disk_size. Set storage_class to a network-attached class in that case.
A git-backed workshop
The most common setup: clone the exercises repo, open a subfolder, install the language extension.
workspace_templates:
- name: go-workshop
git_repo: https://gitlab.com/yodamad/go-workshop.git
git_branch: main
git_folder: exercises
extensions:
- golang.go
The repo is cloned only when the volume is empty — a restarting workspace keeps the student's own commits and edits rather than being reset.
Faster first start
Most of a student's wait happens while the workspace pod starts, not in EasyLab. Two things help:
- Images are pre-pulled for you. Every lab runs a small DaemonSet
(
easylab-prepull-<lab id>, in the workspace namespace) that pulls each template's images onto every node ahead of time. That covers the IDE image, sidecars, the git-clone helper, and the baked or envbuilder image for devcontainer templates. So the first student on a node, or on a node the autoscaler just added, does not wait on a multi-hundred-megabyte pull. This needs no configuration; see Image pre-pull. git_shallow: truefor large repos. It clones only the latest commit instead of the full history. The student can still commit, push and pull, butgit log,git blameand checking out older commits only see that one commit. Use it when the history is large and the exercises don't need it. It also applies to a devcontainer template that is not baked (ENVBUILDER_GIT_CLONE_DEPTH=1). A baked repo snapshot is always a full clone, because it is taken once and not per student.
workspace_templates:
- name: big-repo
git_repo: https://gitlab.com/org/monorepo.git
git_shallow: true
If students don't need their work to survive a reschedule, ephemeral: true
also skips attaching a persistent volume, which on OVHcloud adds tens of seconds
to a start. Read the warning under Persistence first.
Sizing a workspace
workspace_templates:
- name: heavy
cpu: "2"
memory: 4Gi
disk_size: 20Gi
Quote bare numbers
cpu: 2 fails validation — these fields are strings. Write cpu: "2".
Values with a unit suffix (500m, 4Gi) are already strings and need no quotes.
Several templates in one lab
Students request one workspace per template, so a lab with three templates lets each student run up to three workspaces side by side. They pick from a dropdown on the student portal.
workspace_templates:
- name: go
git_repo: https://gitlab.com/yodamad/go-workshop.git
extensions:
- golang.go
- name: node
git_repo: https://gitlab.com/yodamad/node-workshop.git
extensions:
- dbaeumer.vscode-eslint
- name: scratch
disk_size: 5Gi
Installing system packages
Startup scripts run as the workspace user, which has passwordless sudo, so
packages can be installed at start:
workspace_templates:
- name: tooling
startup_script: |
sudo apt-get update
sudo apt-get install -y jq make httpie
Startup scripts are best-effort: a failing command is visible in
kubectl logs but never blocks the workspace from opening.
Every student pays this cost on every workspace start, so for anything slow or large bake the tools into an image instead and point the template at it:
FROM codercom/code-server:latest
USER root
RUN apt-get update && apt-get install -y golang nodejs && rm -rf /var/lib/apt/lists/*
USER coder
workspace_templates:
- name: custom
image: registry.example.com/easylab/go-workshop:1.0
Push it to a registry the lab cluster can pull from. Prebuilt images also start
faster than installing the same packages in every student's workspace. If the
registry is private, add image_pull_secrets — see
Private registries and repositories.
A database sidecar
Sidecars are extra containers in the same pod, reachable from the IDE at
localhost:<port>.
workspace_templates:
- name: api-workshop
git_repo: https://gitlab.com/yodamad/api-workshop.git
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app
sidecars:
- name: db
image: postgres:16
ports: [5432]
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
Each student gets their own database — sidecars live in the student's pod, not in a shared service.
workspace is reserved
The IDE container is named workspace. A sidecar with that name (or a name
that isn't a valid DNS label) is dropped from the pod.
Docker inside a workspace
Managed clusters (OVHcloud, AKS) run containerd, not Docker, so there is no host Docker socket to reuse. Run a Docker-in-Docker sidecar and point the CLI at it:
workspace_templates:
- name: docker-workshop
env:
DOCKER_HOST: tcp://localhost:2375
startup_script: |
sudo apt-get update && sudo apt-get install -y docker.io
until docker info >/dev/null 2>&1; do sleep 2; done
sidecars:
- name: docker
image: docker:dind
ports: [2375]
privileged: true
env:
DOCKER_TLS_CERTDIR: ""
DOCKER_TLS_CERTDIR: "" disables TLS on the daemon, which is what makes the plain
tcp://localhost:2375 connection work. The startup script waits for the daemon so
students don't hit "cannot connect" in their first minute.
privileged: true escalates to the node
Docker-in-Docker requires a privileged container, and a privileged container can
take over the node it runs on. Only enable this on a trusted workshop cluster you
control. For an unprivileged alternative, use docker:dind-rootless with the
right capabilities, or install a Sysbox/Kata RuntimeClass on the cluster.
Mounting config and secrets
Mount a ConfigMap or Secret that already exists in the workspace namespace
(workshops by default — set it on the Workspace step):
workspace_templates:
- name: preconfigured
mounts:
- type: configmap
name: workshop-config
path: /etc/workshop
- type: secret
name: workshop-registry
path: /home/coder/.docker
Create them before students request workspaces:
kubectl -n workshops create configmap workshop-config --from-file=./config/
kubectl -n workshops create secret generic workshop-registry --from-file=.dockerconfigjson=./config.json
The object must exist first
A mount referencing a missing ConfigMap or Secret leaves the pod stuck and the workspace never opens. Mounts are read-only, so students can't edit them.
Personal dotfiles
workspace_templates:
- name: dotfiles
dotfiles_repo: https://github.com/yodamad/dotfiles
The repo is cloned to ~/.dotfiles and the first executable of install.sh,
setup.sh or bootstrap.sh runs. Like startup scripts, this is best-effort.
VS Code settings
workspace_templates:
- name: focused
vscode_settings:
chat.disableAIFeatures: true
security.workspace.trust.enabled: false
editor.fontSize: 14
files.exclude:
"**/node_modules": true
The settings are written to code-server's user settings file
(~/.local/share/code-server/User/settings.json) before the IDE starts. All
setting scopes work there, including application-scoped ones such as
security.workspace.trust.enabled.
- The file is only written when it does not exist yet. On a persistent
workspace that means the first start: a student who changes a setting keeps
the change across restarts, and a later edit to
vscode_settingsdoes not reach workspaces that already exist. - The value must be a map. It is validated when the YAML is saved; a key with no name, or a value that is not a mapping, is rejected in the editor.
- Importing a devcontainer fills
vscode_settingsfrom itscustomizations.vscode.settings— see Importing a devcontainer.
Everything together
A full-featured template using most of the schema:
workspace_templates:
- name: full-stack
image: codercom/enterprise-base:ubuntu
git_repo: https://gitlab.com/yodamad/workshop.git
git_branch: main
git_folder: exercises
cpu: "2"
memory: 4Gi
disk_size: 10Gi
startup_script: |
sudo apt-get update && sudo apt-get install -y jq
make bootstrap
dotfiles_repo: https://github.com/yodamad/dotfiles
extensions:
- golang.go
- dbaeumer.vscode-eslint
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/app
sidecars:
- name: db
image: postgres:16
ports: [5432]
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: app
mounts:
- type: configmap
name: workshop-config
path: /etc/workshop
Private registries and repositories
A private image or a private workshop repository needs a credential. Credentials are never written in the template: the template names a Kubernetes Secret, and the Secret lives in the lab's cluster. That is what keeps tokens out of the lab's job file and out of the templates export, so a template is always safe to share.
workspace_templates:
- name: private-workshop
image: registry.example.com/workshops/base:1
image_pull_secrets:
- regcred
git_repo: https://gitlab.com/org/private-workshop.git
git_auth_secret: gitcred
disk_size: 10Gi
Adding a credential
There are two moments to add one, and they behave the same way — the token is written to the lab's cluster and EasyLab keeps no copy:
- In the creation wizard. The Workspace Templates step has a Credentials section. Add a row for each credential your templates reference. Because the cluster does not exist yet, the tokens are held in memory and written the moment provisioning finishes — before the lab reports itself ready.
- In the lab detail page's Workspaces & Templates section, once the lab is up. Expand Credentials, add a Container registry or Git repository credential, and paste the token.
Saving over an existing name rotates it. Workspaces already running keep the old token — the kubelet resolved it when the pod started — so a rotation reaches students only as their workspaces are recreated.
Credentials live in the cluster, so destroying a lab destroys them. Recreating a lab prompts you to re-enter the tokens (the names and types are carried over). A retried lab keeps them.
If a template references a credential the cluster does not have — a token lost to a server restart mid-provisioning, a name typo, or one not yet added — the Credentials panel says so, and the workspaces that need it fail until it is added.
If you have kubectl access you can create the same Secrets by hand; the panel
lists those too, since what matters is the name your template references:
# Registry — for image_pull_secrets
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=<user> \
--docker-password=<token> \
--namespace=workshops
# Git — for git_auth_secret
kubectl create secret generic gitcred \
--type=kubernetes.io/basic-auth \
--from-literal=username=oauth2 \
--from-literal=password=<token> \
--namespace=workshops
image_pull_secrets does not cover the devcontainer build
Two different things pull images, and they do not share credentials.
| Image | Pulled by | Credential |
|---|---|---|
image, sidecars, the init containers |
The kubelet | image_pull_secrets |
The devcontainer's base image, fallback_image, cache_repo |
The build, inside the pod | devcontainer.registry_auth_secret |
A devcontainer template pulling a private base image needs
registry_auth_secret; image_pull_secrets will not help it. Both may be set,
and both may name the same Secret.
git_auth_secret is http(s) only
A username and password is not how ssh:// authenticates, so the secret would
be ignored and the clone would fail on the host key instead. Validation rejects
the combination rather than letting you find out at the first workspace.
For GitLab use oauth2 as the username with a personal access token; GitHub
accepts any non-empty username. The Credentials panel fills in oauth2 when
you leave it blank.
Tokens in a repo URL are not private
git_repo: https://oauth2:glpat-xxx@gitlab.com/org/repo.git works, and EasyLab
accepts it with a warning rather than rejecting it. But the URL is stored in
the lab's job file, returned by the jobs API, and included in the templates
export — so the token travels anywhere the template does. git_auth_secret
exists to avoid exactly that.
What students can and cannot do
The clone is authenticated; the student's IDE is not. The token is given to the clone step alone and never to the workspace container, because students have a shell there and the token is yours, not theirs.
So a student's checkout has an ordinary remote with no stored credentials: reading
the code works, but an interactive git pull or git push against the private
repo will prompt and fail. If a workshop needs students to push, have them
authenticate as themselves from inside the workspace.
Devcontainer workshops
When a workshop repository already ships a .devcontainer/devcontainer.json, a
template can build from it instead of naming an image. Each student's workspace
builds the devcontainer on first start, so a workshop's own Dockerfile and
features work as written.
workspace_templates:
- name: go-workshop
git_repo: https://gitlab.com/org/workshop.git
git_branch: main
cpu: "2"
memory: 4Gi
disk_size: 20Gi
extensions:
- golang.go
devcontainer:
enabled: true
dir: .devcontainer
cache_repo: registry.example.com/easylab/cache
registry_auth_secret: regcred
| Key | Type | Notes |
|---|---|---|
enabled |
bool | Turns the mode on. Requires git_repo, and conflicts with image. |
dir |
string | Folder holding devcontainer.json. Defaults to .devcontainer. |
cache_repo |
string | Registry the built layers are cached in — see below. Required unless use_in_cluster_cache is set. |
use_in_cluster_cache |
bool | Provisions the build cache inside the lab's cluster instead of requiring cache_repo to name an external registry. Ignored if cache_repo is also set — an explicit cache_repo always wins. See below. |
registry_auth_secret |
string | Registry credential for everything the build pulls or pushes: the base image, fallback_image, and cache_repo. Omit when all of them are public. An in-cluster cache needs none for itself, but a private base image still does — the two combine. See Private registries and repositories. |
fallback_image |
string | Used when the devcontainer names neither an image nor a Dockerfile. |
insecure |
bool | Skip TLS verification when cloning and pulling. |
config_repo |
string | Reads devcontainer.json from a separate repo instead of git_repo — see Devcontainer config from a separate repository. dir then resolves inside this repo. |
config_branch |
string | Branch to clone from config_repo. Default branch when unset. Ignored when config_repo is unset. |
config_auth_secret |
string | Git credential for a private config_repo. http(s) only. Ignored when config_repo is unset. |
The template's own ide key applies here too — see the constraints below.
The image the devcontainer builds contains no IDE of its own, so code-server is copied onto a volume the build is told to leave alone, and started from there. That makes the IDE independent of the devcontainer's own base image — but it does impose three constraints:
- The base image must be glibc-based. The bundle ships a dynamically
linked Node, which cannot run on Alpine/musl. The failure is misleading: the
workspace container exits with
no such file or directoryeven though the binary is plainly there. - The devcontainer's user needs a writable
$HOME. code-server stores its user data and extensions under the home directory of the user the devcontainer runs as. When that is unwritable, extension installs are skipped quietly and the IDE then fails to start — so an unexplained "my extensions are missing" is worth checking here first. - Do not set
ephemeral. The volume holding the student's files is also what makes the workspace folder writable by the usual uid-1000 devcontainer users (vscode,node,coder). Opting out of it can leave the folder owned by root, with the student unable to save. Usedisk_sizeto size it instead. $HOMEis not persisted in devcontainer mode, but the IDE's state is. A plain workspace mounts its volume over the whole home directory; a devcontainer one cannot, because envbuilder replaces the entire root filesystem during the build and a devcontainer image's user home is often not/home/coderat all. Its volume instead holds two things: the project folder, and code-server's data directory (~/.local/share/code-server, linked onto the volume at/ide-state). So a student's files, their IDE settings and the extensions they install survive a restart, while anything else written to the home directory — shell history, dotfiles, tools installed under~— does not.- This needs the devcontainer's user to be root or in group 1000, as the
usual
vscode,nodeandcoderusers are. For any other user the IDE falls back to keeping its data in the unpersisted home directory. - It applies to workspaces created from now on. A devcontainer workspace that already exists keeps the project-only volume it was created with until it is deleted and recreated.
- This needs the devcontainer's user to be root or in group 1000, as the
usual
Authentication is the same as outside devcontainer mode: code-server presents a login page taking the workspace password.
A private devcontainer repo needs git_auth_secret
The devcontainer is built inside the pod, and the builder clones the repo
itself — so a private git_repo needs git_auth_secret, exactly like any other
private repo. It is separate from registry_auth_secret, which only covers
the images the build pulls and pushes. Miss it and the clone falls back to no
authentication: the build then reports the devcontainer.json as missing
(no such file or directory) because nothing was ever fetched.
workspace_templates:
- name: go-workshop
git_repo: https://innersource.example.com/org/workshop.git
git_branch: master
git_auth_secret: gitcred
disk_size: 20Gi
devcontainer:
enabled: true
dir: .devcontainer
cache_repo: registry.example.com/easylab/cache
registry_auth_secret: regcred
Create gitcred the same way as for any private repo — see
Private registries and repositories.
Devcontainer config from a separate repository
By default devcontainer.json is read from git_repo — the same repo the
student's workspace clones. Set devcontainer.config_repo to read it from a
different repo instead — for a devcontainer config shared or standardized
across several workshops, kept apart from the workshop content it builds.
git_repo is still required and is still what the student's workspace clones;
config_repo is only cloned to build the devcontainer, into a separate location
in the pod. dir (default .devcontainer) then resolves inside config_repo
rather than git_repo.
workspace_templates:
- name: go-workshop
git_repo: https://gitlab.com/org/go-workshop-exercises.git
git_branch: main
disk_size: 20Gi
devcontainer:
enabled: true
config_repo: https://gitlab.com/org/devcontainer-config.git
config_branch: main
dir: .devcontainer
cache_repo: registry.example.com/easylab/cache
registry_auth_secret: regcred
A private config_repo needs its own config_auth_secret — it is a separate
credential from git_auth_secret, because the two repos may live in different
projects or organizations. Create it the same way as any other git credential —
see Private registries and repositories.
Importing a devcontainer
Rather than writing the block by hand, choose From a devcontainer in the
Templates step — or in the Add Template drawer on an existing lab. It reads a
devcontainer.json — by cloning a repo, or from a devcontainer.json / repository
.zip you upload — fills the template in, and reports anything in the devcontainer
that will not take effect. The result is ordinary YAML: review and edit it before
creating the lab.
By default the import clones the workshop repository field to read the
devcontainer. Choose Different repository under Devcontainer config to
read it from a separate config repo instead, while the workshop repository field
stays the content repo the generated template's git_repo is set to — this
produces the config_repo block above without hand-editing the YAML.
For a private repository, supply an access token in the import dialog. It
authenticates that one clone and is then discarded — it is not saved anywhere and
does not become the lab's git_auth_secret. It cannot: at import time the lab's
cluster does not exist yet, so there is nowhere to put a Secret. Giving students
access to the repo is the separate step described in
Private registries and repositories.
What is honoured, and what is not
The build understands the parts that describe the image; EasyLab covers several keys the build ignores; and some keys are honoured by neither.
| devcontainer.json | Handled by |
|---|---|
image, build.dockerfile, build.args, build.context |
The build |
features |
The build |
containerEnv, remoteEnv |
The build |
postCreateCommand, onCreateCommand, updateContentCommand |
The build |
customizations.vscode.extensions |
EasyLab — copied to extensions on import; -publisher.ext removal entries are dropped, along with the extension they remove |
customizations.vscode.settings |
EasyLab — copied to vscode_settings on import |
hostRequirements.cpus / memory / storage |
EasyLab — copied to cpu / memory / disk_size on import |
workspaceFolder |
EasyLab — copied to git_folder when relative |
dockerComposeFile, service, runServices |
Nobody — rejected at import |
forwardPorts, mounts, workspaceMount |
Nobody — only the IDE port is routed; use the template's mounts |
privileged, capAdd, init |
Nobody — use a template sidecar instead |
postStartCommand |
Nobody — move it to postCreateCommand or the template's startup_script |
docker-compose devcontainers are not supported
A devcontainer built on dockerComposeFile cannot be used: the workspace is
built as a single image, not a set of orchestrated services. The import
rejects it outright. The nearest equivalent is a hand-written sidecars:
block — see A database sidecar.
The cache registry is not optional
cache_repo (or use_in_cluster_cache) is required for a reason. Image
layers are cached there and pushed after the first build, so the first
workspace pays for the build and every later one starts from the cache —
the difference is minutes versus seconds, per student. Without either
one, all thirty students in a workshop would each rebuild the whole
devcontainer from scratch.
The Secret named by registry_auth_secret must already exist in the workspace
namespace — add it from the lab's Credentials panel, or with kubectl.
See Private registries and repositories.
Hosting the cache registry in the cluster
Set use_in_cluster_cache: true instead of cache_repo to have EasyLab
provision the registry itself, in the lab's own cluster:
devcontainer:
enabled: true
dir: .devcontainer
use_in_cluster_cache: true
No registry_auth_secret is needed for the cache itself. The in-cluster registry does require
authentication (once a template is pre-baked
it is also exposed through the lab's domain, and baked images carry the
workshop repo), but EasyLab generates its credentials itself — stored in the
easylab-registry-cache-auth Secret of the workspace namespace — and
supplies them wherever the registry is used: envbuilder's pushes and pulls
over the cluster network, a student pod's pull of a baked image, and
EasyLab's own checks. It is shared by every devcontainer
template in the lab that opts in, and disappears when the lab is
destroyed. An external cache_repo remains the right choice when a cache
needs to survive across labs (e.g. a shared base image reused by several
workshops) or be inspected outside the cluster.
A devcontainer built from a private base image still needs
registry_auth_secret for that image — EasyLab merges it with the in-cluster
registry's credentials, for live builds and bakes alike:
devcontainer:
enabled: true
dir: .devcontainer
use_in_cluster_cache: true
registry_auth_secret: regcred # for the private base image
First start is slow, and it uses node disk
Even with a warm cache the first workspace of a lab builds the devcontainer before the IDE appears, which can take several minutes. The build writes to the node's disk rather than the workspace volume, so a large image across many students is worth sizing the node pool for.
A warm cache skips the build, not the extraction — size CPU/memory for concurrent starts
A cache hit only skips re-running the devcontainer's build steps; every
workspace still has to extract the resulting image's layers onto its own pod
from scratch (Extracting layer N/M... in the pod's logs) — that part happens
every time, cache hit or not, and it is CPU- and disk-I/O-heavy. Leave cpu,
memory, cpu_limit and memory_limit all unset and EasyLab now applies a
default (500m CPU request / 2 CPU limit, 2Gi memory, 5Gi ephemeral storage)
instead of leaving the pod with no reservation at all — the low CPU request
keeps the scheduler/cluster-autoscaler from over-provisioning nodes for a
burst of many workspaces, while the higher limit still lets extraction burst
into whatever a node actually has spare. A workshop with many concurrent
students on a large base image should still size cpu/memory (the request)
and cpu_limit/memory_limit (the limit) explicitly for the image and class
size — the default is a floor, not a recommendation for every workload.
Pre-baking: skipping the build entirely
Everything above describes making the build fast. A lab admin can instead skip it entirely: in the lab detail page's Workspaces & Templates section, a devcontainer template's card has a Bake image button. Baking builds the devcontainer once, pushes the result as an ordinary image, and records it on the lab — every student workspace for that template afterward does a plain image pull instead of running envbuilder at all, so neither the build nor the per-pod layer extraction above happens per student.
This is an action on a lab, not a template YAML key — there is nothing to set on the
template itself. An admin triggers it with the button, or lets EasyLab do it with the
Bake devcontainer images automatically option of the create-lab wizard and of the
Add Template drawer, which bakes a devcontainer template as soon as the lab is ready
or the template is added. See
Pre-baking a devcontainer template
in the admin guide for how to trigger it, what the status badges mean, and the
domain requirement when baking to the in-cluster registry (an external
cache_repo has no such requirement).
A bake also snapshots the workshop repository (git_repo, at git_branch)
into the image, at /opt/easylab/repo. A student's workspace copies it onto
their volume on first start instead of cloning, so starting a workspace makes no
request to the git host at all — no clone time, and no rate limit to hit when a
whole class starts at once. The result is the same checkout a clone would have
produced, .git included, owned by the IDE user; a returning student's files are
never overwritten. Private repositories are baked too — the clone at bake time uses
the template's git_auth_secret, and the token is never written into the image.
The in-cluster registry requires authentication for exactly this reason; an
external cache_repo should be private if the repository is.
The tradeoff: a bake is a snapshot. It does not track the workshop repository, so
a new commit — or a devcontainer.json change — after baking needs an explicit
Rebuild. Until then, students keep getting the previously baked content rather
than the live one.
Bakes made before repositories were baked
A template baked by an earlier EasyLab version still clones the repository at workspace start, as before — its image has no snapshot to copy from. Click Rebuild once to include the repository.
A Rebuild always reaches students. Baked images are pushed to a fixed :latest
tag, but EasyLab records the image by its digest (…/baked/<lab>/<template>@sha256:…)
rather than by that tag, and hands the digest to student pods. That matters because
workspace pods pull with imagePullPolicy: IfNotPresent — deliberately, so a burst
of students starting at once reuses a node's cached layers instead of all hitting the
registry. Under a plain :latest a node that had already cached the pre-rebuild image
would have gone on serving it; a digest changes whenever the content does, so the
kubelet fetches the new image while an unchanged one still hits the node cache.
(If the registry does not report a digest, EasyLab falls back to the tag and logs it —
the bake still works, without that guarantee.)
Reusing a lab's templates
Use Export Templates YAML on the labs list to
download an existing lab's workspace-templates-<stack>.yaml, then load it into a
new lab — or into a running one through Add Template — with Upload file. Only
the templates are exported — credentials are never included.
This makes template documents easy to keep in a git repository and reuse from one workshop edition to the next.
Troubleshooting
| Symptom | Likely cause |
|---|---|
cannot unmarshal number ... into ... of type string |
A bare number where a string is expected — quote it (cpu: "2"). |
unknown field "imagee" |
A typo'd key. Unknown keys are rejected on purpose. |
duplicate template name "go" |
Two templates share a name; names must be unique within a lab. |
| Student work disappears after a restart | ephemeral: true on the template, or a bring-your-own cluster whose default StorageClass is node-local — see Persistence. |
| The repo isn't cloned | git_repo clones only into an empty volume; an existing workspace keeps its contents. |
| Workspace never opens | Often a mounts entry pointing at a ConfigMap/Secret that doesn't exist in the namespace. |
apt-get fails in the startup script |
Prefix it with sudo; the workspace user has passwordless sudo but is not root. |
| A sidecar is missing from the pod | It was named workspace (reserved) or has no image. |
devcontainer.cache_repo is required unless devcontainer.use_in_cluster_cache is set |
Devcontainer mode needs a layer cache registry — either an external cache_repo, or use_in_cluster_cache: true to have EasyLab host one; see Devcontainer workshops. |
devcontainer.enabled conflicts with image |
The image is built from the repo's devcontainer.json — remove image. |
devcontainer.enabled requires git_repo |
The devcontainer.json is read from the workshop repo, so there must be one. |
git_auth_secret requires git_repo |
The credential has nothing to authenticate to — remove it, or add the repo. |
git_auth_secret needs an http(s) git_repo |
A username and password is not how ssh:// authenticates. Use an https:// remote. |
failed to read git auth secret "x" |
The template names a credential the lab's cluster does not have. Add it in Credentials in the lab detail page's Workspaces & Templates section. |
Workspace pods stuck in ImagePullBackOff |
A private image with no image_pull_secrets, or a name that does not match a credential. |
The devcontainer build — or a bake — fails pulling its base image (DENIED: access forbidden, UNAUTHORIZED) |
Devcontainer images are pulled by the build, not the kubelet — they need devcontainer.registry_auth_secret, not image_pull_secrets. This holds with use_in_cluster_cache too: EasyLab only supplies the in-cluster registry's own credentials, not the base image's. |
Devcontainer build fails with devcontainer.json: no such file or directory on a private repo |
The clone ran with no credentials (Using no authentication! in the pod logs), so nothing was fetched. Add git_auth_secret — see Devcontainer workshops. Recreate the workspace so the clone runs again on a clean volume. |
Students can read the repo but cannot git push |
Expected. The token authenticates the clone only and is never given to the IDE. |
A devcontainer import fails on dockerComposeFile |
Compose-based devcontainers are not supported — use sidecars instead. |
| Every student's workspace takes minutes to start | The cache registry is unreachable or unwritable, so each build starts cold. Check registry_auth_secret. If the cache is warm and it's still slow at scale, it's likely layer extraction contending for CPU/disk across many concurrent pods — see A warm cache skips the build, not the extraction and size cpu/memory explicitly. |
| "Bake image" is rejected for a template using the in-cluster registry | Baking to the in-cluster registry needs the lab to have a domain configured, so a student's pull can trust the registry over HTTPS — see Pre-baking: skipping the build entirely. Configure a domain for the lab, or set an external cache_repo on the template instead. |
| A bake fails with "image was built, but never became pullable" | The image built and pushed fine, but nothing could fetch it back over a trusted connection. For the in-cluster registry this almost always means its TLS certificate never finished issuing. Check kubectl get certificate,certificaterequest -n <workspace namespace> in the lab's cluster; a stuck CertificateRequest usually means the lab's ClusterIssuer name doesn't match one that actually exists on that cluster (kubectl get clusterissuer). For an external cache_repo, verification authenticates with the same registry_auth_secret the push used, so a persistent (not just transient) failure there means those credentials cannot read the pushed tag back — check they have pull, not just push, permission on the registry. Click Rebuild once fixed — EasyLab only records a bake as ready after confirming it. |
A workspace pod's pull error names a *.traefik.default (or similar auto-generated) certificate, not the registry's own hostname |
Traefik never received a real certificate for that host and fell back to serving its own internal default one — the ClusterIssuer/CertificateRequest never actually completed. This is the same failure as the row above, just observed from the workspace pod's own pull error instead of the bake status; the fix is the same (fix the certificate, then Rebuild — a stale pre-fix bake record is cleared automatically once a rebuild confirms the pull path is still broken). Note that the registry's Ingress no longer needs to be recreated to pick up corrected HTTPS settings: it is reconciled on the next bake, as workspace Ingresses are — see Certificates repair themselves. |
A pull from the in-cluster registry fails with 401 Unauthorized / authentication required |
The registry requires authentication, and EasyLab adds its credentials to every workspace it creates — but a workspace created before the registry was secured has none. It keeps running while its node has the image cached; if its pod lands on a fresh node, the pull fails. Delete and recreate that workspace. If new workspaces fail too, check the easylab-registry-cache-auth and easylab-registry-cache-htpasswd Secrets exist in the workspace namespace: EasyLab recreates either one on the next bake or workspace request, and restarts the registry to pick up a new password. |
A bake fails and the bake-repo container's log shows a crane append error |
The devcontainer built and pushed fine, but appending the repository snapshot to it did not. kubectl logs -n <workspace namespace> -l easylab.io/bake-template=<template> -c bake-repo has the cause — typically credentials that can push the build but not the extra layer, or an external cache_repo served over plain HTTP without insecure: true. |