diff --git a/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/README.md b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/README.md
new file mode 100644
index 00000000..85bc55bb
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/README.md
@@ -0,0 +1,513 @@
+# Lab automation
+
+This folder is **optional**. Delete it if your MicroHack does not need automated
+Azure provisioning.
+
+When present, the MicroHack platform reads [lab-defaults.json](lab-defaults.json)
+to decide *how* to scope lab environments, and (optionally) invokes
+[deploy-lab.ps1](deploy-lab.ps1) once per user to deploy MicroHack-specific
+resources into a pre-provisioned Azure scope.
+
+## How the platform runs your lab automation
+
+```mermaid
+flowchart LR
+ A[Deployment start] --> B[Read lab-defaults.json]
+ B --> C{For each lab}
+ C --> D[Pre-provision Azure scope
subscription / resource group
+ user RBAC]
+ D --> E[Run deploy-lab.ps1
one parallel job per user]
+
+ subgraph job[Per-lab job]
+ direction LR
+ E --> F[Your script deploys
MicroHack resources]
+ F --> G["Emit @{ HackboxCredential = ... }
to output stream"]
+ end
+
+ G --> H[(Credential repository
per-lab credentials)]
+ H --> I[User dashboard]
+```
+
+Key points for integration:
+
+- **One job per lab** — `deploy-lab.ps1` is invoked in parallel, once per
+ lab, with that lab's `SubscriptionId` / `ResourceGroupName` /
+ `AllowedEntraUserIds` already set.
+- **Credentials are stored per lab** — every `HackboxCredential` hashtable your
+ script writes to the output stream is captured, attributed to the lab the
+ job ran for, and surfaced on that lab's personal dashboard.
+- **No cross-user state** — each job runs in its own runspace. Use
+ `Get-MhhStableHash` over `$AllowedEntraUserIds` if you need a deterministic,
+ per-user resource name.
+
+- [Lab automation](#lab-automation)
+ - [How the platform runs your lab automation](#how-the-platform-runs-your-lab-automation)
+ - [Folder layout](#folder-layout)
+ - [`lab-defaults.json`](#lab-defaultsjson)
+ - [Fields](#fields)
+ - [`groups` — supported values](#groups--supported-values)
+ - [`deploymentType` — what each value means for your script](#deploymenttype--what-each-value-means-for-your-script)
+ - [`deploy-lab.ps1`](#deploy-labps1)
+ - [Required parameter contract](#required-parameter-contract)
+ - [What the platform guarantees before your script runs](#what-the-platform-guarantees-before-your-script-runs)
+ - [Deploying when `deploymentType = subscription`](#deploying-when-deploymenttype--subscription)
+ - [Returning credentials to the user (HackboxCredential)](#returning-credentials-to-the-user-hackboxcredential)
+ - [Available helper cmdlets](#available-helper-cmdlets)
+ - [`Get-MhhStableHash`](#get-mhhstablehash)
+ - [`Get-MhhLabUser`](#get-mhhlabuser)
+ - [`Invoke-MhhDeploymentWithRegionFallback`](#invoke-mhhdeploymentwithregionfallback)
+ - [`Test-MhhDeploymentFailureRetryable`](#test-mhhdeploymentfailureretryable)
+ - [Authoring guidelines](#authoring-guidelines)
+
+## Folder layout
+
+```text
+labautomation/
+├── lab-defaults.json # Platform-facing configuration (required if folder exists)
+└── deploy-lab.ps1 # Optional: per-user Azure deployment script
+```
+
+Both files are picked up automatically — no registration step is required.
+
+## `lab-defaults.json`
+
+This file tells the platform how to size and scope lab environments for your
+MicroHack. The schema is published at
+`https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json`
+and is validated when your MicroHack is loaded.
+
+```json
+{
+ "$schema": "https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json",
+ "groups": [ "GHCPUsers" ],
+ "deploymentType": "resourcegroup",
+ "labsPerSubscription": 4,
+ "preferredLocation": "swedencentral, norwayeast, spaincentral",
+ "estimatedDailyCostsUsd": 25.0
+}
+```
+
+Only include fields you want to set — missing fields fall back to platform defaults.
+
+### Fields
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `groups` | `string[]` | Group-based feature activations for the Entra ID users created for this lab. See [supported values](#groups--supported-values). |
+| `deploymentType` | `"resourcegroup"` \| `"subscription"` \| `"resourcegroup-with-subscriptionowner"` | Azure scope each user receives. See [details](#deploymenttype--what-each-value-means-for-your-script). |
+| `labsPerSubscription` | `integer` (1–100) | How many users to pack into a single Azure subscription. Ignored when `deploymentType` is `subscription`. |
+| `preferredLocation` | `string` | Comma-separated Azure regions, **in priority order** (e.g. `"swedencentral, norwayeast"`). The first region is used as the default deployment location. List multiple regions so your script can fall back if the first region doesn't support every service your lab needs — see [authoring guidelines](#authoring-guidelines). |
+| `estimatedDailyCostsUsd` | `number` (≥ 0) | Estimated daily cost per lab environment in USD. Used for cost forecasting in the lab lifecycle wizard. |
+
+### `groups` — supported values
+
+Add a group name to provision an extra capability for every lab user created
+for this MicroHack:
+
+| Group | What it provisions for each lab user |
+| --- | --- |
+| `GHCPUsers` | A GitHub Copilot seat assigned to the user. |
+| `M365-E5-Users` | A Microsoft 365 E5 license assigned to the user. |
+
+You can combine multiple groups, e.g. `"groups": [ "GHCPUsers", "M365-E5-Users" ]`.
+Leave the array empty (`[]`) if your MicroHack needs only Azure.
+
+### `deploymentType` — what each value means for your script
+
+| Value | Azure scope per user | RBAC on subscription | RBAC on resource group |
+| --- | --- | --- | --- |
+| `resourcegroup` | One resource group, **shared subscription** | `Reader` | `Owner` |
+| `resourcegroup-with-subscriptionowner` | One resource group, **shared subscription** | `Owner` | `Owner` |
+| `subscription` | One dedicated subscription | `Owner` | n/a |
+
+Choose `subscription` only when your lab genuinely needs subscription-scoped
+resources (policies, management-group operations, etc.) — it is materially more
+expensive in subscription pool consumption.
+
+## `deploy-lab.ps1`
+
+This script is **optional**. If it is missing — or if its parameter block does
+not match the contract below — the platform will skip it and only provision the
+empty scope (subscription / resource group) plus RBAC.
+
+The platform invokes your script **once per user**, in parallel, inside a
+pre-configured PowerShell environment with the user's Azure context already
+selected.
+
+### Required parameter contract
+
+Your script **must** declare exactly these parameters (a mismatching parameter
+block causes the script to be skipped):
+
+```powershell
+param(
+ [Parameter(Mandatory=$true)]
+ [ValidateSet('subscription','resourcegroup','resourcegroup-with-subscriptionowner')]
+ [string]$DeploymentType,
+
+ [Parameter(Mandatory=$true)]
+ [string]$SubscriptionId,
+
+ [string]$ResourceGroupName = "",
+
+ [string[]]$PreferredLocation = @(),
+
+ [string[]]$AllowedEntraUserIds = @()
+)
+```
+
+| Parameter | Provided by | Notes |
+| --- | --- | --- |
+| `DeploymentType` | platform | Same value as in `lab-defaults.json`. |
+| `SubscriptionId` | platform | The user's target Azure subscription. `Set-AzContext` is already pointed at it. |
+| `ResourceGroupName` | platform | Empty for `subscription` deployments; otherwise the user's resource group (already created, user already `Owner`). |
+| `PreferredLocation` | platform | The regions from `lab-defaults.json`, in priority order. Iterate through the list and pick the first region that supports every Azure service your lab needs — skip regions that don't, and emit `Write-Warning` when you skip one (see [authoring guidelines](#authoring-guidelines)). |
+| `AllowedEntraUserIds` | platform | Entra object IDs that should be granted access to anything your script provisions beyond the default RBAC. Always contains exactly one ID — the user this invocation is for. |
+
+> `PreferredLocation` may also be declared as `[string]` (comma-separated) if you
+> prefer — the platform detects the type and adapts.
+
+### What the platform guarantees before your script runs
+
+- The Azure subscription context is set to `$SubscriptionId`.
+- For `resourcegroup` / `resourcegroup-with-subscriptionowner` deployments, the
+ resource group named `$ResourceGroupName` already exists at `$PreferredLocation[0]`,
+ and the user already has `Owner` on it. The resource group is a metadata-only
+ container — you can still deploy individual resources into any other region
+ from `$PreferredLocation` if the first region doesn't support a service your
+ lab needs (emit `Write-Warning` when you fall back).
+- For `subscription` deployments, the user already has `Owner` on the
+ subscription, but **no resource group is created** — `$ResourceGroupName` is
+ empty. Your script owns RG creation (see [below](#deploying-when-deploymenttype--subscription)).
+- The `Az.Accounts` and `Az.Resources` modules are imported.
+- The script runs as a service principal with subscription `Owner`.
+
+You do **not** need to call `Connect-AzAccount` or `Set-AzContext`, and for
+`resourcegroup` / `resourcegroup-with-subscriptionowner` deployments you do not
+need to create the resource group or assign the user's `Owner` role.
+
+### Deploying when `deploymentType = subscription`
+
+Because the platform does not pre-create a resource group in this mode, you
+have two options inside your script:
+
+**Option A — create one or more resource groups yourself.**
+Use `Get-MhhStableHash` to derive a deterministic name from
+`$AllowedEntraUserIds` so re-runs target the same RG:
+
+```powershell
+$location = if ($PreferredLocation.Count -gt 0) { $PreferredLocation[0] } else { 'swedencentral' }
+$rgName = "lab-{0}" -f (Get-MhhStableHash -Value $AllowedEntraUserIds -Length 12)
+
+if (-not (Get-AzResourceGroup -Name $rgName -ErrorAction SilentlyContinue)) {
+ New-AzResourceGroup -Name $rgName -Location $location | Out-Null
+}
+
+# Surface the RG name so the user can find it
+@{ HackboxCredential = @{ name = "Resource Group Name"; value = $rgName; note = "" } }
+```
+
+> The `Resource Group Name` credential is normally emitted by the platform for
+> `resourcegroup` / `resourcegroup-with-subscriptionowner` deployments. In
+> `subscription` mode it is *not* emitted — emit it yourself if you want it
+> shown on the user's dashboard.
+
+**Option B — do a subscription-scoped deployment.**
+Use `New-AzDeployment` (or `New-AzSubscriptionDeployment`) with a template
+whose `targetScope` is `subscription`:
+
+```powershell
+$location = if ($PreferredLocation.Count -gt 0) { $PreferredLocation[0] } else { 'swedencentral' }
+
+New-AzDeployment `
+ -Name ("lab-" + (Get-MhhStableHash -Value $AllowedEntraUserIds -Length 12)) `
+ -Location $location `
+ -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') `
+ -TemplateParameterObject @{ allowedEntraUserIds = $AllowedEntraUserIds } `
+ | Out-Null
+```
+
+Either option is fine — pick the one that matches what your lab actually needs
+at the subscription scope (e.g. policy assignments, multiple RGs, management
+group operations).
+
+### Returning credentials to the user (HackboxCredential)
+
+Anything your script writes to the output stream as a `HackboxCredential`
+hashtable is captured and surfaced to the user on their personal lab dashboard.
+
+```powershell
+# Single credential
+@{ HackboxCredential = @{
+ name = "AdminPassword"
+ value = "TopSecret!"
+ note = "Initial password for the VM admin account"
+} }
+
+# Multiple credentials — emit each as its own hashtable
+@{ HackboxCredential = @{ name = "Storage Account"; value = $storageAccount.Name; note = "" } }
+@{ HackboxCredential = @{ name = "Storage Key"; value = $key; note = "Primary key" } }
+```
+
+Rules:
+
+- `name` and `value` are required and must be non-empty.
+- `note` is optional context shown next to the credential.
+- Emit each credential as a separate hashtable — do not wrap them in an array.
+- The platform reserves certain credential names (e.g. `Subscription ID`,
+ `Resource Group Name`, `Entra ID *`, `Portal URL *`) and emits them itself.
+ Attempts to emit a reserved name are silently dropped.
+
+## Available helper cmdlets
+
+The platform ships a PowerShell module that is auto-imported into your script
+(and into any `Start-Job` child runspaces you spawn). No `Import-Module` call
+is required.
+
+### `Get-MhhStableHash`
+
+Deterministic, order-insensitive hash of one or more strings — useful for
+generating stable resource names from membership (e.g. an Entra-user allow-list).
+
+```powershell
+# 24-char hex hash (default length)
+$hash = Get-MhhStableHash -Value $AllowedEntraUserIds
+
+# Use it to name a resource group / storage account / etc.
+$rgName = "lab-$hash"
+```
+
+| Parameter | Description |
+| --- | --- |
+| `Value` (`string[]`, required) | One or more strings to hash. Accepts pipeline input. |
+| `Length` (`int`, optional) | Number of hex chars to return. Range 12–64. Default 24. |
+
+The same set of inputs (in any order, any casing) always produces the same hash.
+
+### `Get-MhhLabUser`
+
+Resolve the Entra object IDs you receive in `$AllowedEntraUserIds` to lab-user
+records (`UserPrincipalName` + a short, lowercase name). Content scripts only
+get bare object IDs — use this cmdlet whenever you need the user's UPN or a
+human-friendly name (e.g. to tag resources, build a greeting, or derive a
+stable per-user resource name).
+
+```powershell
+# Resolve the single user this invocation is for
+$me = Get-MhhLabUser -UserId $AllowedEntraUserIds[0]
+Write-Host "Deploying for $($me.UserPrincipalName) ($($me.ShortName))"
+
+# Or resolve many at once via the pipeline
+$AllowedEntraUserIds | Get-MhhLabUser | ForEach-Object { $_.ShortName }
+```
+
+| Parameter | Description |
+| --- | --- |
+| `UserId` (`string[]`, required) | One or more Entra object IDs (GUIDs). Also accepts the aliases `-ObjectId` / `-Id`, and accepts pipeline input. |
+
+**Return value** — one `PSCustomObject` per input ID:
+
+| Field | Description |
+| --- | --- |
+| `Id` | The Entra object ID you passed in. |
+| `UserPrincipalName` | The user's UPN (e.g. `user01@contoso.onmicrosoft.com`). |
+| `ShortName` | The lowercase local part of the UPN (the bit before `@`) — handy for resource names and greetings. |
+
+Resolution is served from a cache that the platform pre-seeds before your script runs,
+so hits cost nothing. On a cache miss (e.g. local development or a one-off run)
+it transparently falls back to `Get-AzADUser -ObjectId` and
+writes the result back to the cache, so it always resolves correctly —
+just without the cache speed-up.
+
+### `Invoke-MhhDeploymentWithRegionFallback`
+
+Deploy a Bicep / ARM template into a resource group with **automatic region
+fallback** when Azure returns capacity, quota, or region-unsupported errors.
+This is the recommended way to ship an RG-scoped Bicep template from
+`deploy-lab.ps1` — it removes the need to hand-roll the
+`foreach ($region in $PreferredLocation) { try { … } }` loop documented in the
+[authoring guidelines](#authoring-guidelines).
+
+On every attempt the helper:
+
+1. Force-deletes the resource group if it already exists (synchronous, with
+ forced VM/VMSS teardown).
+2. Recreates an **empty** RG in the current candidate region.
+3. Re-grants `Owner` on the RG to every Entra object ID in
+ `-RgOwnerEntraObjectIds` — **you must pass `$AllowedEntraUserIds` here** or
+ the user will lose access to the RG the platform originally granted them.
+4. Runs your optional `-PreDeployHook` (e.g. to register resource providers).
+5. Submits `New-AzResourceGroupDeployment` with your template.
+
+On failure the error is classified by
+[`Test-MhhDeploymentFailureRetryable`](#test-mhhdeploymentfailureretryable):
+
+| Classification | Behaviour |
+| --- | --- |
+| Fatal (template bug, RBAC, policy, name collision…) | Re-throws immediately. No further regions are tried. |
+| Retry-next-region (`SkuNotAvailable`, `QuotaExceeded`, `LocationNotAvailableForResourceType`, `ResourceProviderUnavailable`, …) | Rotates to the next region in `-PreferredLocations`. |
+| Same-region transient (`TooManyRequests`, `OperationTimedOut`, `ServiceUnavailable`, `5xx`) | Bounded retry in the same region first (`-SameRegionRetryBudget`, default 1), then rotates. |
+
+If every region is exhausted, the helper throws
+`RegionFallbackExhausted: …` with a per-attempt summary.
+
+**Key parameters (integration view):**
+
+| Parameter | What to pass |
+| --- | --- |
+| `PreferredLocations` (`string[]`, required) | Pass `$PreferredLocation` straight through. |
+| `ResourceGroupName` (`string`, required) | For `resourcegroup` / `resourcegroup-with-subscriptionowner`: pass `$ResourceGroupName`. For `subscription`: derive a stable name with `Get-MhhStableHash`. |
+| `RgOwnerEntraObjectIds` (`string[]`) | **Pass `$AllowedEntraUserIds`** — the helper wipes and recreates the RG, so this is required to preserve the user's `Owner` role. |
+| `TemplateFile` (`string`, required) | Path to your `.bicep` / `.json` template (e.g. `Join-Path $PSScriptRoot 'main.bicep'`). |
+| `TemplateParameterObject` (`hashtable`) | Your template parameters. Mutually exclusive with `TemplateParameterFile`. |
+| `TemplateParameterFile` (`string`) | Parameter file path. Mutually exclusive with `TemplateParameterObject`. |
+| `DeploymentNamePrefix` (`string`) | Prefix for the ARM deployment name (final name includes region + timestamp). Default `mhh`. |
+| `MaxAttempts` (`int`) | Caps total attempts. Default `0` = one per region. |
+| `SameRegionRetryBudget` (`int`, 0–5) | Extra retries inside the same region for transient codes before rotating. Default `1`. |
+| `Tag` (`hashtable`) | Tags applied to the RG on each recreate. |
+| `PreDeployHook` (`scriptblock`) | Optional. Invoked after RG create, before deployment. Receives the chosen location as a positional argument. Use it to e.g. `Register-AzResourceProvider` for that region. |
+| `AssumeRetryableOnUnknown` (`switch`) | Treat unknown ARM error codes as retryable instead of fatal. Off by default — leave it off so real template bugs fail fast. |
+
+**Return value on success** (a hashtable):
+
+| Field | Description |
+| --- | --- |
+| `Success` | `$true` |
+| `LocationUsed` | The region the deployment actually succeeded in. |
+| `DeploymentName` | Full ARM deployment name. |
+| `Outputs` | Hashtable of template outputs (`name → value`), flattened for direct use. |
+| `DeploymentResult` | Raw `PSResourceGroupDeployment` object. |
+| `Attempts` | Per-attempt diagnostic records (region, outcome, classification, duration). |
+
+Feed `Outputs` straight into `HackboxCredential` hashtables — that is the
+typical bridge between your Bicep `output` blocks and the user's dashboard.
+
+**Example — RG-scoped Bicep deployment with region fallback:**
+
+```powershell
+param(
+ [Parameter(Mandatory=$true)]
+ [ValidateSet('subscription','resourcegroup','resourcegroup-with-subscriptionowner')]
+ [string]$DeploymentType,
+
+ [Parameter(Mandatory=$true)]
+ [string]$SubscriptionId,
+
+ [string]$ResourceGroupName = "",
+ [string[]]$PreferredLocation = @(),
+ [string[]]$AllowedEntraUserIds = @()
+)
+
+$result = Invoke-MhhDeploymentWithRegionFallback `
+ -PreferredLocations $PreferredLocation `
+ -ResourceGroupName $ResourceGroupName `
+ -RgOwnerEntraObjectIds $AllowedEntraUserIds `
+ -TemplateFile (Join-Path $PSScriptRoot 'main.bicep') `
+ -TemplateParameterObject @{
+ userObjectId = $AllowedEntraUserIds[0]
+ } `
+ -DeploymentNamePrefix 'lab'
+
+# Surface region used + any template outputs to the user's dashboard.
+@{ HackboxCredential = @{ name = "Region"; value = $result.LocationUsed; note = "" } }
+foreach ($k in $result.Outputs.Keys) {
+ @{ HackboxCredential = @{ name = $k; value = [string]$result.Outputs[$k]; note = "" } }
+}
+```
+
+**When *not* to use it:**
+
+- You are not doing an ARM/Bicep deployment (e.g. you only call individual
+ `New-Az*` cmdlets). Use the manual
+ [region-iteration pattern](#authoring-guidelines) instead.
+- You need a `subscription`-scoped deployment (`New-AzDeployment`). This helper
+ is RG-scoped only.
+- You need to preserve resources across retries. The helper **wipes the RG on
+ every attempt** — your template must be self-contained.
+
+### `Test-MhhDeploymentFailureRetryable`
+
+Lower-level classifier used internally by
+`Invoke-MhhDeploymentWithRegionFallback`. Call it directly only if you've
+hand-rolled your own deployment loop and need to make the same
+fatal / retry-next-region / same-region-transient decision.
+
+**Parameters:**
+
+| Parameter | Description |
+| --- | --- |
+| `ErrorRecord` (`ErrorRecord`, required) | The terminating error caught from `New-AzResourceGroupDeployment`. |
+| `ResourceGroupName` (`string`) | Used to drill into deployment operations when the outer exception is an opaque wrapper (`DeploymentFailed` / `InvalidTemplateDeployment`). |
+| `DeploymentName` (`string`) | Same as above — required for the drill-in to work. |
+| `AssumeRetryableOnUnknown` (`switch`) | Treat unknown ARM error codes as retryable instead of fatal. Off by default. |
+
+```powershell
+try {
+ New-AzResourceGroupDeployment @splat
+}
+catch {
+ $verdict = Test-MhhDeploymentFailureRetryable `
+ -ErrorRecord $_ `
+ -ResourceGroupName $ResourceGroupName `
+ -DeploymentName $depName
+
+ if (-not $verdict.IsRetryable) { throw } # Fatal
+ if ($verdict.SameRegionRetry) { Start-Sleep 30; } # Transient
+ # else: rotate to next region in your loop
+}
+```
+
+**Return value** (a hashtable):
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `IsRetryable` | `bool` | `$true` if you should retry (in this region or the next), `$false` if you must give up. |
+| `SameRegionRetry` | `bool` | When `IsRetryable` is `$true`: `$true` = retry in the **same** region first (transient throttling / timeout / 5xx), `$false` = rotate to the **next** region (capacity / quota / region-unsupported). |
+| `Classification` | `string` | One of `Fatal`, `CapacityShortage`, `QuotaExceeded`, `RegionUnsupported`, `ResourceProviderUnavailable`, `RegionalFailure`, `TransientThrottle`, `UnknownAssumedFatal`, `UnknownAssumedRetryable`. |
+| `MatchedCode` | `string` | The specific ARM error code that drove the decision (e.g. `SkuNotAvailable`, `QuotaExceeded`, `TooManyRequests`). `$null` for unknown classifications. |
+| `AllCodes` | `string[]` | Every ARM error code discovered while walking the error tree — useful for diagnostics / logging. |
+| `Reason` | `string` | The surface error message from the original exception. |
+| `RetryAfterSeconds` | `int` | Suggested backoff before retrying. Default `30`. |
+
+For most labs, prefer
+[`Invoke-MhhDeploymentWithRegionFallback`](#invoke-mhhdeploymentwithregionfallback)
+and you won't need to call this directly.
+
+## Authoring guidelines
+
+- **Be idempotent.** The platform may re-invoke your script if a deployment is
+ retried. Use `-ErrorAction SilentlyContinue` + `if (-not …)` patterns, or
+ `New-AzResourceGroupDeployment` with the same deployment name.
+- **Prefer `Invoke-MhhDeploymentWithRegionFallback` for RG-scoped Bicep/ARM
+ deployments.** It handles RG recreate, Owner re-grant, region fallback, and
+ failure classification for you — see
+ [the helper docs](#invoke-mhhdeploymentwithregionfallback).
+- **Honour `$PreferredLocation` — but skip unsupported regions.** Iterate
+ through `$PreferredLocation` in order and pick the first region that
+ supports every Azure service your lab needs. If you have to skip a region,
+ emit a `Write-Warning` so the operator can see *why* the lab landed in a
+ fallback region. If none of the preferred regions work, `throw` with a clear
+ message. Never hardcode regions, and never silently ignore the list.
+
+ ```powershell
+ $location = $null
+ foreach ($candidate in $PreferredLocation) {
+ # Replace with your own probe — e.g. Get-AzComputeResourceSku,
+ # Get-AzLocation, or a service-specific availability check.
+ if (Test-MyLabRegionSupported -Location $candidate) {
+ $location = $candidate
+ break
+ }
+ Write-Warning "Skipping region '$candidate': required services are not available there."
+ }
+ if (-not $location) {
+ throw "None of the preferred regions support this lab: $($PreferredLocation -join ', ')"
+ }
+ ```
+
+- **Scope RBAC to `$AllowedEntraUserIds`.** If you provision additional
+ identities (managed identities, service principals, etc.) and want the user
+ to manage them, grant the user access explicitly.
+- **Keep total runtime reasonable.** Your script runs concurrently for every
+ user; long synchronous deployments slow down the whole event start.
+- **Do not call `Connect-AzAccount` or `Set-AzContext`.** The platform manages
+ authentication and subscription context for you.
diff --git a/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/deploy-lab.ps1 b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/deploy-lab.ps1
new file mode 100644
index 00000000..03547a90
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/deploy-lab.ps1
@@ -0,0 +1,65 @@
+<#
+.SYNOPSIS
+Deploys the lab resources scoped to a subscription or resource group.
+.DESCRIPTION
+Provides a controlled deployment flow for lab environments, optionally limited to a resource group and specific Entra user IDs.
+.PARAMETER DeploymentType
+Defines the deployment scope; allowed values are subscription or resourcegroup.
+.PARAMETER SubscriptionId
+Specifies the Azure subscription that contains the lab resources.
+.PARAMETER ResourceGroupName
+In case of resourcegroup deployment, specifies the target resource group name.
+.PARAMETER PreferredLocation
+Specifies the preferred Azure regions (ordered by preference) for resource deployment. An empty array indicates no preference.
+.PARAMETER AllowedEntraUserIds
+Optional list of Entra user object IDs permitted to access the lab resources.
+#>
+param(
+ [Parameter(Mandatory=$true)]
+ [ValidateSet('subscription','resourcegroup', 'resourcegroup-with-subscriptionowner')]
+ [string]$DeploymentType,
+
+ [Parameter(Mandatory=$true)]
+ [string]$SubscriptionId,
+
+ [string]$ResourceGroupName = "",
+
+ [string[]]$PreferredLocation = @(),
+
+ [string[]]$AllowedEntraUserIds = @()
+)
+
+$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
+
+# Validate parameters
+if($DeploymentType -eq 'resourcegroup' -and [string]::IsNullOrEmpty($ResourceGroupName)) {
+ throw "ResourceGroupName must be provided when DeploymentType is 'resourcegroup'."
+}
+
+# set the effective location (example)
+if($PreferredLocation.Count -gt 0) {
+ $effectiveLocation = $PreferredLocation[0]
+} else {
+ $effectiveLocation = "swedencentral" # Default location if no preference is provided
+}
+
+# set the effective resource group based on deployment type (example)
+if($DeploymentType -eq 'subscription') {
+ $stableHash = Get-MhhStablehash $AllowedEntraUserIds -Length 24
+ $effectiveResourceGroup = "lab-$stableHash"
+ Write-Host "Deploying lab resources at the subscription level in subscription $SubscriptionId..."
+ New-AzResourceGroup -Name $effectiveResourceGroup -Location $effectiveLocation -Verbose
+}
+else {
+ $effectiveResourceGroup = $ResourceGroupName
+}
+# feed the effective resource group back to the console
+@{"HackboxCredential" = @{ name = "ResourceGroupName" ; value = $effectiveResourceGroup; note = "The name of the resource group where lab resources are deployed" }}
+
+# $template = Join-Path $scriptPath "template.bicep"
+# $template = Join-Path $scriptPath "template.json"
+# New-AzResourceGroupDeployment -ResourceGroupName $effectiveResourceGroup -TemplateFile $template -Verbose
+
+# You can send back information to the hackbox console (credentials) - Simply return a hashtable like this:
+# @{"HackboxCredential" = @{ name = "AdminPassword" ; value = "TopSecret"; note = "Useful info here" }}
+
diff --git a/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/lab-defaults.json b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/lab-defaults.json
new file mode 100644
index 00000000..0f3a7bd2
--- /dev/null
+++ b/03-Azure/01-01-App Innovation/03_GHCPAppModernization/labautomation/lab-defaults.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://raw.githubusercontent.com/microsoft/MicroHack/refs/heads/main/lab-defaults-schema.json",
+ "groups": [ "GHCPUsers" ],
+ "deploymentType": "resourcegroup",
+ "labsPerSubscription": 8,
+ "preferredLocation": "swedencentral, norwayeast, spaincentral",
+ "estimatedDailyCostsUsd": 25.0
+}