Pattern Cookbook

Instruqt Track Patterns Cookbook

Recipes for building production-quality tracks

55
Recipes
19
Infrastructure
24
Technique
12
Author
In this cookbook
PRE Preface How to Read
Part II — Infrastructure
Part III — Technique
Part IV — Author
END Closing Note

This book is the recipe form of a working library. Every pattern here has been used in a production track, hit a problem, been refined, and come back to be used again. The names are the names that authors use when they describe what they built to other authors. The shapes are the shapes that survive contact with real learners on real labs.

The book is organized for the moment you have a problem in front of you. Each recipe is self-contained. You can read one and act on it without reading the rest of the book. The pieces are small on purpose: when something is broken at four o'clock on a Friday before a Monday demo, you do not have time to read three chapters.

The companion book is The Instruqt track builder's field guide, which walks the lifecycle of a track end to end and explains the reasoning behind the patterns. The Field Guide teaches you how to think about a track. This Cookbook tells you exactly what to type. The two are best read together — Field Guide first, Cookbook on the desk afterwards — but each works on its own. Decision Trees v2 cross-references this Cookbook by recipe name; the Cheat Sheets v2 pack is for glance-and-go reference.

Three constraints shape every recipe. No real track names appear anywhere. The patterns are described concretely enough that the reader can implement them from the description alone. Code is like-for-like, not verbatim. Hostnames, image references, and identifiers are generic placeholders — image registries, customer org slugs, and platform-vendor namespaces are stripped — but the shapes mirror real production code. Voice is direct. When a pattern has a known failure mode, the failure mode is named and the symptom is named. When an opinion is earned, the opinion is given without softening.

This is the v2 edition. It is built against a larger curated source than v1, and it expands the catalog from forty-seven recipes to fifty-five. Two recipes — Recipe 14 and Recipe 27 — preserve their v1 numbering as carry-forward seeds. The rest are reorganized, retuned, and extended.

The Cookbook is structured as recipes. Each recipe is a single, complete, copy-friendly answer to a specific design problem. You can read it cover to cover, but it is built to be opened to one page when you have a problem in front of you.

Every recipe follows the same six-section format:

  • Use when — a one-line guidance test. If this line does not match your situation, skip the recipe.
  • Problem — the situation that prompts the pattern. What you tried first, why it did not work, what the failure looked like.
  • Solution — the conceptual shape of the answer. The mental model, in prose, before any code.
  • Skeleton — copy-friendly code or YAML. Generic names; safe to lift and adapt.
  • Gotchas — the failure modes other authors have hit. Specific symptoms, specific fixes.
  • Variations — related recipes, alternative shapes, or extensions worth knowing about.

Patterns are categorized by scope.

Infrastructure patterns describe how a track's hosts, networks, and resources are wired together. They are decisions that live in config.yml and shape what every other pattern can do.

Technique patterns describe a single self-contained move — a trick, a wrapper, a setup convention — that a track might or might not use. Most are bash patterns from setup or check scripts. They compose freely.

Author patterns describe how content is structured and presented to the learner. They live in assignment.md files and in the surrounding decisions about pacing, hinting, and feedback.

Recipes are numbered globally and continuously. Recipe 14 in Part II is the fourteenth recipe in the book; Recipe 27 in Part III is the twenty-seventh. Cross-references in the text use the recipe number.

Each recipe carries a Difficulty marker — Basic, Intermediate, or Advanced. Basic means the pattern is one or two moves and the failure modes are obvious. Intermediate means there are several moving parts and at least one non-obvious failure mode. Advanced means the pattern coordinates multiple hosts, services, or runtime states and is easy to wire up wrong on the first try.

The recipes assume working knowledge of the Instruqt platform — what a track is, what track.yml and config.yml do, what a lifecycle script is. If you need that grounding, start with the Field Guide's Part I.

Part II
Infrastructure Patterns
Recipes for declaring compute, networking, and storage. Use these when shaping the environment a learner will work inside.
Infrastructure Basic
Use when The track demonstrates command-line work that fits inside one Linux container with no other infrastructure.
Problem

A track for a CLI tool, a scripting walkthrough, or a self-contained tutorial does not need a database, a VM, or a cloud account. The standard temptation is to reach for a bigger shape than the problem requires — multi-container, container-plus-VM, full sandbox — because the bigger shapes feel more "real." They are slower to boot, they cost more lab-session compute, and they add failure modes the lesson does not teach. Single-container is almost always the right shape when one host is enough.

Solution

Declare one container in config.yml, give it a sensible name, point it at one of the platform's base images, and stop. Use a single terminal tab. Put any tooling the lesson needs into the track-level setup script.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

apt-get update -qq
apt-get install -y -qq jq tree
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
Gotchas

The platform ships two common base images. The lighter one is minimal — no cloud CLIs preinstalled. The heavier cloud-client image ships with aws, gcloud, az, kubectl, and helm already installed. Use the lighter image for pure-bash and pure-CLI lessons; the cost of having it slim is worth it. Use the heavier image when even one cloud CLI would otherwise need to be installed in setup.

The default shell when no cmd: is set on the terminal tab is /bin/bash running as root. That is fine for most labs. If the lesson teaches non-root workflows, see Recipe 29.

The memory: field is in megabytes. 1024 is enough for almost any single-container track that does not run a database, a JVM, or a model. Bump to 2048 only after measuring; bigger memory does not make a track snappier.

Variations

For tracks that demonstrate a tool the platform's base images do not ship, build a custom container image from one of the platform's base images and reference it from config.yml. Cache pulls with Recipe 20 to keep boot times low.

For tracks that need no custom infrastructure at all — a pure introductory tutorial, a documentation walkthrough — see Recipe 6 (sandbox preset adoption), which lets you skip config.yml entirely.

Infrastructure Basic
Use when The track needs a real Linux kernel, a display server, custom kernel modules, systemd, or any workload that does not run inside a container.
Problem

Containers are fast to boot, cheap to run, and sufficient for most tracks. They are not sufficient when the lesson involves systemd unit files, kernel-module loading, real network namespaces beyond what a container can simulate, or any GUI workload that requires a display server. Trying to teach those things inside a container is a long fight with workarounds; teaching them on a VM is the path of least resistance.

Solution

Declare one VM in config.yml, use a stock Ubuntu image (or a custom image if you have one), and provision tooling in the VM's track-level setup script. Surface a terminal tab pointing at the VM.

yaml config.yml
version: "3"
virtualmachines:
  - name: workstation
    image: ubuntu-2204
    machine_type: n1-standard-2
    shell: /bin/bash
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
Gotchas

VM boot takes 60–120 seconds compared to a container's 5–15. Set the user expectation accordingly: the first challenge's loading message should mention that the environment is provisioning. See Recipe 21 (bootstrap wait) for the pattern that protects setup scripts from running before the host is fully up.

VMs cost more per lab-session than containers. Default to a container; reach for a VM only when the track genuinely needs one.

The machine_type: field accepts standard GCP machine type strings. n1-standard-2 (2 vCPU, 7.5 GB RAM) is a sensible default for a single-VM workstation. Step up to n1-standard-4 only when you have measured a workload that needs it. Prefer machine_type: over the older memory: + cpus: pair — machine_type: is the canonical knob across modern tracks.

If the lesson involves Docker or kind running inside the VM, set nested_virtualization: true and pick a machine type with enough headroom. Drop nested virtualization when the track does not need it — it slows boot.

Variations

For tracks that need a desktop UI on the VM, see Recipe 17 (noVNC) or Recipe 16 (Guacamole). For tracks that need a single GUI application without a full desktop, see Recipe 18 (KasmVNC).

For tracks that need a VM running Docker — a meta-pattern where the VM hosts containers — see Recipe 19.

For tracks that need a pre-baked k3s cluster, see Recipe 38.

Infrastructure Intermediate
Use when The lesson involves two or more services that need to talk to each other over a network — typically an application and a backing service like a database, message queue, or cache.
Problem

A workstation container is enough for command-line teaching. Once the lesson involves a real application — an API the learner runs, a frontend they edit, a worker they restart — and that application talks to a database or a queue, a single container starts fighting itself. Running both processes in one container means juggling supervisord or background processes, and the failure modes leak into the lesson.

The clean shape is one process per container, wired together by hostname.

Solution

Declare each service as its own container in config.yml. Use container names as the hostname for inter-service communication. Set environment: keys on each container to pass connection strings or service URLs. The platform creates a Docker network that all declared containers share, so any container can reach any other by its declared name.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    ports: [8080]
    memory: 1024
    shell: /bin/bash
    environment:
      DB_HOST: db
      QUEUE_URL: redis://queue:6379
  - name: db
    image: postgres:16
    ports: [5432]
    memory: 1024
    private: true
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
  - name: queue
    image: redis:7-alpine
    ports: [6379]
    memory: 256
    private: true
Gotchas

Containers boot in parallel, not in dependency order. The workstation's setup script may start before postgres or redis is ready to accept connections. Always poll for readiness — pg_isready -h db for postgres, redis-cli -h queue ping for redis — before any setup that depends on the service. See Recipe 22 for the general wait-for-port pattern.

The learner's browser cannot resolve internal Docker hostnames. If the workstation serves a web UI on port 8080 and that UI's JavaScript calls fetch('http://api:5000/data'), the fetch fails because the browser is talking to the platform's traffic proxy, not the Docker network. Always use relative paths in client-side code; route everything through one service tab.

The ports: field declares which ports the platform should be aware of for service-tab purposes. It does not restrict which ports the container can use internally; containers can talk to each other on any port without declaring it. Mark backend-only services private: true so they do not appear as terminal tabs to the learner.

Variations

For a workstation paired specifically with postgres and a live web dashboard, see Recipe 14. For a workstation paired with a FastAPI service, see Recipe 12. For a workstation paired with a mock REST API and a branded CLI that talks to it, see Recipe 13.

When the second container is a dependency of the application rather than a peer (e.g., a sidecar log collector), still declare it as a peer container. Sidecar shapes are not a first-class concept on the platform.

Infrastructure Intermediate
Use when The lesson involves two or more VMs that need to coordinate — a leader/follower cluster, a workstation with a separate database VM, a multi-node Kubernetes setup.
Problem

Two VMs is a meaningfully different problem from one VM. The platform boots both in parallel, IPs are not stable in advance, and any setup that depends on one VM finding the other has to handle the case where the other is still coming up. Naive multi-VM tracks have setup scripts that fail intermittently because they assume hostname resolution works the moment the script starts.

Solution

Declare each VM in config.yml with a stable name. Use the environment: block on each VM to inject the hostnames of the other VMs. In setup, wait for the bootstrap sentinel file before doing anything that depends on the network being up. Coordinate cross-VM state via one of three patterns: agent variable for single-string values, a JSON broker sidecar for structured payloads, or cross-VM env passthrough by sourcing curl output (see Recipe 40).

yaml config.yml
version: "3"
virtualmachines:
  - name: leader
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
  - name: follower-1
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    environment:
      LEADER_URL: http://leader:8081
  - name: follower-2
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    environment:
      LEADER_URL: http://leader:8081
bash track_scripts/setup-follower-1
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/host-bootstrap-completed ]; do
  sleep 1
done

# Wait for the leader's broker, then source its env
until curl -fsS "$LEADER_URL/env" > /tmp/leader.env; do
  sleep 2
done
source /tmp/leader.env

# Now safe to join the cluster
join-cluster --leader "$LEADER_HOST" --token "$CLUSTER_TOKEN"
Gotchas

VMs in the same sandbox resolve each other by name, but only after the sandbox network is up. Always gate cross-VM calls on the bootstrap sentinel file plus a readiness probe against the target service.

VM-to-VM env-var injection via config.yml's environment: block is one-way at provisioning time. The leader does not know follower hostnames unless you hardcode them. Use the broker pattern for anything dynamic.

Tracks that provision external resources from one VM (cloud accounts, SaaS tenants) and consume them from another need a cross-VM hand-off. See Recipe 34 for the agent variable form and Recipe 40 for the broker form. They are not mutually exclusive.

Variations

For a multi-node Kubernetes cluster pre-baked into the platform's k3s VM image, see Recipe 38 — the image's auto-join logic removes most of the cross-VM coordination work.

For a workstation that needs a SaaS tenant pre-provisioned by a separate VM, JSON-broker the credentials (Recipe 40) rather than encoding them in agent variable strings.

Infrastructure Intermediate
Use when The lesson centers on operating a stateful service that lives on a VM (a database, a cluster control plane), but the learner's working environment is a container with the appropriate CLIs.
Problem

The learner needs a sane shell with cloud CLIs, the right kubectl version, the right helm version, and a pre-cloned repo of lab content. They also need to operate a real Linux service that requires a VM — a database engine, a kernel-tracing tool, a systemd-managed daemon. Putting both in one container is awkward; putting both on a VM is slow and gives the learner a less polished workstation experience.

The clean shape is a container as the workstation and a VM as the operand.

Solution

Declare the container with the workstation tooling and a VM with the service under study. Wire the container's environment: to expose the VM's hostname. In the container's setup, wait for the VM's service port before continuing. Surface a terminal tab on the container as the primary workspace; surface a service tab pointing at the VM if the lesson involves a UI.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
    environment:
      TARGET_HOST: target-vm
virtualmachines:
  - name: target-vm
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done

# Wait for the VM service port
until nc -z "$TARGET_HOST" 22; do sleep 2; done
Gotchas

The cloud-client container uses a different bootstrap sentinel than other images. On cloud-client, wait on gcp-bootstrap-completed. On the lighter shell image and on VMs, wait on host-bootstrap-completed. Mixing them up causes the wait to either return immediately (false ready) or never (hang).

The container's setup runs in parallel with the VM's setup. The workstation can be ready in 10 seconds while the VM is still 90 seconds out. Always poll for the VM's service port on the workstation side; do not assume the VM is up when the workstation's bootstrap completes.

If the VM's setup script needs a value from the container (rare but real), use Recipe 34 to write it via agent variable set in the container and read it on the VM.

Variations

For tracks where the VM is in fact a Kubernetes cluster (k3s, kind, kubeadm), the workstation pattern is identical — the container holds kubectl, helm, and a pre-merged kubeconfig pointing at the VM. See Recipe 38 for the pre-baked k3s shape.

For multi-VM stacks (e.g., a database cluster), pair the container with multiple VMs and use Recipe 40 for cross-VM coordination.

Infrastructure Basic
Use when The track is purely click-through against a managed UI and the platform offers a preset whose pre-installed tooling matches what the lesson needs.
Problem

For a click-through tutorial against Kibana, a managed-cluster console, or a temporal sandbox, every minute spent declaring and provisioning hosts is wasted. The lesson is the UI; the infrastructure underneath is overhead the learner does not see and does not benefit from. Authors who default to config.yml here pay startup time the lesson never claims back.

Solution

Use sandbox_preset: instead of config.yml. The preset gives a pre-configured set of hosts with relevant tooling already installed. Reference the preset's hostnames in your tab declarations. Skip track_scripts/ entirely.

yaml track.yml
slug: my-managed-ui-track
title: Click-through workshop
sandbox_preset: managed-vm-elastic-9
timelimit: 3600
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Kibana
    type: service
    hostname: kubernetes-vm
    port: 30001
  - title: Terminal
    type: terminal
    hostname: lab-host-1
Gotchas

The preset's hostnames are fixed by the preset, not by you. Look up the preset's hostname cheat sheet before writing your tab declarations — referring to a hostname the preset does not expose silently breaks the tabs without an obvious error.

Presets lock you out of track_scripts/, custom images, multi-VM topologies, secrets injection, and per-track networking. The moment the lesson needs any of those, you have outgrown the preset and should switch to config.yml. Trying to layer setup work onto a preset usually fails awkwardly — start with config.yml if there is any doubt.

Some presets pre-deploy a full software stack (a Kibana stack, an MCP environment) before the first challenge. Tracks built on those presets often need zero setup scripts at all — pure click-through against a pre-warmed UI. Check what your preset has already done before you write any setup work.

Variations

For a track that needs most of the preset's environment but with one custom tweak (a specific package, a per-learner credential), the answer is usually to abandon the preset and use config.yml. Trying to bolt a custom setup onto a preset is more work than starting from scratch.

For tracks where the preset gets you 90% of the way and the last 10% is challenge-level state, per-challenge setup scripts on a preset's host work — but only inside what the preset already exposes.

Infrastructure Intermediate
Use when The track teaches AWS — services, console navigation, IAM, infrastructure as code — and the learner needs a real AWS account they can log into.
Problem

Teaching AWS in a fake environment is unconvincing. Localstack covers parts of the surface, but the moment the lesson involves the AWS console, IAM roles, billable services, or any "click here in your account" step, the learner needs a real account. Provisioning one per learner ahead of time is impractical at scale; provisioning one inside the lab is what the platform exists for.

Solution

Declare an aws_accounts: resource in config.yml with the services and regions the lesson needs and the IAM policy the learner should hold. The platform mints a fresh account at sandbox start, injects credentials and an account ID into the workstation container, and reaps the account at cleanup.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
aws_accounts:
  - name: lab-account
    services: [s3, ec2, iam]
    regions: [us-east-1]
    iam_policy: |-
      {
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Action": ["s3:*", "ec2:Describe*"],
            "Resource": "*"
          }
        ]
      }
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done

# Surface the account ID into assignment-body interpolation
agent variable set ACCOUNT_ID "$INSTRUQT_AWS_ACCOUNT_LAB_ACCOUNT_ACCOUNT_ID"
agent variable set USERNAME "$INSTRUQT_AWS_ACCOUNT_LAB_ACCOUNT_USERNAME"
Gotchas

The account name in config.yml becomes a prefix on the injected env vars, uppercased with hyphens replaced by underscores. A name of lab-account becomes INSTRUQT_AWS_ACCOUNT_LAB_ACCOUNT_*. Forgetting to uppercase or mishandling the dashes is the single most common reason cloud creds appear empty in setup.

If the lesson needs both narrowly-scoped learner credentials and broader admin credentials for setup work, declare both iam_policy and admin_iam_policy in the resource block. Setup runs as admin; the learner's session uses the narrower role.

Referencing INSTRUQT_AWS_ACCOUNT_* env vars without an aws_accounts: block in config.yml returns empty strings. The platform only injects these when the resource is declared. Symptom: AWS CLI calls fail with empty-credential errors with no clear pointer to the missing block.

The lighter shell base image does not ship with the AWS CLI. If you use it with aws_accounts:, install the CLI in setup before any aws command runs. The cloud-client image already has it.

Variations

For "bring your own credentials" — a track where the learner connects to a customer-owned AWS account rather than a sandbox-provisioned one — declare AWS credentials as secrets: instead of aws_accounts: and reference them as standard AWS env vars. You lose automatic IAM scoping, SCP policies, and account cleanup, so use this only when the customer relationship requires it.

For tracks that need a managed Kubernetes cluster (EKS) on top of an AWS account, see Recipe 38's principles applied to a runtime-provisioned cluster: kick off eksctl create cluster in the background during setup and gate the first cluster-touching challenge on completion.

Infrastructure Intermediate
Use when The track teaches Azure — Resource Manager, AKS, the portal, Bicep, ARM — and the learner needs a real subscription.
Problem

Azure has the same fundamental constraint as AWS: meaningful lessons require a real subscription. Service principals, role assignments, the portal, and most managed services do not have credible offline equivalents. Per-learner subscription provisioning is what the platform manages.

Solution

Declare an azure_subscriptions: resource with the resource-provider namespaces the lesson needs and the roles the learner and the admin should hold. The platform mints a subscription, registers the providers, and injects ARM credentials into a workstation container.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
azure_subscriptions:
  - name: lab-sub
    services:
      - Microsoft.Compute
      - Microsoft.Network
      - Microsoft.Resources
      - Microsoft.Storage
    regions: [eastus]
    roles: [Reader, Contributor]
    admin_roles: [Owner]
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done

# az login can be transient immediately after subscription provisioning
for i in {1..12}; do
  az login --service-principal \
    -u "$ARM_CLIENT_ID" -p "$ARM_CLIENT_SECRET" --tenant "$ARM_TENANT_ID" \
    && break || sleep 10
done

az account set --subscription "$ARM_SUBSCRIPTION_ID"
agent variable set ACCOUNT_USER "$INSTRUQT_AZURE_SUBSCRIPTION_LAB_SUB_USERNAME"
Gotchas

When the subscription resource is present, the platform also injects the Terraform AzureRM-style aliases (ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID) on the cloud-client container. Use these directly in setup and Terraform without manual aliasing. AZURE_LOCATION is also injected with the configured region.

Service principal login can transiently fail in the first 30–60 seconds after the subscription is provisioned. Wrap az login in a retry loop with at least five attempts; for per-challenge setups extend to twelve. Tenant edge propagation is the cause and there is nothing you can do besides wait it out.

Always provision a cleanup script that calls az rest --method POST against the subscription cancel endpoint. Without this, the subscription persists after the lab ends and accrues cost. The "skip cleanup for costed resources" anti-pattern hits hardest with Azure subscriptions because they are large and expensive.

If your iam_policy block references a region not in the regions: list, the role assignment silently fails. Match regions to roles.

Variations

For tracks that need a fresh AKS cluster, provision it in setup using the auto-injected ARM credentials and add a wait loop for the cluster to be ready. For long boot times, gate the first cluster-touching challenge on a kubectl get nodes readiness probe.

For tracks delivered via the platform's Custom Resources mechanism (a Terraform module attached to the sandbox via the web UI), the credential injection pattern looks different — secrets appear as named variables rather than INSTRUQT_AZURE_SUBSCRIPTION_*. The decision rule: if your config.yml shows a secrets block with client_id, tenant_id, subscription_id, and billing-related fields but no azure_subscriptions: block, a Custom Resource is doing the provisioning behind the scenes.

Infrastructure Intermediate
Use when The track teaches GCP — Compute, GKE, Cloud Storage, IAM, the console — and the learner needs a real project.
Problem

GCP teaching requires a real project for the same reasons AWS and Azure teaching does. The console, IAM, billing-aware services, and most managed offerings have no fake equivalent worth using.

Solution

Declare a gcp_projects: resource with the API services the lesson needs and the IAM roles the learner and admin should hold. The platform creates the project, enables the APIs, and injects the project ID and a service account key.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
gcp_projects:
  - name: lab-project
    services:
      - compute.googleapis.com
      - storage.googleapis.com
      - iam.googleapis.com
    regions: [us-central1]
    roles:
      - roles/storage.objectUser
      - roles/compute.viewer
    admin_roles:
      - roles/owner
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done

# Service account key arrives base64-encoded; decode and clean up on exit
SA_JSON=$(mktemp)
trap "rm -f $SA_JSON" EXIT
echo "$INSTRUQT_GCP_PROJECT_LAB_PROJECT_ADMIN_SERVICE_ACCOUNT_KEY" \
  | base64 -d > "$SA_JSON"

gcloud auth activate-service-account --key-file="$SA_JSON"
gcloud config set project "$INSTRUQT_GCP_PROJECT_LAB_PROJECT_PROJECT_ID"

agent variable set PROJECT_ID "$INSTRUQT_GCP_PROJECT_LAB_PROJECT_PROJECT_ID"
Gotchas

The service account key is base64-encoded. You must decode before use. Forgetting to decode produces opaque "invalid credentials" errors from gcloud.

Trap-clean the credentials file on exit. A SA key sitting on disk after setup is a low-tier security smell at minimum; on shared images it is worse.

roles: accepts only valid GCP IAM role strings. roles/writer is not a real role; the correct write role for Cloud Storage is roles/storage.objectUser or roles/storage.objectCreator. Invented role names silently fail at sandbox provisioning. Always validate role strings against the GCP IAM predefined roles list.

When the same setup script must serve multiple tracks with different project names, resolve the SA key variable name dynamically with bash indirect expansion: SA_KEY="INSTRUQT_GCP_PROJECT_${PROJECT_NAME}_ADMIN_SERVICE_ACCOUNT_KEY"; echo "${!SA_KEY}" | base64 -d > "$SA_JSON". The ${!VAR} form reads the variable whose name is in VAR.

Variations

For tracks that need GKE, provision in setup with gcloud container clusters create. Gate the first cluster-touching challenge on a kubectl get nodes readiness probe.

For tracks that expose a private VM behind IAP, see Recipe 11 — the GCP project is the foundation; the IAP tunnel is the surface.

For cost-bounded GPU labs, set --provisioning-model=FLEX_START, --instance-termination-action=DELETE, and --max-run-duration=7200s on gcloud compute instances create. FLEX_START is GCP's preemptible mode; significantly cheaper but instances may be reclaimed.

Infrastructure Advanced
Use when The lesson genuinely involves more than one cloud — cross-cloud network peering, DNS, identity federation, multi-cloud Terraform — and pretending one cloud is enough would distort the lesson.
Problem

Multi-cloud lessons are rare and mostly unnecessary. Most "we need both clouds" requests resolve to "we need one cloud and a believable mock of the other." But some lessons — connecting an on-prem-style network across providers, federating IAM, demonstrating cross-cloud data movement — fundamentally need both. Writing those tracks requires juggling two sets of credentials, two cleanup paths, and one workstation that holds them all.

Solution

Declare both cloud resource blocks in config.yml. Use a single workstation container with the cloud-client image, which holds the CLIs for all major providers. In setup, log into each provider in sequence, surface useful identifiers via agent variable set for assignment-body interpolation, and wait for both cloud accounts to be ready before completing setup.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 2048
    shell: /bin/bash
aws_accounts:
  - name: aws-side
    services: [vpc, ec2, route53]
    regions: [us-east-1]
    iam_policy: |-
      { "Version": "2012-10-17", "Statement": [
        { "Effect": "Allow", "Action": "*", "Resource": "*" } ] }
azure_subscriptions:
  - name: azure-side
    services:
      - Microsoft.Network
      - Microsoft.Compute
      - Microsoft.Resources
    regions: [eastus]
    roles: [Contributor]
    admin_roles: [Owner]
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done

# AWS side
export AWS_ACCESS_KEY_ID="$INSTRUQT_AWS_ACCOUNT_AWS_SIDE_AWS_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$INSTRUQT_AWS_ACCOUNT_AWS_SIDE_AWS_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION=us-east-1
aws sts get-caller-identity > /dev/null

# Azure side, with retry against tenant-edge transient failures
for i in {1..12}; do
  az login --service-principal \
    -u "$ARM_CLIENT_ID" -p "$ARM_CLIENT_SECRET" --tenant "$ARM_TENANT_ID" \
    && break || sleep 10
done
az account set --subscription "$ARM_SUBSCRIPTION_ID"
Gotchas

Cleanup must handle both clouds. Forgetting to cancel the Azure subscription in cleanup leaks cost; forgetting to delete AWS resources leaks cost. Make cleanup explicit and idempotent for each provider.

Workstation memory needs to be larger than for single-cloud tracks. Two clouds means two CLIs, two contexts, larger Terraform state, and more concurrent processes during setup. 2048 MB is a reasonable starting point; 1024 MB will run out during multi-cloud Terraform applies.

Cross-cloud Terraform with explicit dependencies between providers can require phased applies. If Terraform's dependency graph cannot order resources correctly across clouds (e.g., AWS Transit Gateway must exist before Azure VNet peering), use sequential -target= applies to force the order. This is a workaround; restructure the modules to express dependencies correctly when possible.

Variations

For tracks that need a third cloud, the same shape extends — add a gcp_projects: resource and decode the SA key in setup. The pattern scales but the cleanup discipline becomes critical.

For tracks where one cloud is fully provisioned and the other is "bring your own credentials" (e.g., the customer's existing AWS account, but a fresh Azure subscription), mix the resource block with secrets: injection. Setup logs into both as appropriate.

Infrastructure Advanced
Use when The lesson needs a real GCP VM (perhaps a GPU machine, perhaps a runtime-provisioned shape) but the VM should not have a public IP, and the learner reaches it through Identity-Aware Proxy from a workstation container.
Problem

A track that teaches GPU work or runtime-provisioned VMs cannot use the platform's standard virtualmachines: block — that block is for VMs whose lifecycle is owned by the sandbox, not VMs the lesson itself provisions. A learner-launched VM also should not be exposed to the public internet; doing so is a security smell and requires firewall churn the lesson does not teach. The clean answer is IAP: the VM has no public IP, the workstation tunnels into it through Google's Identity-Aware Proxy.

Solution

Use a workstation container with a GCP project. From setup, provision the VM with gcloud compute instances create and no external IP. Open IAP tunnels mapping remote ports to local ports on the workstation. Surface terminal tabs that ssh -p <local-port> root@localhost and service tabs pointing at the local tunnel ports.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
gcp_projects:
  - name: lab-project
    services: [compute.googleapis.com, iap.googleapis.com]
    regions: [us-central1]
    roles:
      - roles/iap.tunnelResourceAccessor
      - roles/compute.viewer
    admin_roles: [roles/owner]
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

# (Decode SA key, gcloud auth — see Recipe 9.)

# Provision a private VM with no external IP
gcloud compute instances create lab-vm \
  --zone=us-central1-a \
  --machine-type=n1-standard-2 \
  --no-address \
  --image-family=ubuntu-2204-lts \
  --image-project=ubuntu-os-cloud

# Allow IAP CIDR through the firewall
gcloud compute firewall-rules create allow-iap \
  --direction=INGRESS \
  --source-ranges=35.235.240.0/20 \
  --allow=tcp:22,tcp:80

# Open IAP tunnels in the background
nohup gcloud compute start-iap-tunnel lab-vm 22 \
  --local-host-port=localhost:2222 \
  --iap-tunnel-disable-connection-check \
  > /tmp/ssh-tunnel.log 2>&1 & disown

nohup gcloud compute start-iap-tunnel lab-vm 80 \
  --local-host-port=localhost:8888 \
  --iap-tunnel-disable-connection-check \
  > /tmp/http-tunnel.log 2>&1 & disown

# Wait until the SSH tunnel is reachable
until nc -z localhost 2222; do sleep 2; done
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: VM Terminal
    type: terminal
    hostname: workstation
    cmd: ssh root@localhost -p 2222 -o StrictHostKeyChecking=no
  - title: Web App
    type: service
    hostname: workstation
    port: 8888
Gotchas

--iap-tunnel-disable-connection-check is mandatory when the target VM may still be booting. Without it, gcloud performs a synchronous connectivity probe that exits non-zero before the VM finishes startup, and your setup script aborts.

The IAP role must be on both the user role and the admin role for the project. Missing it from one of them produces opaque permission errors deep inside gcloud.

nohup ... & disown is the right pattern for tunnels within a single setup script. Do not rely on nohup to keep tunnels alive across challenge boundaries — the runner does not preserve nohup'd processes between challenges. If a tunnel must survive challenge transitions, install it as a systemd service on a real VM rather than backgrounding from a container.

Variations

For GPU labs that should be cost-bounded, provision the VM with --provisioning-model=FLEX_START --instance-termination-action=DELETE --max-run-duration=7200s. FLEX_START is significantly cheaper than standard but instances may be reclaimed.

For tracks that need the VM ports to be reachable from the learner's browser (not from the workstation container), expose the VM with a sandbox-public URL pattern instead of IAP. See Recipe 39.

Infrastructure Intermediate
Use when The track teaches API consumption or design and benefits from a live, browseable API surface rather than a static spec.
Problem

A REST API lesson where the learner only reads the spec is forgettable. A lesson where the learner can hit the API live, see real responses, and edit the running code is what closes the loop. Spinning up a FastAPI service in a separate container — with autogenerated /docs Swagger UI — gives the learner a hands-on surface with no extra teaching cost.

Solution

Declare a workstation container with the cloud-client image and a separate container running a FastAPI application. Wire the workstation to the API via environment:. Expose FastAPI's autogenerated Swagger UI as a service tab; expose the workstation terminal as a terminal tab.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    memory: 1024
    shell: /bin/bash
    environment:
      API_URL: http://api:8000
  - name: api
    image: example/fastapi-app:latest
    ports: [8000]
    memory: 512
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
  - title: API Docs
    type: service
    hostname: api
    port: 8000
    path: /docs
Gotchas

FastAPI's /docs page is interactive; the learner can fire requests from the browser. That is exactly the point — but it also means the API's CORS configuration must allow the platform's traffic-proxy origin. If the lesson involves the learner pasting a curl command into the terminal and clicking "Try it out" in the docs page, both code paths must work; CORS misconfiguration breaks the click-through path silently.

FastAPI's reload mode (uvicorn --reload) is convenient during authoring but expensive at runtime — it watches the filesystem and restarts on every change, including any file the learner edits. Disable reload in track-shipped images.

If the API depends on a database, wire the database as a third container and gate setup on database readiness (Recipe 22). The workstation should never assume the API is ready; always poll the API's health endpoint before any challenge that depends on it.

Variations

For a track where the API and a portal frontend live in one container on one port, see Recipe 25.

For a track where the API also needs a custom CLI wrapper (so the learner can use a branded acme-cli rather than raw curl), see Recipe 13.

For a track where the API is the lesson's entire surface and there is no terminal, drop the workstation container and surface the docs tab as the only working interface.

Infrastructure Intermediate
Use when The track teaches a SaaS product's API/CLI experience without depending on the real product, and the learner should feel the brand and the workflow.
Problem

When the lesson teaches a product's CLI ergonomics — "use acme-cli policy create to do X" — the cleanest experience is the real CLI talking to the real backend. The real backend is often unavailable, expensive, or stateful in ways the lesson does not want to manage. A mock REST API plus a branded CLI wrapper gives the same learner experience with predictable backend behavior.

Solution

Declare a workstation container that holds the branded CLI and a separate container running a mock REST API (FastAPI is convenient). The CLI is a small Python script using httpx and rich that points at the mock API via an env var. The mock API returns canned-but-realistic responses keyed by request shape.

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: example/branded-workstation:latest
    memory: 1024
    shell: /bin/bash
    environment:
      ACME_API_URL: http://api:8000
      PATH: /opt/acme-cli/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
  - name: api
    image: example/acme-mock-api:latest
    ports: [8000]
    memory: 512
python
# /opt/acme-cli/bin/acme — packaged into the workstation image
import argparse, os, sys
import httpx
from rich.console import Console
from rich.table import Table

console = Console()

def cmd_policy_list(args):
    r = httpx.get(f"{os.environ['ACME_API_URL']}/policies")
    r.raise_for_status()
    table = Table()
    table.add_column("ID", style="cyan")
    table.add_column("Name", style="green")
    for p in r.json():
        table.add_row(p["id"], p["name"])
    console.print(table)

def main():
    parser = argparse.ArgumentParser(prog="acme")
    sub = parser.add_subparsers(required=True, dest="resource")
    p = sub.add_parser("policy"); ps = p.add_subparsers(required=True, dest="verb")
    pl = ps.add_parser("list"); pl.set_defaults(func=cmd_policy_list)
    args = parser.parse_args()
    args.func(args)

if __name__ == "__main__":
    main()
Gotchas

A two-level argparse (<resource> <verb>) reads naturally and matches what most SaaS CLIs do. Avoid one-level argparse for anything beyond a toy CLI — learners struggle to discover commands without the resource grouping.

When the mock API returns an error, the CLI must surface it cleanly. Catch httpx.HTTPStatusError and render the response body in a colored panel rather than letting Python dump a traceback. A traceback-on-error CLI feels half-finished.

Bake the CLI into the workstation image rather than installing it via setup. Setup-time installs of Python packages are slow and add a class of failure at sandbox start. The image is the right boundary.

Variations

For tracks where the CLI also exposes interactive subcommands (Q&A flows, certification answer submission), the CLI grows but the structure stays the same. See Recipe 30 for the larger CLI-wrapper shape.

For tracks where the API and a portal page live together, route the CLI traffic and the portal traffic through one port (Recipe 25). Two ports for related surfaces is usually unnecessary.

Infrastructure Intermediate
Use when You want a track that pairs hands-on SQL with a live UI that reacts to the learner's work.
Problem

A pure terminal track teaching SQL is functional but flat. The learner runs CREATE VIEW, types a query, and trusts that something happened. There is no feedback loop that closes the gap between "I ran a SQL statement" and "I changed something visible."

Solution

Pair a workstation container (with psql and any other tooling the learner needs) with a postgres container as the database, then add a small web application — Flask or FastAPI — that polls the database every few seconds and renders the current state. Surface the dashboard as a service tab so it lives next to the terminal.

The dashboard becomes the feedback loop. When the learner creates a view, populates a table, or runs a stored procedure, the dashboard updates and the learner sees their work materialize.

A two-container config.yml:

yaml config.yml
version: "3"
containers:
  - name: workstation
    image: cloud-client
    ports: [5000]
    memory: 1024
    shell: /bin/bash
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app
  - name: db
    image: postgres:16
    ports: [5432]
    memory: 1024
    private: true
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app

A track-level setup script on workstation that waits for postgres, seeds the schema, and launches the dashboard:

bash setup script
#!/bin/bash
set -euxo pipefail

# Wait for postgres to accept connections
until pg_isready -h db -U app -d app; do sleep 1; done

# Seed the schema
psql "$DATABASE_URL" < /opt/seed/schema.sql

# Launch dashboard in background
cd /opt/dashboard
nohup python3 app.py > /var/log/dashboard.log 2>&1 &

# Poll for readiness so the service tab does not 502
until curl -fsS http://localhost:5000/healthz > /dev/null; do sleep 1; done

The assignment frontmatter exposes the dashboard alongside the terminal:

yaml assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
  - title: Dashboard
    type: service
    hostname: workstation
    port: 5000
Gotchas

The postgres image takes longer to be ready than to be running. Use pg_isready (which ships with the postgres client) rather than checking that the container is up — the container accepts TCP connections seconds before the database accepts queries. If your workstation image lacks pg_isready, fall back to a Python loop that catches psycopg2.OperationalError and retries.

The dashboard's JavaScript must use relative paths for any fetch calls. The learner's browser is talking to the platform's traffic proxy, not your Docker network, and any absolute URL pointing at an internal hostname will fail with no usable error message.

The setup script should poll the dashboard's own port for HTTP readiness before exiting. Without that final wait, the service tab loads while the Flask app is still binding the port and the learner sees a 502 on first render.

Variations

When the dashboard's purpose is to demonstrate a REST API rather than a UI, swap Flask for FastAPI and let the auto-generated /docs Swagger page serve as the interactive surface. The pattern collapses to "container + postgres + FastAPI service tab" with no separate frontend code at all (Recipe 12).

When the dashboard needs to host both a portal page and a backend API, serve them from the same hostname and the same port, routing by URL prefix on the server side. The proxy addresses one endpoint per service tab; collapsing two surfaces into one removes a class of cross-origin headaches (Recipe 25).

Infrastructure Basic
Use when The lesson centers on a SaaS UI (or a console running on the VM itself) and the learner needs an embedded browser tab pointed at that URL.
Problem

When the lesson is a click-through against a real product UI, a terminal tab is the wrong surface. A real browser running inside the lab — pointed at the right URL, with the right credentials surfaced — is what the lesson needs. Authors who build this by giving the learner an external link out of the lab break the embedded experience and lose all the contextual goodness the platform provides.

Solution

Declare the VM that hosts (or backs) the application. Declare a virtualbrowsers: entry pointing at the URL. Surface the virtual browser as a tab. Credentials are surfaced separately in the assignment body via agent variable interpolation.

yaml config.yml
version: "3"
virtualmachines:
  - name: app-vm
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    provision_ssl_certificate: true
    allow_external_ingress: [http, https]
virtualbrowsers:
  - name: app-ui
    url: https://app-vm.${_SANDBOX_ID}.instruqt.io
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: app-vm
  - title: Application
    type: browser
    hostname: app-ui
Gotchas

The browser tab type targets the name: of a virtualbrowsers: entry, not a hostname directly. Mismatching produces a tab that loads but does not point anywhere obvious.

Internal-hostname virtualbrowsers: URLs work — https://app-vm.${_SANDBOX_ID}.instruqt.io resolves through the platform's traffic proxy. Pair with provision_ssl_certificate: true on the VM. Useful when one VM serves multiple SaaS-style URLs/paths.

For external SaaS targets (Google Cloud console, AWS console, ServiceNow), a virtualbrowsers: entry can point at the product's URL directly. Credentials go in the assignment body via agent variable interpolation; do not include credentials in the URL query string — they will appear in browser history and server logs.

Variations

For a website tab pointed at a sandbox-public URL, use type: website with an explicit HTTPS URL. The website tab supports ${_SANDBOX_ID} interpolation in url:; the URL must be HTTPS or instruqt track push validation fails.

For tracks that need to expose multiple paths on the same VM (e.g., one URL for the admin UI, another for the user UI, a third for a metrics endpoint), add multiple virtualbrowsers: entries with the same hostname and different paths.

For a true Linux desktop rather than a SaaS UI, see Recipe 16 (Guacamole) or Recipe 17 (noVNC).

Infrastructure Advanced
Use when The learner needs a real Windows or Linux desktop inside the lab — for RDP-based tools, GUI apps that cannot live in a browser, or scenarios that require a Windows session.
Problem

Web-based desktops (noVNC, KasmVNC) cover most needs. They do not cover Windows; they do not cover scenarios that require RDP-specific features (clipboard sharing, printer redirection, multi-monitor); and they do not cover the multi-VM pattern where the learner switches between several remote desktops in one session. Guacamole is the right tool for those cases.

Solution

Run a Guacamole container next to the target VM(s). Configure Guacamole's user-mapping.xml with one connection per target. Surface Guacamole as a service tab whose path deeplinks the learner straight into a connection.

yaml config.yml
version: "3"
containers:
  - name: guac
    image: guacamole
    ports: [8080]
    memory: 1024
virtualmachines:
  - name: target-windows
    image: windows-server-2022
    machine_type: n2-standard-4
    shell: powershell
bash track_scripts/setup-guac
#!/bin/bash
set -euxo pipefail

mkdir -p /config/guacamole

cat > /config/guacamole/user-mapping.xml <<'EOF'
<user-mapping>
  <authorize username="guac_user" password="guac_password">
    <connection name="windows-target">
      <protocol>rdp</protocol>
      <param name="hostname">target-windows</param>
      <param name="port">3389</param>
      <param name="username">Administrator</param>
      <param name="password">${WIN_PASSWORD}</param>
      <param name="ignore-cert">true</param>
      <param name="resize-method">display-update</param>
      <param name="enable-font-smoothing">true</param>
    </connection>
  </authorize>
</user-mapping>
EOF

# Wait for the Windows VM's RDP listener
while ! nc -z target-windows 3389; do sleep 5; done
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Windows Desktop
    type: service
    hostname: guac
    port: 8080
    path: /guacamole/#/client/<connection-name>?username=guac_user&password=guac_password
Gotchas

Guacamole v2 path format differs from v1. v1 used /guacamole/#/?username=...&password=... (hash-fragment query string). v2 requires the connection name in the path: /guacamole/#/client/<connection-name>?username=...&password=.... Upgrading the Guacamole image from v1 to v2 without updating the path lands the learner on the connection-selection screen rather than the desktop.

Windows passwords from aws ec2 get-password-data may contain XML special characters. Escape before embedding in user-mapping.xml: a small escape_xml() shell helper that runs the password through sed for &, <, >, ", and '. Without escaping, the XML fails to parse and all connections appear broken.

Polling for RDP readiness (nc -z target 3389) is the right gate before declaring setup done. Windows VMs take longer than Linux VMs to expose RDP — 90+ seconds is normal — and a Guacamole tab that loads before RDP is ready shows a permanent connection error in the desktop pane.

Variations

For Linux desktops, swap RDP for VNC and point the Guacamole connection at port 5901 with <protocol>vnc</protocol> and the appropriate VNC password parameters.

For multi-VM scenarios where the learner switches between desktops, list multiple <connection> blocks under one <authorize> block. The Guacamole sidebar shows all connections; the learner picks.

For tracks that need a Windows-style desktop without a real Windows VM, run a Linux VM with nested virtualization and a Windows-desktop Docker container exposing RDP on port 3389. Cheaper than a real Windows VM but slower to boot and resource-constrained.

Infrastructure Advanced
Use when The learner needs a Linux GUI desktop in the browser, and Guacamole's overhead is not justified.
Problem

For a single Linux desktop session — XFCE, GNOME, KDE — Guacamole works but is heavy. It runs a separate container, requires user-mapping.xml, and adds a layer between the learner and the desktop. Sometimes you just want the desktop on the VM, accessible directly from a browser tab.

Solution

Install tightvncserver and a desktop environment on the VM. Wrap with noVNC for browser access over WebSocket. Expose via a website tab with a sandbox-public URL.

yaml config.yml
version: "3"
virtualmachines:
  - name: ubuntu-desktop
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    provision_ssl_certificate: true
    allow_external_ingress: [http, https, high-ports]
bash track_scripts/setup-ubuntu-desktop
#!/bin/bash
set -euxo pipefail

export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq xfce4 xfce4-goodies tightvncserver wget git

# Install noVNC
cd /opt
wget -q https://github.com/novnc/noVNC/archive/refs/tags/v1.3.0.tar.gz
tar xzf v1.3.0.tar.gz
git clone https://github.com/novnc/websockify noVNC-1.3.0/utils/websockify

# VNC password
mkdir -p /root/.vnc
echo "password" | vncpasswd -f > /root/.vnc/passwd
chmod 600 /root/.vnc/passwd

# Certs in BOTH locations
cp /root/self.crt /root/noVNC-1.3.0/utils/websockify/self.crt
cp /root/self.key /root/noVNC-1.3.0/utils/websockify/self.key

# Start VNC
vncserver :1 -geometry 1280x800 -depth 24

# Start noVNC over TLS
/opt/noVNC-1.3.0/utils/novnc_proxy \
  --vnc localhost:5901 \
  --cert /root/self.crt --key /root/self.key \
  --ssl-only --listen 8443 \
  > /var/log/novnc.log 2>&1 &
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Desktop
    type: website
    url: https://ubuntu-desktop.${_SANDBOX_ID}.instruqt.io:8443/vnc.html?host=ubuntu-desktop.${_SANDBOX_ID}.instruqt.io&port=8443
Gotchas

Certs must exist in two places — /root/ for whatever provisioned them, and inside noVNC-1.3.0/utils/websockify/ for the websockify binary. Forgetting the second copy yields an SSL handshake failure with no obvious symptom on the learner's side beyond "the page does not load."

provision_ssl_certificate: true mints a real cert via the platform — do not run certbot certonly at runtime. The Let's Encrypt rate limit (5 certs per registered domain per week) silently breaks tracks under load, and provision_ssl_certificate: true has no such limit.

The ?host=&port= query string on the URL is what makes noVNC auto-connect. Without it, the learner lands on the noVNC home page and has to fill in the form by hand.

Variations

For tracks that need a single GUI app rather than a full desktop, swap to KasmVNC with the app baked into a Docker container (Recipe 18). Lighter setup, simpler tab.

For tracks where the desktop is Windows, swap to Guacamole RDP (Recipe 16). noVNC for Windows is technically possible but rarely worth the friction.

Infrastructure Intermediate
Use when The learner needs one specific GUI application accessible in the browser — an IDE, a database client, a vendor desktop tool — without a full desktop environment.
Problem

A full desktop is overkill when the lesson needs only one app. Booting XFCE just to run a single IDE is slow, pulls in window-manager UI the lesson does not teach, and gives the learner a desktop they do not need. KasmVNC-based Docker images bundle one app with a streaming-VNC server, exposing the app directly through a browser tab.

Solution

Run a Linux VM with nested virtualization and Docker. Pull a KasmVNC-based image of the target app onto the VM in setup. Run the container with the app bound to the KasmVNC port. Surface a service tab pointing at the VM's KasmVNC port.

yaml config.yml
version: "3"
virtualmachines:
  - name: ide-vm
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    nested_virtualization: true
    provision_ssl_certificate: true
    allow_external_ingress: [http, https, high-ports]
bash track_scripts/setup-ide-vm
#!/bin/bash
set -euxo pipefail

# Install Docker
apt-get update -qq
apt-get install -y -qq docker.io
systemctl enable --now docker

# Pre-pull the IDE image (also do this in image baking — see Recipe 20)
docker pull example/kasm-ide:latest

# Project workspace; container UID may differ from root
mkdir -p /workspace
chown -R 1000:1000 /workspace

# Run the IDE
docker run -d --restart=unless-stopped \
  -p 8080:6901 \
  -v /workspace:/home/kasm-user/workspace \
  example/kasm-ide:latest

# Wait for HTTP readiness
until curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 \
  | grep -qE "^(200|302|401)$"; do sleep 3; done
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: IDE
    type: service
    hostname: ide-vm
    port: 8080
Gotchas

KasmVNC images run as a non-root user inside the container. The default UID is image-specific — common values are 911 and 1000. Run docker run --rm <image> id -u to discover it, or check the image's Dockerfile for USER. Mounting /workspace without chown-ing it to the right UID means the container cannot write, and the IDE silently fails to load.

Display server constraints: containers on the platform do not support virtual display servers (X11, Xvfb). KasmVNC images that need an X server will not run in a container — they require a VM with nested_virtualization: true. A web-based IDE like a browser-based code editor is fine in a container because it is a web server, not a GUI app.

Pre-pull the KasmVNC image during VM image baking, not at first-challenge setup. KasmVNC images are large (1–3 GB), and a first-challenge pull takes the lab past the start-up budget.

Variations

For a track that needs to patch an internal Chromium binary inside the KasmVNC image (e.g., add --no-sandbox), use Recipe 27's idempotent binary wrapper, applied via docker exec -u root against the running container.

For multiple GUI apps in one session, swap to Recipe 17 (noVNC desktop) or Recipe 16 (Guacamole). KasmVNC excels at one app per tab.

Infrastructure Intermediate
Use when The lesson needs the learner to interact with an interactive terminal program — a TUI, a game, a REPL — through a service tab rather than a standard terminal tab.
Problem

Terminal tabs are great for shell sessions. They are not great for programs that own the screen — ncurses interfaces, terminal-based games, REPLs with custom keybindings. The Instruqt terminal tab uses xterm.js with platform-specific input handling and may not pass every keystroke through cleanly. The clean answer is ttyd, a small program that wraps a terminal session in a WebSocket and serves it over HTTP, which the platform can frame as a service tab.

Solution

On a VM with Docker, run the target program inside a container with ttyd --writable wrapping it. Set the base path so multiple ttyd instances can coexist behind a reverse proxy. Add an nginx config on the VM that proxies each path to its ttyd instance with WebSocket support. Surface each as a service tab.

yaml config.yml
version: "3"
virtualmachines:
  - name: workstation
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    provision_ssl_certificate: true
    allow_external_ingress: [http, https, high-ports]
bash track_scripts/setup-workstation
#!/bin/bash
set -euxo pipefail

apt-get update -qq && apt-get install -y -qq docker.io nginx
systemctl enable --now docker

# Run the target program with ttyd
docker run -d --restart=unless-stopped --name=game \
  -p 7681:7681 \
  example/dungeon:latest \
  ttyd --writable --port 7681 --base-path /game/ \
       --client-option fontSize=20 \
       /usr/local/bin/dungeon

# nginx reverse proxy with WebSocket directives
cat > /etc/nginx/conf.d/ttyd.conf <<'NGX'
server {
  listen 8443 ssl;
  ssl_certificate /root/self.crt;
  ssl_certificate_key /root/self.key;

  location /game/ {
    proxy_pass http://127.0.0.1:7681;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400s;
    proxy_send_timeout 86400s;
  }
}
NGX

systemctl reload nginx
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Game
    type: service
    hostname: workstation
    port: 8443
    path: /game/
Gotchas

WebSocket termination through nginx requires proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection "upgrade". Missing any of these breaks the upgrade handshake silently — the page loads but the terminal never appears.

The default nginx 60-second timeout closes idle WebSocket connections mid-session. Set proxy_read_timeout and proxy_send_timeout to a long value (24 hours is common) — without this, learners get kicked out of the program after one minute of idle.

ttyd's --writable flag is required for any session the learner will interact with. Without it, the terminal is read-only and keystrokes do not reach the underlying program.

Variations

For multiple ttyd instances on one VM (a multi-pane retro arcade, a multi-REPL lesson), run each as its own container with its own port and add one location /name/ block per program in the nginx config. Pre-pull all the images in setup (Recipe 20).

For a single program that must run as a privileged process and cannot live in a container, skip Docker and run ttyd directly against the program on the host VM. The nginx reverse proxy is identical.

Part III
Technique Patterns
Patterns for lifecycle scripts, check scripts, and learner-facing behavior. Composable techniques that ride on top of any infrastructure.
Technique Basic
Use when The lesson uses Docker, and the learner's first docker compose up (or the setup script's first docker run) would otherwise pull large images at lab start.
Problem

The first docker compose up in a track that uses six images and is not pre-pulled takes longer than the rest of the lesson. Learners hit a long pause they cannot diagnose, the demo loses momentum, and the lab clock is eating budget while nothing happens.

Solution

Pre-pull every image the lesson uses in track-level setup, in parallel. Background each pull with &, then wait for all of them. The setup script bears the wait so the first interactive moment is fast.

bash setup script
#!/bin/bash
set -euxo pipefail

docker pull example/web:1.4 &
docker pull example/api:2.1 &
docker pull example/worker:2.1 &
docker pull postgres:16 &
docker pull redis:7-alpine &
docker pull nginx:1.27-alpine &
wait
Gotchas

Without wait, setup completes before the pulls finish. The first docker compose up still pays the full cost. The & backgrounds the pulls; the lone wait joins them.

Pre-pulling does not help when the image gets re-pulled later because it changed upstream. Pin tags to specific versions when the lesson should be deterministic; use mutable tags (:latest) only for content that genuinely should track upstream.

For very large images (multi-GB IDE-in-browser, ML stacks), bake them into the VM image rather than pulling at setup time. Image bake is a longer feedback loop for the author but a much shorter cold-start for the learner.

Variations

For tracks that pull from a private registry, declare the credentials in secrets: and docker login before the pull. Do not bake credentials into the setup script body.

For tracks with extremely large images and a tight start-up budget, pre-pull at image bake time. The Packer/image-bake pipeline does the work once for every learner who uses the image.

Technique Basic
Use when Any track-level setup script that runs on a host that has just booted.
Problem

Setup scripts can begin before the host's bootstrap pipeline has finished writing the helper binaries (fail-message, set-status, agent variable) onto disk. Calls to those binaries fail with cryptic "command not found" errors. The race is timing-dependent and intermittent.

Solution

Block on the bootstrap sentinel file at the top of every track-level setup script.

bash setup script
#!/bin/bash
set -euxo pipefail

# On VMs and the lighter shell container image:
until [ -f /opt/instruqt/bootstrap/host-bootstrap-completed ]; do
  sleep 1
done
bash
# On the cloud-client image, the sentinel is different:
until [ -f /opt/instruqt/bootstrap/gcp-bootstrap-completed ]; do
  sleep 1
done
Gotchas

The sentinel file path differs by image. On VMs and on the lighter shell base image, the file is host-bootstrap-completed. On the cloud-client base image, it is gcp-bootstrap-completed. Both live under /opt/instruqt/bootstrap/. Mixing them up causes the wait to either return immediately (false ready) or never (hang).

For Hashi-style hardened setups, pair the file wait with a port check on the local agent: ss -tuln | grep -q ':15779'. The port check catches the case where the file exists but the agent has not fully come up.

Variations

Per-challenge setup does not need this wait — by the time challenge N runs, the host has been up for a while. Reserve the wait for track-level setup.

For tracks where setup may run before some other prerequisite (a peer VM is reachable, an external API has provisioned a tenant), layer additional waits on top of the bootstrap sentinel. They are not substitutes for each other.

Technique Basic
Use when Setup needs to talk to a service that is starting concurrently — a database container, an API service, an internal HTTP endpoint.
Problem

A naive "this should be ready by now" assumption fails the moment the service takes longer to start than usual. CI is flaky, lab cold-starts surface the timing, and the failure mode is a setup script that fails on a randomly timed first run and works on retry.

Solution

Poll the target's port (or a specific health endpoint) until it answers, with a sane sleep and a sane retry budget. Distinguish between local-service readiness (flat retry, short total budget) and shared SaaS readiness (exponential backoff with longer budget).

bash
# Local service — flat retry
for i in $(seq 1 30); do
  if curl -fsS http://127.0.0.1:8080/health > /dev/null; then
    echo "Ready"
    break
  fi
  echo "Retry $i/30"
  sleep 2
done
bash
# Postgres — accept TCP plus accept queries
until pg_isready -h db -U app -d app; do sleep 1; done

# As a fallback when pg_isready is unavailable
until PGPASSWORD=app psql -h db -U app -d app -c 'SELECT 1' > /dev/null 2>&1; do
  sleep 2
done
bash
# Generic HTTP — accept any "responsive" status
until curl -s -o /dev/null -w "%{http_code}" http://localhost:8080 \
  | grep -qE "^(200|302|401)$"; do sleep 3; done
Gotchas

Container readiness is not service readiness. A container can accept TCP connections seconds before the service inside it accepts requests. Always poll the service-level signal (pg_isready, /health, an authenticated probe), never just the port being open.

A "readiness" check that accepts only HTTP 200 misses serving-but-redirecting and serving-but-auth-required cases. Accept 200, 302, and 401 as "the server is responding." That is what the regex ^(200|302|401)$ is for.

For shared external SaaS APIs (a vendor service many learners share), use exponential backoff to avoid thundering-herd retries. For local services on localhost, flat retry is simpler and adequate.

Variations

For tracks where the target is a Kubernetes API server, use kubectl proxy and poll /healthz. More reliable than kubectl cluster-info, which can return success before the API is fully ready.

For tracks where readiness depends on a Kubernetes Event rather than a port (Helm chart provisioning), poll for the event with kubectl wait --for=condition=... rather than tailing logs.

Technique Basic
Use when Setup must launch a long-running daemon (a dashboard, an MCP server, a load generator) that should keep running during the challenge.
Problem

A background process launched naively from setup leaves stdout and stderr attached to the script's session. Once the script exits, the process can lose its file descriptors and die. Even when it survives, debugging its behavior later is hard because the output disappeared.

Solution

Redirect stdout and stderr to a log file, run with nohup, and disown. The process keeps running, the logs are inspectable, and the script can exit cleanly.

bash setup script
#!/bin/bash
set -euxo pipefail

cd /opt/dashboard
nohup python3 app.py > /var/log/dashboard.log 2>&1 &
disown

# Wait until the dashboard responds before completing setup
until curl -fsS http://localhost:5000/healthz > /dev/null; do sleep 1; done
Gotchas

nohup keeps the process alive across the script's exit, not across challenge boundaries. The platform's runner does not preserve nohup-ed processes between challenges. If a process must survive a challenge transition, install it as a systemd service.

disown removes the job from the script's job table so the script can exit cleanly. Without it, the script may wait at exit for the backgrounded job, wasting boot budget.

Polling for HTTP readiness after launch is the difference between a service tab that works on first render and one that 502s for the first 5–10 seconds of the lab.

Variations

For services that must survive challenge boundaries, write a systemd unit (Restart=always, RestartSec=5s) and systemctl enable --now. Survives reboots, restarts on crashes, and is what you want for any persistent service.

For Windows VMs, use Register-ScheduledTask with New-ScheduledTaskTrigger -AtStartup instead of nohup. Bash conventions do not apply on Windows.

Technique Basic
Use when The track teaches code that should appear in the learner's editor at challenge start, the codebase is small (under a hundred or so files), and an external repo or bucket would add a class of failure the lesson does not need.
Problem

External content repos break in two predictable ways: the GitHub clone fails because the network blip ate the request, or the upstream changed in a way that breaks the lesson. A small project that lives entirely inside the setup script avoids both.

Solution

Write the project tree from setup using bash heredocs. One cat > path block per file. mkdir -p directories beforehand. Use single-quoted heredoc terminators ('EOF') for files where bash should not expand variables (Dockerfiles, sample code that contains $VAR literally).

bash setup script
#!/bin/bash
set -euxo pipefail

mkdir -p /workspace/myapp/{src,tests}

cat > /workspace/myapp/src/app.py <<'EOF'
from flask import Flask
app = Flask(__name__)

@app.route("/")
def home():
    return "hello, world"
EOF

cat > /workspace/myapp/Dockerfile <<'DOCKERFILE'
FROM python:3.11-slim
WORKDIR /app
COPY src/ /app/
RUN pip install flask
CMD ["python", "app.py"]
DOCKERFILE

cat > /workspace/myapp/docker-compose.yml <<EOF
services:
  web:
    build: .
    ports:
      - "5000:5000"
    environment:
      ENV_NAME: ${ENV_NAME:-dev}
EOF

# Pre-build so the first compose up is a no-op
cd /workspace/myapp
docker compose build
Gotchas

Single-quoted terminators ('EOF') disable variable expansion in the heredoc body. Use them for anything that contains $VAR literally — Dockerfiles, code that interpolates at its own runtime, environment-variable examples. Use unquoted terminators when bash should expand variables before writing.

Pre-build images in setup so the learner's first docker compose up is a near-instant no-op. A learner who waits 30 seconds for the first build loses momentum.

For codebases over a hundred files, switch to a clone-from-public-repo pattern (Recipe 41 for private repos, plain git clone for public). Heredocs scale poorly past that size.

Variations

For tracks that need the project to be re-creatable on each challenge (a "reset" pattern between challenges), wrap the heredoc block in a per-challenge setup script and add a rm -rf of the prior state. The reset must be idempotent.

For tracks where the heredoc would be very long, bake the project into the custom VM image instead. Image bake is a one-time cost; setup is paid every lab session.

Technique Intermediate
Use when The lesson involves both a portal page (HTML the learner sees) and a backend API (which the portal's JavaScript calls), and the two should appear to the learner as one surface.
Problem

Splitting a portal page and its API into two service tabs means two places where the URL might be wrong, two places to authenticate, and a guarantee of cross-origin pain when the portal's fetch calls go to the API's URL. The clean answer is one service tab, one port, and routing inside the server.

Solution

Serve both the portal and the API from the same hostname and the same port. Route by URL prefix on the server side: /api/* goes to the API handler, everything else is the portal HTML. The platform addresses one endpoint per service tab; collapsing two surfaces into one removes the cross-origin class of bugs.

python
# /opt/portal/main.py — single FastAPI app exposing portal + API on one port
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse

app = FastAPI()

@app.get("/api/healthz")
def healthz():
    return {"ok": True}

@app.get("/api/items")
def items():
    return JSONResponse([{"id": "1", "name": "alpha"}])

# Everything else: serve the portal SPA
app.mount("/", StaticFiles(directory="/opt/portal/static", html=True))
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Portal
    type: service
    hostname: workstation
    port: 8080
Gotchas

JS fetch() calls in the portal must use relative paths (/api/items), not absolute URLs pointing at internal hostnames. The platform's traffic proxy proxies the one declared port; absolute URLs to internal Docker hostnames fail with no usable error.

Routing order matters in single-app frameworks. Mount static files last (or with a lower priority than API routes); otherwise the static-file handler swallows /api/* requests.

When the API and portal need different memory or runtime characteristics, do not collapse them onto one process — split the containers and accept the cross-origin work. The collapse is for cases where one process can serve both cleanly.

Variations

For a track where the portal is purely static and only one API endpoint is needed, an Nginx server with a location /api/ proxy block is simpler than a single Python app.

For a track where multiple APIs back the portal (e.g., REST plus WebSocket), keep them in one app or one Nginx config — the goal is one declared port to the platform.

Technique Intermediate
Use when Several check (or solve) scripts share validation logic — file existence, command output regex, table-row counts — and copy-pasting the helpers across scripts has started to drift.
Problem

Copy-pasted assertion code in five check scripts will diverge in five different ways within two weeks. The fix is to put the helpers in one place and import them from each script.

Solution

In track-level setup, write helper Python modules into the system site-packages directory. Import them from any check or solve script.

bash track_scripts/setup-workstation
cat > /usr/lib/python3/dist-packages/instruqt_tests.py <<'PY'
import os, re, subprocess

def test_file_exists(path):
    assert os.path.isfile(path), f"missing: {path}"

def test_string_in_file(path, needle):
    with open(path, encoding="utf-8") as f:
        assert needle in f.read(), f"{needle!r} not in {path}"

def test_command(cmd, expected_pattern):
    out = subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout
    assert re.search(expected_pattern, out), f"output of `{cmd}` did not match {expected_pattern!r}"
PY
bash 03-challenge/check-workstation
#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from instruqt_tests import test_file_exists, test_command
test_file_exists("/workspace/output.txt")
test_command("psql -At -c 'SELECT count(*) FROM users'", r"^[3-9]$")
PY
Gotchas

Writing into /usr/lib/python3/dist-packages/ requires root, which setup has by default. Do not write into /usr/local/lib/... for shared helpers — it works but is harder to discover.

Keep helpers small and assertion-shaped. The point is to remove duplication across check scripts; the moment the helper grows test orchestration logic, switch to a real test runner (pytest, mocha) instead.

Solve scripts can use the same module — run_as_user(), add_to_bash_history(), "do the lesson in five lines" helpers — without duplicating the bash behind them.

Variations

For tracks in JavaScript-heavy ecosystems, the same idea applies with mocha + chai, with the helper bundle written into a per-track tests directory and each check script invoking it via a single mocha invocation. Pick the language that matches the ecosystem.

For tracks where check scripts must run inside a heredoc'd Python block (no separate file), inline the helper code rather than importing — only when the check logic is small enough to inline cleanly.

Technique Basic
Use when A setup script needs to wrap a system binary — to add flags, intercept arguments, or enforce a sandbox mode — and the script may run more than once.
Problem

The standard pattern for wrapping a binary is to rename the original out of the way, then write a shell script in its place that calls the renamed original with extra arguments. For example, a track that needs Chromium to run with --no-sandbox inside a constrained container will rename /usr/bin/chromium to /usr/bin/chromium-real and write a new /usr/bin/chromium that exec's chromium-real --no-sandbox "$@".

This works perfectly the first time. On the second run, the rename moves the wrapper to chromium-real, and the new wrapper ends up calling itself. The result is infinite recursion the first time anyone invokes the binary, often manifesting as a process explosion that fills the host's process table before anything else gets a chance to run.

Solution

Guard the rename. Only move the original out of the way if it has not already been moved.

bash setup script
#!/bin/bash
set -euo pipefail

# Only rename if the real binary hasn't already been moved
if [ ! -f /usr/bin/chromium-real ]; then
  mv /usr/bin/chromium /usr/bin/chromium-real
fi

# Write (or overwrite) the wrapper
cat > /usr/bin/chromium <<'EOF'
#!/bin/bash
exec /usr/bin/chromium-real --no-sandbox "$@"
EOF
chmod +x /usr/bin/chromium
Gotchas

The wrapper itself is safe to rewrite on every run — the danger is only in the rename. Putting the rename behind the [ ! -f ] guard is the entire fix.

If the original binary is a symlink rather than a file, [ ! -f ] still works because -f follows symlinks, but mv will move the symlink itself. If you need the underlying target moved instead, resolve first with readlink -f.

This pattern composes with anything that needs an idempotent overwrite-once: replacing a config file, swapping a default editor, or substituting a library. The same [ ! -f <backup> ] guard pattern applies in each case.

Variations

When the wrapping is conditional on runtime context — for example, only patching the binary inside a specific container — combine the guard with a feature-detection check rather than running the patch unconditionally.

When the binary lives inside a container that is already running and needs to be patched post-start, use docker exec -u root <container> bash -c '...' and apply the same idempotency guard inside the inner shell.

When multiple wrappers chain (a binary wrapped, then re-wrapped by a higher-level tool), maintain a separate -real suffix per layer (chromium-real, chromium-real-real) and document the chain in a comment at the top of the setup script. The guard logic still applies per layer.

Technique Basic
Use when Setup must inject environment variables (or aliases, or a custom prompt) that should be present in both login shells and plain interactive shells.
Problem

/etc/profile.d/*.sh is sourced by login shells. ~/.bashrc is sourced by interactive non-login shells. A learner whose terminal opens with cmd: su - <user> gets a login shell; a learner whose terminal opens without that gets an interactive non-login shell. Putting the env var in only one place means it is missing in the other context — usually discovered when the lesson does not work and the env var turns out to be unset.

Solution

Write to both. /etc/profile.d/<lesson>.sh for login shells, and the user's .bashrc for plain interactive shells. Same content, two destinations.

bash setup script
#!/bin/bash
set -euxo pipefail

# Login shells (when the terminal opens via su -)
cat > /etc/profile.d/lab-env.sh <<'EOF'
export ACME_API_URL="http://api:8000"
export PS1='acme:\w$ '
EOF
chmod +x /etc/profile.d/lab-env.sh

# Interactive non-login shells (terminal tab without su -)
cat >> /root/.bashrc <<'EOF'
export ACME_API_URL="http://api:8000"
export PS1='acme:\w$ '
EOF
Gotchas

A custom PS1 belongs in .bashrc rather than profile.d. The login-shell flow already handles PS1, but plain interactive shells will inherit the default unless .bashrc overrides it.

Avoid ANSI color codes inside PS1. Color works fine in the terminal tab's xterm.js, but when setup echoes $PS1 for diagnostics, the codes corrupt the platform's debug log.

For a cleaner branded prompt, omit username and hostname: \u@\h adds noise and makes copy-pasted commands ugly. \w for the current directory plus $ (or # if you must signal root) is enough.

Variations

For tracks that create a non-root user (Recipe 29), write to both /etc/profile.d/ and the user's home .bashrc, not root's. Mismatching the user's home directory leaves the env vars unset when the user logs in.

For tracks that need a function (not just a variable) in both contexts, define the function in the profile.d file and serialize it into .bashrc with type <funcname> | tail -n +2.

Technique Basic
Use when The lesson teaches realistic Linux workflow — sudo, file permissions, multi-user awareness — and running everything as root would distort the lesson.
Problem

Running setup as root is fine; running the learner's shell as root makes most "real Linux" lessons feel staged. The lesson teaches sudo apt-get install; the lab gives the learner a shell where sudo is invisible. Things you can do without thinking are not the things the lesson is about.

Solution

In setup, create a non-root user, copy whatever dotfiles and env vars they need into the user's home, and either set the terminal tab to cmd: su - <user> or set set-workdir to the user's home so plain bash opens there.

bash setup script
#!/bin/bash
set -euxo pipefail

useradd -m -s /bin/bash iggy
usermod -aG sudo iggy
echo "iggy ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/iggy

# Carry env over from root
cp /etc/profile.d/lab-env.sh /home/iggy/.bashrc-lab
echo 'source ~/.bashrc-lab' >> /home/iggy/.bashrc
chown -R iggy:iggy /home/iggy
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
    cmd: su - iggy
Gotchas

cmd: su - iggy opens a login shell for iggy. Env vars set only in root's .bashrc will not appear. Use Recipe 28's dual-write pattern, or copy the env into the user's home explicitly.

For a stock Debian or Ubuntu image whose default user is ubuntu (not iggy) and the lesson actually wants root, set cmd: sudo su on the terminal tab. Minimal ceremony, escalates cleanly.

VM-level shell: and per-tab cmd: are not equivalent. On VMs, shell: su - <user> at the VM level applies to every terminal tab without per-tab repetition. On containers, use per-tab cmd: su - <user>. Mixing them is allowed but noisy; pick one and stick with it within a track.

Variations

For tracks where multiple users matter (a lesson on file permissions across owners), create each user explicitly and let the learner switch between them as part of the lesson rather than via cmd: magic.

For tracks where the non-root user must exist on a fresh boot in a Windows VM, use PowerShell with Rename-Computer and account-creation cmdlets — the bash idiom does not apply.

Technique Intermediate
Use when The track teaches a SaaS product's CLI experience and the real CLI is unavailable, slow, or requires a connection the lab does not have.
Problem

A real branded CLI talking to a mock backend is the cleanest learner experience for a SaaS-CLI lesson. You get the brand, the verbs, the panels, the colored output — without the real backend's latency or rate limits. The trick is making the wrapper feel like a real CLI rather than a thin shim.

Solution

Write a Python CLI that uses httpx for HTTP and rich for tables and panels. Two-level argparse (<resource> <verb>). Catch HTTP errors and connection errors with specific handlers; render the response body as a colored panel rather than letting Python dump a traceback. Bake into the workstation image; do not install at setup time.

python
#!/usr/bin/env python3
import argparse, os, sys
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.table import Table

console = Console()

def client():
    return httpx.Client(
        base_url=os.environ["ACME_API_URL"],
        headers={"Authorization": f"Bearer {os.environ.get('ACME_TOKEN', '')}"},
        timeout=10.0,
    )

def run(fn):
    try:
        fn()
    except httpx.HTTPStatusError as e:
        console.print(Panel(e.response.text, title=f"[red]API {e.response.status_code}[/red]"))
        sys.exit(1)
    except httpx.ConnectError:
        console.print("[red]Could not reach the API. Is the service tab open?[/red]")
        sys.exit(1)
    except Exception:
        console.print_exception()
        sys.exit(1)

def policy_list(_):
    def f():
        with client() as c:
            r = c.get("/policies"); r.raise_for_status()
            t = Table(title="Policies")
            t.add_column("ID", style="cyan")
            t.add_column("Name", style="green")
            for p in r.json():
                t.add_row(p["id"], p["name"])
            console.print(t)
    run(f)

def main():
    p = argparse.ArgumentParser(prog="acme")
    sub = p.add_subparsers(dest="resource", required=True)
    pol = sub.add_parser("policy")
    psub = pol.add_subparsers(dest="verb", required=True)
    psub.add_parser("list").set_defaults(func=policy_list)
    args = p.parse_args()
    args.func(args)

if __name__ == "__main__":
    main()
Gotchas

Single-file CLIs scale further than people expect — five resources × four verbs in one file is fine. Splitting into a package adds a layer of overhead the lab does not need.

Rich's Console().print_exception() formats unhandled errors clearly. Reach for it when an unexpected error surfaces; the default Python traceback wall is hostile to a learner who sees it for the first time.

Bake the CLI into the workstation image and prepend its bin directory to PATH in config.yml's environment: block. Setup-time pip install is slow and adds cold-start failure modes the lesson does not need.

Variations

For interactive CLIs that capture multi-line input (Q&A flows, certification answer submission), pair the CLI with a read -p loop that writes to a JSON file consumed by check scripts.

For tracks where the vendor publishes a Python SDK, the CLI can be a thin wrapper over the SDK rather than over raw HTTP. Same shape; different inner module.

Technique Basic
Use when The lesson asks the learner to edit a starter file, and providing a completed reference implementation as an opt-in hint would help without spoiling.
Problem

A code lesson where the learner gets stuck at minute 30 has two bad outcomes: they give up, or they look up the answer outside the lab and break the experience. Providing a completed reference inside the lab — but not by default — gives them a way out without taking the lesson away.

Solution

Two code tabs in the assignment frontmatter. The first points at the learner's editable file. The second points at a completed reference file. Setup writes both files. The reference is for self-recovery only; the assignment body should not encourage opening it before attempting the work.

yaml 03-challenge/assignment.md frontmatter
tabs:
  - title: Terminal
    type: terminal
    hostname: workstation
  - title: Your code
    type: code
    hostname: workstation
    path: /workspace/acme_report.py
  - title: Hint (reference)
    type: code
    hostname: workstation
    path: /workspace/acme_report_complete.py
bash 03-challenge/setup-workstation
#!/bin/bash
set -euxo pipefail

cat > /workspace/acme_report.py <<'EOF'
# TODO: implement summarize_orders(orders) returning a dict of region -> total
def summarize_orders(orders):
    pass
EOF

cat > /workspace/acme_report_complete.py <<'EOF'
def summarize_orders(orders):
    out = {}
    for o in orders:
        out[o["region"]] = out.get(o["region"], 0) + o["total"]
    return out
EOF
Gotchas

Order matters in the tab list. Put the editable file before the reference. Learners scan tabs left-to-right; the editable one being first signals which is "yours."

If the reference is a complete spoiler of the lesson, the lesson is teaching the wrong shape. Refactor the lesson into smaller steps, each with its own reference, rather than one big reveal.

Per-challenge setup overwrites the editable file at challenge start. Learners who navigate away and back lose their work — usually fine for short challenges, painful for long ones. For long challenges, write the file only on first run ([ -f /workspace/acme_report.py ] || cat > ...).

Variations

For tracks where the reference lives in a documentation tab rather than a code tab, use type: code with a directory path; the editor renders a folder tree. Useful when the reference is a multi-file project.

For tracks where the hint should be progressive (one step at a time), see Recipe 51 (layered hint) — the hint tab is just one form factor of the broader pattern.

Technique Intermediate
Use when A challenge depends on state that a prior challenge was supposed to leave behind, and the lesson should still work if the learner skipped or the prior solve did not persist.
Problem

Tracks with skipping_enabled: true are useful: a learner who is short on time can jump to the part they care about. But the moment Challenge 3 assumes that Challenge 2 created a database row, a skip breaks the lesson silently. Equally, a learner who restarted the lab partway through may have lost the prior state without noticing.

Solution

In each challenge's setup-{hostname}, query for the expected state and re-create only what is missing. Idempotent and fast on the common case (state is already there); recovers transparently when it is not.

bash setup script
#!/bin/bash
set -euxo pipefail

# Make sure the customer the learner was supposed to create in challenge 1 exists
EXISTS=$(psql -At "$DATABASE_URL" -c \
  "SELECT 1 FROM customers WHERE email='alice@example.com'")

if [ -z "$EXISTS" ]; then
  psql "$DATABASE_URL" <<'SQL'
  INSERT INTO customers (name, email) VALUES ('Alice', 'alice@example.com');
SQL
fi
Gotchas

The check must be cheap. Setup runs every time the challenge opens; an expensive defensive check makes the lab feel slow without the learner understanding why.

Defensive setup is for correcting missing state, not for normalizing it. If the learner did Challenge 2 correctly but slightly differently, the defensive setup should leave that alone, not reset it. Otherwise the learner watches the lab undo their work.

Pair with full-chain solve scripts (Recipe 33). Defensive setup helps when the learner is moving forward; full-chain solve scripts are the safety net when an auto-skip happens.

Variations

For tracks where defensive setup needs to query an external SaaS rather than a local database, use the same shape with the SaaS's API. Wrap with retries and a sane timeout — external dependencies can be slow in ways internal ones are not.

For tracks where the defensive setup is large (re-creates dozens of records), consider whether the lesson's challenge boundaries are in the right place. Large defensive setup often signals that the boundary is too far from the natural seam.

Technique Intermediate
Use when The lab supports skipping, and the auto-solve script for any challenge must work whether or not prior challenges' solves ran.
Problem

A solve script that assumes "Challenge 2 already ran" breaks the moment a learner skipped Challenge 2. The skip generates a hidden lab error: the auto-solve hits a missing prerequisite and exits non-zero, and the lab tells the learner the lesson is broken when in fact the lesson is fine and the test infrastructure is not.

Solution

Each solve script re-creates the entire prerequisite chain from the beginning, idempotently, before doing the current challenge's work. If state already exists, skip; if not, create.

bash setup script
#!/bin/bash
set -euxo pipefail

# Rebuild prerequisites from challenge 1
psql "$DATABASE_URL" <<'SQL'
CREATE TABLE IF NOT EXISTS customers (id serial, name text, email text);
INSERT INTO customers (name, email) SELECT 'Alice', 'alice@example.com'
  WHERE NOT EXISTS (SELECT 1 FROM customers WHERE email='alice@example.com');
SQL

# Now do challenge 3's actual solve work
psql "$DATABASE_URL" <<'SQL'
CREATE OR REPLACE VIEW recent_orders AS
SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days';
SQL
Gotchas

CREATE TABLE IF NOT EXISTS and INSERT ... WHERE NOT EXISTS are the standard idempotent shapes for SQL. Use them everywhere; a solve script that fails because a table is already there is hostile to the test pipeline.

Idempotent solve scripts are large. That is fine. Their job is to be the safety net, not the elegant lesson. Optimize for "this works on every entry point" over "this is short."

When the prerequisite chain is genuinely long, factor the rebuild into a helper script that every solve script calls. One canonical rebuild beats five copies of the same logic.

Variations

For tracks with cloud-side state, the rebuild must use the cloud API and tolerate "resource already exists" errors. Wrap each create with || true only when the create's failure mode is genuinely "already there"; do not blanket-suppress errors.

For tracks where the rebuild is expensive (provisioning a full sandbox tenant), the right answer may be to forbid skipping rather than to write a heavy solve script.

Technique Basic
Use when A value computed on one host needs to appear on another host, or in the assignment body, and the value is a single string that fits cleanly into a key/value pair.
Problem

Cross-host coordination tempts authors to invent fancy mechanisms — shared file mounts, custom REST endpoints, environment-variable gymnastics. For single-string values, the platform already has the right primitive: agent variable set/get. Use it before reaching for anything heavier.

Solution

agent variable set KEY VALUE on one host writes the value into the platform's per-sandbox key/value store. agent variable get KEY on any other host (or any other lifecycle script) reads it. The same value is also available in assignment.md via [[ Instruqt-Var key="KEY" hostname="..." ]].

bash track_scripts/setup-leader
#!/bin/bash
set -euxo pipefail

CLUSTER_TOKEN=$(generate-cluster-token)
agent variable set CLUSTER_TOKEN "$CLUSTER_TOKEN"

# Surface for assignment-body interpolation
agent variable set DASHBOARD_URL "https://leader.${_SANDBOX_ID}.instruqt.io:8443"
bash track_scripts/setup-follower
#!/bin/bash
set -euxo pipefail

CLUSTER_TOKEN=$(agent variable get CLUSTER_TOKEN)
join-cluster --token "$CLUSTER_TOKEN"
markdown assignment.md
<!-- 01-first-challenge/assignment.md body -->
Open the dashboard at [[ Instruqt-Var key="DASHBOARD_URL" hostname="leader" ]].
Gotchas

_SANDBOX_ID is a shell variable, not an agent variable. To surface it via [[ Instruqt-Var ]], write it explicitly: agent variable set SANDBOX_ID "$_SANDBOX_ID". The most common "Instruqt-Var renders nothing" symptom traces back to this.

The [[ Instruqt-Var ]] form supports interpolation in essentially every string field — cmd:, url:, path:, frontmatter teaser, body markdown (including inside fenced code blocks), notes text, button labels, table cells. No escaping needed inside backticks or code blocks.

For multi-field structured values, agent variable's single-string limit forces ugly serialization. Switch to a JSON broker sidecar (Recipe 40) when the payload has structure.

Variations

For credentials that should be surfaced to the body (passwords, tokens) use the same pattern with care: the [[ Instruqt-Var ]] will render the value into the rendered HTML, where the learner's browser sees it. That is fine for per-sandbox credentials; do not use it for long-lived shared secrets.

For values that need to flow from cleanup back into a cleanup-of-another-host, the same agent variable get works during cleanup — useful for retrieving the cloud-side resource ID set during setup so cleanup can delete it.

Technique Intermediate
Use when A setup script needs to run with admin GCP credentials for some operations and with the learner's narrower credentials for others, and switching context cleanly matters.
Problem

gcloud auth activate-service-account switches the active context globally. If setup activates the admin SA, runs an admin operation, and then forgets to switch back, every subsequent gcloud call (including those the learner sees in the terminal) runs as admin. That is wrong on its face and dangerous if the learner is supposed to hit permission errors as part of the lesson.

Solution

Use CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE to scope the admin credentials to a single command's environment, rather than activating them globally. The override is per-process; the global gcloud context is never disturbed.

bash setup script
#!/bin/bash
set -euxo pipefail

SA_JSON=$(mktemp)
trap "rm -f $SA_JSON" EXIT
echo "$INSTRUQT_GCP_PROJECT_LAB_PROJECT_ADMIN_SERVICE_ACCOUNT_KEY" | base64 -d > "$SA_JSON"

# Admin operation, scoped via env var
CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE="$SA_JSON" \
  gcloud projects add-iam-policy-binding "$PROJECT_ID" \
  --member="user:$LEARNER_EMAIL" \
  --role="roles/storage.objectViewer"

# Learner-facing operations continue with the default (learner) credentials
gcloud config list account
Gotchas

The override env var is a per-process scope. Setting it once and running multiple commands inside that exported scope is fine; setting it inline as VAR=val gcloud ... keeps the scope to one invocation. Pick the form that matches what you need.

Trap-clean the temp credentials file. A SA JSON sitting on disk after setup is a smell at minimum and worse on shared images.

The same pattern applies for AWS (AWS_ACCESS_KEY_ID=... inline before a command) and Azure (sourcing a per-context profile). The mechanism varies; the principle — scope admin credentials narrowly — is the same.

Variations

For tracks where the admin SA must persist across multiple setup steps but should still be cleared at end of setup, set the override at the script's top, do all admin work, then unset and rm before exit.

For tracks where setup never needs to switch back (the learner does not interact with gcloud directly), global activation is fine. The override pattern is for the case where the contexts must coexist.

Technique Basic
Use when A learner-facing check script validates several things, and "something is wrong" is not actionable feedback.
Problem

A check script that combines five assertions into one [ X && Y && Z && ... ] test gives the learner one bit of information when they fail: the whole thing is broken. They have no idea which of the five things is wrong. Frustration follows.

Solution

Split into several independent assertions. After each, call fail-message with a specific remediation hint and exit 1. The learner gets actionable feedback at the first thing that breaks.

bash setup script
#!/bin/bash
set -euxo pipefail

# Check 1: user exists
if ! psql -At "$DATABASE_URL" -c \
  "SELECT 1 FROM pg_user WHERE usename='reader'" | grep -q '^1$'; then
  fail-message "Create the 'reader' user with: CREATE USER reader WITH PASSWORD '...';"
  exit 1
fi

# Check 2: user has SELECT on the table
if ! psql -At "$DATABASE_URL" -c \
  "SELECT has_table_privilege('reader', 'orders', 'SELECT')" | grep -q '^t$'; then
  fail-message "Grant the reader user SELECT on orders: GRANT SELECT ON orders TO reader;"
  exit 1
fi

# Check 3: user does NOT have DELETE
if psql -At "$DATABASE_URL" -c \
  "SELECT has_table_privilege('reader', 'orders', 'DELETE')" | grep -q '^t$'; then
  fail-message "The reader user must not have DELETE — REVOKE it from the role."
  exit 1
fi

set-status "All three permission checks passed."
Gotchas

fail-message and set-status availability is image-dependent. The platform's published images always have them; raw upstream Docker images may not. Guard with || true if you cannot be sure: fail-message "..." || true.

For VMs using stock cloud images (Ubuntu 22.04, 24.04, Rocky 9), fail-message is available — the platform's agent installs during boot. For very old custom images or for raw container images with stripped PATH, fall back to echo plus exit 1.

When running multiple checks under set -euo pipefail, an unguarded subprocess substitution that fails will silently exit the script. Wrap subprocess assignments with explicit handlers: OUT=$(... ) || { fail-message "..."; exit 1; }.

Variations

For long check scripts, count the assertions automatically and prefix each message with [Check N/$TOTAL]. The learner sees their progress as they fix things.

For self-paced workshops where any failed check should not block progress, swap the pattern for "always pass, surface a set-status describing what was checked." Recipe 37 covers the graded-rubric variant.

Technique Advanced
Use when The track is a graded certification or assessment, and binary pass/fail is too coarse — partial credit and visibility into what was missed matter.
Problem

A certification check that exits 0 or 1 with no detail is hostile to learners who almost passed. They get no signal on what to study. They retry blindly. The right shape is a rubric: each criterion is worth points, the script tallies, and the result is presented with what was earned and what was missed.

Solution

In the check script, build an array of criteria with point values and assertion logic. Tally as the script runs. Emit a per-criterion pass/fail line plus a final score line. Color-code the total. Exit 0 only at full marks (or whatever the pass threshold is); otherwise fail-message with the missing items.

bash setup script
#!/bin/bash
set -uo pipefail

TOTAL=100
SCORE=0

assertion() {
  local label=$1; local points=$2; local cmd=$3
  if eval "$cmd" > /dev/null 2>&1; then
    echo "[PASS] $label (+$points)"
    SCORE=$((SCORE + points))
  else
    echo "[FAIL] $label"
  fi
}

assertion "Reader user exists" 10 \
  "psql -At \"$DATABASE_URL\" -c \"SELECT 1 FROM pg_user WHERE usename='reader'\" | grep -q '^1$'"

assertion "Reader has SELECT on orders" 30 \
  "psql -At \"$DATABASE_URL\" -c \"SELECT has_table_privilege('reader','orders','SELECT')\" | grep -q '^t$'"

assertion "Reader lacks DELETE on orders" 30 \
  "! psql -At \"$DATABASE_URL\" -c \"SELECT has_table_privilege('reader','orders','DELETE')\" | grep -q '^t$'"

assertion "Audit table exists" 30 \
  "psql -At \"$DATABASE_URL\" -c \"SELECT 1 FROM pg_tables WHERE tablename='audit_log'\" | grep -q '^1$'"

echo "----"
echo "Score: $SCORE / $TOTAL"

if [ "$SCORE" -eq "$TOTAL" ]; then
  set-status "Score: $SCORE/$TOTAL — all criteria passed."
  exit 0
else
  fail-message "Score: $SCORE/$TOTAL — review the [FAIL] criteria above and try again."
  exit 1
fi
Gotchas

The point values should map to the lesson's relative weight, not be uniform. A criterion that is "the whole point of the challenge" should be worth more than a setup detail.

For partial credit at scale (10+ criteria), wrap the rubric in a single Python heredoc rather than chaining bash assertions. The Python version is easier to read and easier to extend.

Reserve scored rubrics for tracks that are explicitly graded. Putting a rubric on a workshop track frustrates learners who skipped a section deliberately.

Variations

For tracks where partial credit is also captured externally (a learning platform's gradebook), POST the score from the check script to the platform's API at the end of the run. Use INSTRUQT_AUTH_TOKEN to authenticate.

For tracks where the rubric should also produce a per-criterion remediation list, append failed criteria to a file the learner can read and reference. The check script becomes both grader and study guide.

Technique Intermediate
Use when The track needs a multi-node Kubernetes cluster, every minute of cold-start budget matters, and a pre-baked k3s VM image is available on the platform.
Problem

Bootstrapping a Kubernetes cluster from scratch in setup — install kubeadm, init the control plane, generate join tokens, distribute, run kubeadm join on each worker — takes minutes the lab does not have. It also adds five places where setup can fail. For a track where the cluster is the foundation, not the lesson, that is wasted complexity.

Solution

Use a platform-published k3s VM image. The image ships with a built-in bootstrap that auto-joins the control plane from an env var: set K3S_CONTROL_PLANE_HOSTNAME on each worker VM in config.yml's environment: block, and the worker joins on boot with no setup script.

yaml config.yml
version: "3"
virtualmachines:
  - name: control-plane
    image: platform-published-k3s
    machine_type: n2-standard-4
    shell: /bin/bash
  - name: worker-1
    image: platform-published-k3s
    machine_type: n2-standard-4
    shell: /bin/bash
    environment:
      K3S_CONTROL_PLANE_HOSTNAME: control-plane
  - name: worker-2
    image: platform-published-k3s
    machine_type: n2-standard-4
    shell: /bin/bash
    environment:
      K3S_CONTROL_PLANE_HOSTNAME: control-plane
bash track_scripts/setup-control-plane
#!/bin/bash
set -euxo pipefail

until [ -f /opt/instruqt/bootstrap/host-bootstrap-completed ]; do sleep 1; done

# Wait for all workers to join
TARGET=2
until [ "$(kubectl get nodes --no-headers | grep -c worker-)" -ge "$TARGET" ]; do
  sleep 5
done

kubectl get nodes
Gotchas

The control-plane VM's hostname must match the value of K3S_CONTROL_PLANE_HOSTNAME on every worker. A typo silently produces a single-node cluster and the workers spin trying to join an unreachable name.

Do not call kubeadm on these images. The image's bootstrap is not kubeadm — running kubeadm on top fights the existing daemon. If you need a kubeadm-flavored cluster instead, do not use the pre-baked k3s image.

The control plane is ready before the workers. If the first challenge depends on workers being present, gate on the node count, not on kubectl version or cluster-info.

Variations

For tracks that need a single-node cluster (control plane plus workloads on the same node), declare only the control plane and skip the worker VMs. The image runs as a single-node cluster by default.

For tracks needing kind or k3d (Kubernetes-in-Docker), Recipe 19's Docker-on-VM shape is the substrate. Pre-baked k3s images are the right choice when you want a real multi-VM cluster without the kubeadm dance.

Technique Intermediate
Use when A web service running on a VM must be reachable from the learner's browser as an HTTPS URL, and embedding it inside a service tab is not the right shape.
Problem

A service tab embeds the target URL in an iframe served from the platform's traffic proxy. That works for most service UIs, but it does not work when the target sets strict frame-blocking headers that cannot be stripped, when the service must be opened in a new browser window for OAuth-style flows, or when the lesson explicitly demonstrates the URL the learner would use in production. The right answer is a sandbox-public URL: the VM's port is reachable on a public DNS name with TLS provisioned out-of-band.

Solution

Set provision_ssl_certificate: true and allow_external_ingress: [http, https, high-ports] on the VM. The platform mints a TLS cert and routes <vm-name>.${_SANDBOX_ID}.instruqt.io:<port> to the VM's port. Surface as a type: website tab with the URL templated; the URL is also paste-friendly into the assignment body for "share this link" flows.

yaml config.yml
version: "3"
virtualmachines:
  - name: app-vm
    image: ubuntu-2204
    machine_type: n2-standard-4
    shell: /bin/bash
    provision_ssl_certificate: true
    allow_external_ingress: [http, https, high-ports]
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: App
    type: website
    url: https://app-vm.${_SANDBOX_ID}.instruqt.io:8443
markdown assignment.md
<!-- assignment body -->
Share this URL with a colleague: `https://app-vm.[[ Instruqt-Var key="SANDBOX_ID" hostname="app-vm" ]].instruqt.io:8443`
Gotchas

type: website URLs must be HTTPS. instruqt track push validates this and rejects HTTP URLs.

_SANDBOX_ID is a shell variable, not an agent variable. To interpolate it into the assignment body, run agent variable set SANDBOX_ID "$_SANDBOX_ID" in setup and reference as [[ Instruqt-Var key="SANDBOX_ID" ... ]]. URL fields like tabs[].url: accept the ${_SANDBOX_ID} form directly because the platform substitutes shell-style placeholders before the field is consumed.

The cert is provisioned for standard ports (80/443). Custom high ports get the same cert but you must explicitly add high-ports to allow_external_ingress for the route to be open. Forgetting that produces "site unreachable" with no obvious cause.

Variations

For tracks that need a sandbox-public URL on a non-standard subdomain (e.g., kibana.${_SANDBOX_ID}... rather than <vm-name>.${_SANDBOX_ID}...), the same pattern applies — the VM name in the subdomain must match the name: field in config.yml.

For OAuth flows that require the redirect URL to be reachable from the learner's browser, the sandbox-public URL is what the OAuth client should be configured with. Set the URL via agent variable set and inject it into the OAuth client config in setup.

Technique Advanced
Use when Multi-VM tracks need to share a structured payload — not just one string — between hosts, and the payload includes endpoints, credentials, or other multi-field state.
Problem

agent variable set is one-string-per-key. Stuffing a multi-field payload into one string by serializing JSON works but is ugly to read on the consuming side and impossible to extend. Cross-VM environment: injection is one-way at provisioning time; it cannot carry a value computed on one VM at runtime to another VM. When the payload is structured, the right answer is a tiny HTTP sidecar that returns JSON.

Solution

On the leader VM, run a small Python HTTP server that serves a single JSON document on a high port. On consumer VMs, curl the leader's URL and parse with jq. The leader is the source of truth; consumers re-fetch as needed.

bash track_scripts/setup-leader
#!/bin/bash
set -euxo pipefail

# Provision the external resource
ENDPOINT=$(provision-tenant)
TOKEN=$(provision-token)

# Write the payload
cat > /tmp/broker-payload.json <<EOF
{
  "endpoint": "$ENDPOINT",
  "token": "$TOKEN",
  "username": "lab-user",
  "password": "$(openssl rand -hex 16)"
}
EOF

# Serve on port 8081
nohup python3 -m http.server 8081 \
  --directory /tmp > /var/log/broker.log 2>&1 &
disown
yaml config.yml
# config.yml — make the leader hostname known to followers
virtualmachines:
  - name: leader
    image: ubuntu-2204
    machine_type: n2-standard-2
    shell: /bin/bash
  - name: follower-1
    image: ubuntu-2204
    machine_type: n2-standard-2
    shell: /bin/bash
    environment:
      LEADER_URL: http://leader:8081/broker-payload.json
bash track_scripts/setup-follower-1
#!/bin/bash
set -euxo pipefail

until curl -fsS "$LEADER_URL" > /tmp/payload.json; do sleep 2; done

ENDPOINT=$(jq -r .endpoint /tmp/payload.json)
TOKEN=$(jq -r .token /tmp/payload.json)
join-cluster --endpoint "$ENDPOINT" --token "$TOKEN"
Gotchas

python3 -m http.server is fine for small private payloads inside a sandbox. Do not expose the broker outside the sandbox network, and do not put long-lived secrets in it — the sandbox boundary is the security boundary. agent variable for sensitive single-string values, broker for structured payloads.

Followers must wait for the broker. The leader's broker comes up after some setup work; the follower's setup may run faster. A retry loop on the follower side is the right gate.

When the structure of the payload changes between v1 and v2 of the track, version the URL path or add a version: key to the JSON. Older follower setups break silently if the payload structure changes underneath them.

Variations

For tracks where the payload should be cross-cloud (one cloud's leader broadcasting to another cloud's follower), use a small managed bucket (S3, GCS) instead of an HTTP sidecar. The shape is the same; the transport is different.

For sandbox-internal use where security matters, add a token check on the broker and pass the token via agent variable set/get. Keep the broker dumb otherwise.

Technique Advanced
Use when Lesson content lives in a private GitHub repo, the track only needs a subset of the repo, and committing a long-lived deploy key into the track is the wrong shape.
Problem

Cloning a whole repo for one lesson is wasteful and slow. Putting a Personal Access Token in git clone https://<token>@github.com/... lives forever in repo history and works around a security control rather than respecting it. The clean answer is a GitHub App installation token minted at setup time, used once, and discarded.

Solution

In setup, mint a short-lived GitHub App installation token using a private key delivered via Instruqt secrets. Use the token in a sparse-checkout against the private repo. Discard the token before exit; do not write it to disk past the clone.

bash setup script
#!/bin/bash
set -euxo pipefail

##### START WORKSHOP_DEPLOYMENT_PARAMETERS #####
# DO NOT MODIFY THIS SECTION — track-test automation scrapes these markers.
agent variable set TRACK_REPO "lesson-content"
agent variable set TRACK_PATH "lessons/foo"
agent variable set TRACK_BRANCH "main"
##### END WORKSHOP_DEPLOYMENT_PARAMETERS #####

get_github_token() {
  local app_id="111111"
  local install_id="22222222"
  local pem="$GH_APP_PRIVATE_KEY"   # delivered via secrets:

  local now=$(date +%s)
  local exp=$((now + 540))

  local header=$(printf '{"alg":"RS256","typ":"JWT"}' | base64 -w0 \
    | tr -d '=' | tr '/+' '_-')
  local payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$now" "$exp" "$app_id" \
    | base64 -w0 | tr -d '=' | tr '/+' '_-')
  local sig=$(echo -n "$header.$payload" \
    | openssl dgst -sha256 -sign <(echo "$pem") -binary \
    | base64 -w0 | tr -d '=' | tr '/+' '_-')
  local jwt="$header.$payload.$sig"

  curl -sS -X POST \
    -H "Authorization: Bearer $jwt" \
    -H "Accept: application/vnd.github.v3+json" \
    "https://api.github.com/app/installations/$install_id/access_tokens" \
    | jq -r .token
}

TOKEN=$(get_github_token)

mkdir -p /opt/lesson
cd /opt/lesson
git init -q
git remote add origin "https://x-access-token:${TOKEN}@github.com/your-org/${TRACK_REPO}"
git config core.sparsecheckout true
echo "${TRACK_PATH}/" > .git/info/sparse-checkout
git pull --depth=1 origin "$TRACK_BRANCH"

unset TOKEN
Gotchas

The bash JWT signer relies only on openssl, base64, tr, jq, and curl — all present on standard workstation images. The signing pattern is reusable for any RS256-JWT API (GCP service accounts, Auth0, custom OIDC); GitHub App is the most common case but not the only one.

The deployment-parameters comment block is not just a comment. Some track-test pipelines scrape these markers to discover the source repo. If you copy this pattern, keep the START/END comments verbatim.

For public repos, skip all of the above. git init; git remote add ... https://github.com/<org>/<repo>; git config core.sparsecheckout true; ... is enough. The JWT machinery is only needed when the repo is private.

Variations

For lesson content that lives in a private GitLab rather than GitHub, the same JWT pattern applies with GitLab's token-minting API; the URL and headers differ but the pattern is identical.

For tracks where the same content is fetched by many parallel labs, consider hosting the content as a tarball in a managed bucket and downloading at setup. Faster cold-start and one less moving part.

Technique Intermediate
Use when A setup or check script's failure mode is hard to diagnose from set -e alone, or a check script provisions per-check state that must be cleaned up regardless of pass/fail.
Problem

set -e aborts on any non-zero exit but does not say which line. Diagnosing a 200-line setup that exits non-zero with no other output is a slog. Equally, a check script that creates temporary state to validate something can leak that state on failure paths if cleanup is only in the happy-path tail.

Solution

Use trap '... ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}' ERR for line-numbered error reporting. Use trap cleanup EXIT to guarantee cleanup runs on every exit path — normal, error, signaled. Pair with set -E so the ERR trap fires for failures inside functions and subshells too.

bash setup script
#!/bin/bash
set -Eeuo pipefail

trap 'echo "ERR at ${BASH_SOURCE[0]}:${LINENO}: ${BASH_COMMAND}" >&2' ERR

# (long setup script)
deploy-stuff
configure-stuff
validate-stuff
bash
# Check script that provisions and tears down temp state
#!/bin/bash
set -Eeuo pipefail

cleanup() {
  ctm env delete check-env > /dev/null 2>&1 || true
}
trap cleanup EXIT

# Idempotent re-add in case prior run left it behind
ctm env delete check-env > /dev/null 2>&1 || true
ctm env add check-env "$AAPI_ENDPOINT" "$AAPI_TOKEN"
ctm env set check-env

# (validation logic)
Gotchas

set -E is the difference between an ERR trap that fires for failures inside functions and one that does not. Without -E, a failure in a called function bypasses the trap and you are back to "set -e ate my error message."

For check scripts, set -o history; export HISTFILE=/root/.bash_history; history -r is needed if the check uses history to validate engagement. Non-interactive bash defaults to history off. Without the explicit enable, history returns nothing regardless of what the learner typed.

PS4='+$0[$LINENO]+' plus set -x makes the platform's debug log readable. Every traced line shows the script name and line number. Without it, a long setup script's xtrace looks like a wall of opaque commands.

Variations

For check scripts on raw upstream Docker images that lack fail-message, replace the helper calls with echo plus exit 1. The error-trap pattern is identical; the messaging primitive differs.

For tracks with a private build pipeline that runs all check scripts in CI, the trap-based error trace is what makes those CI failures actionable. Keep it on by default.

Technique Intermediate
Use when A SaaS or self-hosted UI returns strict frame-blocking headers (X-Frame-Options, Content-Security-Policy) and you want to embed it inside a service tab.
Problem

Many SaaS UIs ship X-Frame-Options: DENY or a strict CSP. Embedded in an iframe, they refuse to render. Two layered remedies exist: the platform's service tab supports per-tab header overrides, and a local reverse proxy (Caddy, nginx) can strip the headers before the platform sees the response. socat is the wrong tool here — it is a TCP-level proxy and cannot touch HTTP headers.

Solution

Try the per-tab override first. It is one block in assignment.md and zero lines of code. If the upstream does not respect the request override (some SaaS UIs ignore client hints), add a Caddy or nginx reverse proxy on the workstation and point the service tab at the proxy port.

yaml assignment.md frontmatter
# 01-first-challenge/assignment.md frontmatter — per-tab override
tabs:
  - title: Admin UI
    type: service
    hostname: app-vm
    port: 8443
    custom_request_headers:
      x-strip-frame-options: "1"
    custom_response_headers:
      content-security-policy: "frame-ancestors *"
      x-frame-options: ""
bash
# track_scripts/setup-workstation — Caddy fallback
cat > /etc/caddy/Caddyfile <<'EOF'
{
  auto_https off
}

:8080 {
  reverse_proxy https://upstream:8443 {
    header_down -X-Frame-Options
    header_down -Content-Security-Policy
    transport http {
      tls
      tls_insecure_skip_verify
    }
  }
}
EOF
systemctl restart caddy
yaml assignment.md frontmatter
# Repointed service tab
tabs:
  - title: Admin UI
    type: service
    hostname: workstation
    port: 8080
Gotchas

The Caddy auto_https off block is mandatory at the top of the Caddyfile. Without it, Caddy attempts Let's Encrypt provisioning on internal-only hostnames and silently fails to start.

socat does not work for stripping HTTP headers. Several teams hit this blind alley before reaching for Caddy or nginx. socat has a legitimate use for pure TCP forwarding; the anti-pattern applies only to the HTTP-header case.

For nginx, the equivalent directives are proxy_hide_header Content-Security-Policy and proxy_hide_header X-Frame-Options. The CSP rewrite is also one option — set add_header Content-Security-Policy "frame-ancestors *" after stripping.

Variations

For self-hosted UIs you control, fix the headers at the source (the application's config) rather than rewriting in transit. The Caddy/nginx pattern is for upstreams you cannot touch.

For tracks where the iframe-embed is OAuth-related (Kibana with SSO), the auth flow may also need a header rewrite — the proxy pattern still works, but the OAuth redirect URLs must point at the proxy hostname, not the upstream.

Part IV
Author Patterns
Patterns for assignment.md, pacing, hinting, and feedback. The reader-facing surface of a track.
Author Basic
Use when A challenge is more than two short instructions long and the learner benefits from a clear "where am I" signal.
Problem

A challenge body that is one wall of prose loses learners. Where does step 2 begin? Did I do step 3? A long block of running text without visual seams is a usability failure even when the prose itself is good.

Solution

Break the body into numbered steps with a horizontal-rule separator between each. Render the step header in a colored <h2> so it stands out. Close the challenge with a single completion line marked with a checkmark.

markdown
---

[Optional pre-work: prerequisites, downloads, links]

---

<h2 style="color: #37C980;">Step 1: Configure the database connection</h2>

Open the connection settings and set `DATABASE_URL` to the value below.

echo "DATABASE_URL=postgres://app:app@db:5432/app" >> /workspace/.env

text

---

<h2 style="color: #37C980;">Step 2: Run the migration</h2>

Run the migration script and verify the schema is up.

cd /workspace && ./scripts/migrate.sh

text

---

<h2 style="color: #37C980;">Step 3: Verify connectivity</h2>

Connect with `psql` and list the tables.

psql "$DATABASE_URL" -c '\dt'

text

---

✅ The database is configured. Click **Check** to continue.
Gotchas

Open the body with a horizontal rule only if there is a pre-work section before Step 1. Otherwise start directly with the first step header. A leading rule with nothing before it looks like a stray separator.

Steps are sequential and do not skip numbers. Skipping (jumping from Step 2 to Step 4 because Step 3 was deleted) confuses learners and signals careless authoring.

The closing line uses . Reserve for inline "wrong path" markers within step content. Mixing the two erodes the convention.

Variations

Setext-style headings (Title\n=== for H1, Title\n--- for H2) are the alternative to ATX-style (## Title) and render identically. Pick one and use it consistently within a track. Be careful: Title\n--- with no blank line above makes a heading; \n---\n with blank lines makes a horizontal rule. Both appear in well-formed bodies; the distinction matters only when editing separators.

For challenges that are all one short instruction, drop the step structure entirely. The "step" pattern earns its place once there are three or more.

Author Basic
Use when The challenge needs context before the learner can engage — a splash screen, a short video, a "what we will do" preamble.
Problem

Putting context inside the challenge body forces the learner to read it before they can start. Embedding it as a video that plays inline distracts from the working tabs. A pre-challenge overlay shows the context once, the learner dismisses it, and the working tabs are clean.

Solution

Use the notes: array in the challenge frontmatter. Each entry is a slide; types are text, video, or image. The overlay shows before the challenge starts; the learner clicks through to begin.

yaml assignment.md frontmatter
notes:
  - type: text
    contents: |
      <style>img { display: block; margin: 0 auto; max-width: 80%; }</style>
      ![Splash](../assets/splash.png)

  - type: text
    contents: |
      ## What we will build

      In this challenge you will configure a postgres connection,
      run a migration, and verify connectivity from the workstation.

  - type: video
    url: https://www.youtube.com/embed/abc123?start=0&end=45&autoplay=1&modestbranding=1&rel=0
Gotchas

enhanced_loading: false must be set at the track level for notes: slides to render as the pre-challenge overlay. Setting it true (or omitting it) changes the loading flow and turns notes into a clickable tab rather than a full-screen overlay.

The first challenge can leave enhanced_loading: null (inherit the track value); subsequent challenges should set enhanced_loading: false explicitly. Without the explicit override, the platform may render notes inconsistently across challenges.

YouTube embeds support start= and end= query params. Use them when only a clip of a longer video is the right context for this challenge.

Variations

For locally hosted videos (avoiding external dependency), reference an .mp4 in ../assets/ rather than a YouTube URL. Keep videos short — 30 to 60 seconds — so the pre-challenge gate does not become a wait.

For multi-slide intros, use multiple - type: blocks. Each renders as one slide; the learner clicks through.

Author Basic
Use when A challenge's body references a specific tab the learner should switch to, and embedding a clickable tab-switch button reads better than "now click on the second tab."
Problem

"Now switch to the Terminal tab" is a stale instruction the moment a track grows past three tabs. The learner has to scan the tab bar, identify the right one by label, and click. Worse, the lesson author later reorders the tabs and the prose now points at the wrong place.

Solution

Use the platform's tab-button shortcode in the assignment body. The button appears inline, the learner clicks, the right tab opens. Tab references are zero-indexed; (tab-0) is the first tab.

markdown
Run the migration:

[button label="Open Terminal" variant="success"](tab-0)

Then verify the schema:

[button label="Open Database UI" variant="success"](tab-1)
markdown
For navigation between challenges:

[button label="Next Section" variant="outline"](section--02-run-the-migration)
Gotchas

Indexing is by tab order in assignment.md's frontmatter, not by label. Reorder the tabs and every tab-N reference shifts. Audit when you reorder.

The variant= parameter handles standard semantics — success (green), warning (amber), danger (red, for destructive-action warnings), outline (outlined, for nav-only buttons). Use these rather than custom hex codes; consistency across tracks matters.

Hash-anchor variants like (#) and (#null) exist for "no-op" buttons in some templates. Use them only when the button is a visual cue, not a navigation target.

Variations

For inter-challenge navigation (Next Section, Previous Section), use (section--<challenge-slug>) rather than a tab anchor. The slug is the directory name minus the leading number.

For tracks that use the block parameter, the button renders full-width rather than inline. Use sparingly — full-width buttons compete with the body for attention.

Author Basic
Use when A value in the assignment body must vary per sandbox — a generated password, a sandbox-specific URL, an account ID.
Problem

Hardcoding "the URL is x" works for a static demo. For a real lab where each learner's sandbox has its own URL, account ID, or credentials, the body must show the right values for this learner without the author maintaining ten variants.

Solution

Set the value via agent variable set in setup. Reference in the body via [[ Instruqt-Var key="..." hostname="..." ]]. The platform substitutes at render time.

bash track_scripts/setup-workstation
agent variable set DASHBOARD_URL "https://workstation.${_SANDBOX_ID}.instruqt.io:8443"
agent variable set ACCOUNT_ID "$INSTRUQT_AWS_ACCOUNT_LAB_AWS_ACCOUNT_ID"
agent variable set TEMP_PASSWORD "$(pwgen --ambiguous 12 1)"
markdown assignment.md
Open the dashboard at [[ Instruqt-Var key="DASHBOARD_URL" hostname="workstation" ]].

Sign in with the account ID `[[ Instruqt-Var key="ACCOUNT_ID" hostname="workstation" ]]`
and the temporary password `[[ Instruqt-Var key="TEMP_PASSWORD" hostname="workstation" ]]`.
Gotchas

Interpolation works inside fenced code blocks, table cells, button labels, and most other string fields. No escaping required inside backticks.

_SANDBOX_ID is a shell variable, not an agent variable. Run agent variable set SANDBOX_ID "$_SANDBOX_ID" to surface it in the body.

The hostname: parameter must point at a host where the agent variable is defined. Variables set on the workstation are not visible to lookups against db unless explicitly set on db too.

Variations

For credential dashboards rendered as HTML pages (rather than markdown body), see Recipe 55 — the same primitives work behind a templated page.

For tracks with many variables, group them in a single setup step at the bottom of the script. Hunting for "where is FOO set?" across a 200-line setup is unpleasant.

Author Advanced
Use when The track is an AI-tool demo and the learner's primary action is pasting prompts into an agent rather than running terminal commands.
Problem

A track for an AI coding tool, an LLM-driven workflow, or any agent-based product has a different rhythm from a normal track. The learner spends most of their time prompting the agent and reading responses, not running commands. A standard "type these commands; check pass/fail" structure misfires.

Solution

Structure challenges around prompts. Each challenge presents a prompt the learner pastes into the agent and a description of the response they should observe. Challenges may not need check scripts at all — the agent's response is the validation. Progression goes from "observe what the agent does" to "review what the agent suggests" to "edit alongside the agent."

markdown
<h2 style="color: #37C980;">Step 1: Ask the agent to explore the codebase</h2>

In the agent panel, paste this prompt and press Enter:

Explore the codebase in /workspace/myapp and tell me:

text

Read the agent's response. You should see a summary of the framework, entry point, and dependencies.

---

<h2 style="color: #37C980;">Step 2: Ask the agent to find a bug</h2>

There is a bug in the function summarize_orders in src/app.py. Find it and explain what it does wrong.

text

The agent should identify that empty input lists raise a KeyError when no orders are present.

---

✅ The agent has done the exploration. Move on to **Challenge 2** to start editing.
Gotchas

Challenges without check scripts are valid here. The platform's "Check" button can pass automatically (exit 0) for a workshop-format track where the agent's interaction is the work.

Pasted prompts should use copy (copy button, no run button). The learner pastes the prompt into the agent panel, not into a terminal.bash,run` is wrong here; it would offer to execute the prompt in a shell.

Pacing matters. An Agent-as-Teacher track is more relaxed than a "type a command and check" track. Build that pacing into the body — fewer steps per challenge, more whitespace, longer transitions.

Variations

For tracks where the agent is paired with light terminal work, intersperse bash,run blocks with copy blocks. Make the boundary visible: "Run this in the terminal" versus "Paste this to the agent."

For tracks with a final certification challenge, reintroduce a real check script at the end. The agent guides discovery; the check verifies the final state.

Author Intermediate
Use when The platform's native type: quiz is too narrow for the validation you need — case-insensitive matching, multi-part answers, fuzzy normalization — and you want the answer captured from terminal input.
Problem

type: quiz is great for multiple-choice with a fixed answer. It does not handle "the answer is roughly this string with whitespace tolerance" or "any of three equivalent forms." When the lesson needs a freeform answer, the right shape is a type: challenge with a check script that reads a file the learner writes.

Solution

Ask the learner to write the answer to a known file (echo "answer" > /tmp/answer). The check script reads, normalizes (strip whitespace, lowercase), and compares to a list of acceptable answers. Fail with a specific message; pass with set-status summarizing what they got.

markdown
<!-- assignment.md body -->
Write your answer to `/tmp/quiz-answer`:

echo "<your answer>" > /tmp/quiz-answer

text
bash
# check-workstation
#!/bin/bash
set -uo pipefail

if [ ! -f /tmp/quiz-answer ]; then
  fail-message "Write your answer to /tmp/quiz-answer first."
  exit 1
fi

ANSWER=$(tr -d '[:space:]' < /tmp/quiz-answer | tr '[:upper:]' '[:lower:]')

case "$ANSWER" in
  productcatalog|product-catalog|catalog) ;;
  *)
    fail-message "Try again. Hint: it's the service that owns product metadata."
    exit 1
    ;;
esac

set-status "Correct — $ANSWER."
Gotchas

Normalization is the whole point. Whitespace, case, and trivial punctuation should not break a correct answer. Strip and lowercase before comparing.

A plain case with several alternatives is more readable than nested ifs when there are 3+ acceptable forms. For 10+, switch to a Python heredoc with a list and in.

For multi-part answers (affected service; affected product id; exception text), split on ;, normalize each part, and validate each independently.

Variations

For a branded answer-submission CLI (submit 1 "answer" rather than echo > /tmp/...), see Recipe 30 plus a small wrapper script. The CLI is more discoverable than the file-based form.

For tracks where the answer is computed against live data (a query result), have the check script re-run the same query against the live data and compare to the learner's submission. More robust than hardcoding the expected answer.

Author Basic
Use when A check fails and the learner needs to know what to do next.
Problem

"FAIL" is not an error message. "Something is wrong" is not an error message. Generic check failures send learners into a tailspin: they do not know what they got wrong and the obvious troubleshooting paths are all wrong.

Solution

Every fail-message should name the missing thing, name the fix, and (where possible) include the literal command or query the learner should run. The fail-message is teaching, not punishment.

bash setup script
#!/bin/bash
set -uo pipefail

if ! psql -At "$DATABASE_URL" -c \
  "SELECT 1 FROM pg_user WHERE usename='reader'" | grep -q '^1$'; then
  fail-message "The reader user does not exist. Create it with: CREATE USER reader WITH PASSWORD 'lab-pass';"
  exit 1
fi

if ! psql -At "$DATABASE_URL" -c \
  "SELECT has_table_privilege('reader','orders','SELECT')" | grep -q '^t$'; then
  fail-message "The reader user lacks SELECT on orders. Run: GRANT SELECT ON orders TO reader;"
  exit 1
fi
Gotchas

The fix should be runnable, not a description of how to derive the fix. "Make sure the user has access" is bad; "Run GRANT SELECT ON orders TO reader;" is good. Specificity beats generality.

Long fail-messages compete with the body for attention. Keep each under two sentences; if the message wants to be longer, that is a sign the lesson should split into smaller checks.

The fail-message is rendered in the platform's UI, not in the terminal. Markdown formatting will not work; plain text only.

Variations

For self-paced workshop tracks, the fail-message can be more permissive — "expected to see X but did not, did you skip the previous step?" When the learner is in control of progression, the message is a nudge, not a hard stop.

For graded certification tracks, the fail-message must be more diagnostic — list the criterion that failed, the expected state, and the observed state. The learner is being scored; precision matters more than warmth.

Author Basic
Use when A challenge has a non-obvious step and the lesson wants to support self-rescue without blowing the lesson.
Problem

A learner who is stuck has two options: stay stuck, or open a hint that gives away the answer. The first wastes time; the second wastes the lesson. A layered hint gives them a nudge first, then a stronger nudge, then the answer — opt-in at each layer.

Solution

Three nested <details> collapsibles: nudge, hint, answer. The learner expands as much as they need. Closing all three is the default state; the lesson is intact for learners who do not need help.

markdown
Find the configuration option that controls cluster auto-join.

<details>
<summary>🤔 Nudge</summary>

Look in the file at `/etc/lab/cluster.conf`.

</details>

<details>
<summary>💡 Hint</summary>

The setting starts with `auto_` and takes a boolean.

</details>

<details>
<summary>✅ Answer</summary>

Set `auto_join = true` in `/etc/lab/cluster.conf`.

sed -i 's/auto_join = false/auto_join = true/' /etc/lab/cluster.conf

text

</details>
Gotchas

The <details> element is interactive HTML, not markdown. It is supported in assignment bodies. Inline styles (<details style="...">) work for further visual customization but are usually unnecessary.

The order matters: nudge, hint, answer. A learner who opens "answer" first short-circuits the layering. The sequence has to feel like progressive escalation.

The completion-providing <details> should still leave the learner with something to do — even the "answer" reveal can be a copy block they execute, not a pre-applied state.

Variations

For tracks where hints should be persistent (a hint tab the learner returns to), see Recipe 31 (companion code-tab pair) — the reference file pattern is the persistent form.

For tracks where the hint is video, embed a short clip inside the deepest <details> rather than text. The video plays only when the learner opens the collapsible.

Author Basic
Use when The track is long (10+ challenges), the lesson has natural midpoint seams, and a deliberate pause helps consolidation.
Problem

A long track without seams becomes a slog. The learner is moving forward but losing track of why. A short recap challenge in the middle — "here is what we have built so far; here is what comes next" — gives them a moment to consolidate.

Solution

Insert a content-only challenge at the midpoint. No setup script changes, no check that requires action. The body summarizes what was learned, points forward, and ends with a check that auto-passes.

markdown
<h2 style="color: #37C980;">What we have built</h2>

Over the first five challenges you provisioned a postgres database, seeded it with sample data, wrote a migration, configured a read-only user, and built a small API on top.

<h2 style="color: #37C980;">What comes next</h2>

The next five challenges focus on observability: surfacing query latency, alerting on slow queries, and capturing audit logs.

<h2 style="color: #37C980;">Take a breath</h2>

Click **Check** when you are ready to continue.

✅ Recap complete.
bash
# check-workstation
#!/bin/bash
echo "Recap acknowledged."
exit 0
Gotchas

The recap challenge has zero setup work. Do not add a setup-{hostname} script that does anything visible — the recap is for reflection, and any visible state change distracts.

Mid-track recaps that try to be useful by reviewing material with prompts and exercises miss the point. A recap is a pause, not a quiz. Reserve actual review for end-of-track quiz challenges.

For tracks under eight challenges, the recap is overhead. Reach for it only when the track length earns it.

Variations

For tracks with multiple thematic sections, use multiple recaps. One per section seam works; one per challenge does not.

For tracks where the lesson is genuinely a single arc with no seams, do not add a recap. Inserting one to "be thorough" weakens the existing momentum.

Author Basic
Use when The first challenge needs to set context — what the lab is, what the learner will accomplish — without requiring the learner to do anything yet.
Problem

A first challenge that drops the learner into an action without context loses the "why." A first challenge that opens with two paragraphs of body and then asks for an action splits attention. Neither shape is what an intro should be.

Solution

Make the first challenge body-only. No commands, no files to edit, no tabs to interact with. The body summarizes the lab, points at the agenda, and ends with a single Check button that auto-passes.

markdown
<h2 style="color: #37C980;">Welcome</h2>

This lab teaches the fundamentals of running postgres in production: provisioning, schema migration, role-based access control, and observability.

<h2 style="color: #37C980;">What you will do</h2>

- Stand up a postgres instance with sample data.
- Migrate the schema with a versioned migration tool.
- Configure read-only and admin roles.
- Surface query latency and slow-query alerts.

<h2 style="color: #37C980;">Time</h2>

About 90 minutes. The lab is paced for following along, not racing.

✅ Click **Check** to continue to Challenge 2.
bash
# check-workstation
#!/bin/bash
echo "Intro acknowledged."
exit 0
Gotchas

The intro challenge should not include a quiz, a confirmation prompt, or a "type yes to continue" interaction. It is a context-setter; gating progression on a trivial action makes the lab feel padded.

If the intro feels like it needs to do real setup work, that work belongs in track_scripts/setup-{hostname}, not in the intro challenge's setup. Track-level setup runs once before the first challenge; intro challenges do not need a per-challenge setup.

For tracks with a pre-challenge notes overlay (Recipe 45), the overlay is a different layer from the body. Use both: the overlay shows once, the body persists.

Variations

For tracks with strict skipping (no skipping enabled), an intro challenge ensures every learner sees the context. For tracks with skipping enabled, the intro is optional and learners may jump past it.

For very short tracks (3–5 challenges total), an intro challenge is overhead. The first real challenge can carry the context inline.

Author Basic
Use when A challenge has supplementary information — a syntax reference, a deeper explanation, an alternative approach — that some learners want and others should not have to scroll past.
Problem

Inline reference material slows everyone down. A learner who knows the syntax has to read past it; a learner who does not know it has to stop and read. Tucking the material behind a click resolves the conflict: present by default for learners who need it, invisible for those who do not.

Solution

Use a <details> collapsible with a clear summary. The body of the details is the supplementary content. Default state is closed; opening it does not change the lesson state.

markdown
Set the `auto_join` configuration in `/etc/lab/cluster.conf`.

<details>
<summary>🤔 Reminder: how to edit a config file</summary>

sed -i 's/auto_join = false/auto_join = true/' /etc/lab/cluster.conf

text

Or open the file in a code tab and change line 14 manually.

</details>
Gotchas

<summary> content should be scannable. "Click here for more" is content-free; "Reminder: how to edit a config file" tells the learner whether they want to expand it.

Avoid stacking multiple collapsibles in a row. Two next to each other compete for attention; three or more turn the body into a click-everything game. If you have three pieces of supplementary material, the body might be too long.

The collapsible is interactive HTML, not markdown — but standard markdown inside the body works (fenced code blocks, lists, links). Inline styles work but are usually unnecessary.

Variations

For collapsibles that contain progressive hints rather than reference material, see Recipe 51 (layered hint) — same primitive, different framing.

For supplementary material that should be present in every challenge of a track, factor it into a notes: slide on the first challenge rather than repeating in every body.

Author Intermediate
Use when A track surfaces several credentials, URLs, or identifiers and the learner benefits from a single "lab info" page rather than scrolling through assignments to find them.
Problem

A track that surfaces "here is your URL, your username, your password, your tenant ID, your account ID" by repeating them in every challenge body is noisy. The learner re-reads the same boilerplate before each step. A single dashboard tab — credentials in one place, branded to match the track's visual style — is the right shape.

Solution

Render an HTML page from a template at setup time using gomplate (or a similar Go-template renderer). The template is a small HTML file with placeholders. Setup runs gomplate -f against the template and writes the result to a path served by the workstation's local web server. The dashboard appears as a service tab pointing at the workstation's port 80.

html
<!-- /opt/dashboard/index.html.tmpl -->
<!doctype html>
<html><head><title>Lab credentials</title>
<style>
  body { font-family: system-ui, sans-serif; padding: 2rem; }
  table { border-collapse: collapse; }
  td { padding: 0.4rem 1rem; border-bottom: 1px solid #eee; }
  td.k { font-weight: bold; }
  td.v { font-family: monospace; }
</style></head>
<body>
<h1>Lab credentials</h1>
<table>
  <tr><td class="k">URL</td><td class="v">{{ .Env.DASHBOARD_URL }}</td></tr>
  <tr><td class="k">Username</td><td class="v">{{ .Env.LAB_USER }}</td></tr>
  <tr><td class="k">Password</td><td class="v">{{ .Env.LAB_PASS }}</td></tr>
  <tr><td class="k">Account ID</td><td class="v">{{ .Env.ACCOUNT_ID }}</td></tr>
</table>
</body></html>
bash
# track_scripts/setup-workstation (cloud-client image, which serves /var/www/html on port 80)
#!/bin/bash
set -euxo pipefail

export DASHBOARD_URL="https://workstation.${_SANDBOX_ID}.instruqt.io:8443"
export LAB_USER="lab-user"
export LAB_PASS=$(pwgen --ambiguous 12 1)
export ACCOUNT_ID="$INSTRUQT_AWS_ACCOUNT_LAB_AWS_ACCOUNT_ID"

gomplate -f /opt/dashboard/index.html.tmpl -o /var/www/html/index.html
yaml 01-first-challenge/assignment.md frontmatter
tabs:
  - title: Lab info
    type: service
    hostname: workstation
    port: 80
    path: /
  - title: Terminal
    type: terminal
    hostname: workstation
Gotchas

The cloud-client image ships nginx serving /var/www/html/. Confirm before assuming — on the lighter shell image, you would need to install nginx in setup, which adds a class of failure the dashboard does not need.

gomplate reads from .Env for environment variables. Other data sources (files, JSON URLs) are also supported but rarely needed for a credential dashboard.

Do not put long-lived secrets on the dashboard. The dashboard is convenient for per-sandbox values that are sandbox-scoped; permanent credentials still belong in the platform's secrets store, not on a learner-visible page.

Variations

For tracks where the dashboard should also include a real-time status indicator (cluster ready, services healthy), pair the static dashboard with a small JS poll against /healthz endpoints. The result is a dashboard that updates as the lab progresses.

For tracks with multiple user personas (admin, operator, viewer), render multiple sections on the dashboard — one per persona — so the learner can see all credentials at a glance.

The recipes in this book are answers, not laws. Every track is different; the pattern that fits one will be the wrong shape for another. Use the recipes as starting points, lift the parts you need, and adapt them to the lesson in front of you.

When in doubt:

  • Start small. The smallest infrastructure shape that supports the lesson is almost always the right one.
  • Make failure modes visible. A check that fails with a specific message teaches; a check that fails generically frustrates.
  • Be ruthless about cleanup. Every cloud resource you provision must have a path to deletion when the lab ends.
  • Optimize for the learner's first thirty seconds. Boot time, first-render correctness, and the moment they realize the lab is real are the moments that decide whether they keep going.

Build well. Test honestly. Ship deliberately. Maintain with patience.