Innovation Sandbox for Azure: I Built the Version AWS Has and Azure Doesn't
2026-10-25 · ~8 min read
AWS ships a packaged way to hand people short-lived, budget-capped sandboxes that clean themselves up. Azure has the parts but not the package. So I built it: Terraform, a resource group per user, budgets, expiration tags, and a cleanup job with a six-permission role. Live testing found three bugs nothing else would have.
AWS has a thing called Innovation Sandbox. You deploy it, and people can lease a temporary AWS account with a time limit and a budget. When either runs out, the account gets frozen and cleaned. It's packaged, documented, and supported.
Azure doesn't have that packaged. So I built one.
The code is public: github.com/dswann101164/innovation-sandbox-for-azure. Designed by me, built with Claude Code.
Every enterprise lab I've seen ends up the same way. People spin things up, move on, and the resources keep running. Nobody owns them, nothing expires, and the bill quietly reaches thousands a month. This fixes that.
Why Azure doesn't have this packaged
Azure has every ingredient. Resource groups, RBAC, Azure Policy, budgets, tags, Automation. What it doesn't have is the recipe that ties them into "give this person a place to experiment, cap the spend, and take it away on a date."
The closest things are built for a specific shape of work. DevTest Labs is about lab VMs. Deployment Environments is about developers spinning up approved templates. Neither is "here's a resource group, you're Contributor, go learn something, it's gone in two weeks."
In enterprise environments that gap gets filled one of two ways. Either nobody gets a sandbox and people learn in shared dev subscriptions, or somebody gets one and it lives forever because nobody owns the cleanup.
The v1 design
I kept v1 deliberately small:
- One subscription holds every sandbox.
- One resource group per user, and the user gets Contributor on that group only.
- Tags on every sandbox:
owner,expires-on, andisb-sandbox = true. - A budget per sandbox, with email alerts at 50, 80 and 100 percent.
- A daily cleanup job that deletes any sandbox that has expired or spent its budget.
- Azure Policy guardrails (allowed regions, SKUs, resource types) are the next piece. They're designed, not built yet.
Everything is Terraform, in four stacks: a state backend, provider registrations, the cleanup job, and the sandboxes. One PowerShell script deploys them in order, shows every plan, and waits for you to type yes before applying anything.

A few decisions that mattered more than I expected:
Object IDs, not emails. Role assignments use the user's Entra object ID. Guest accounts, like a personal Outlook account invited into a tenant, can't be looked up by email at all. The first time I tried, it simply failed with "Cannot find user."
expires-on means the first day the sandbox is gone. Not the last day it exists. That one-word ambiguity decides whether a sandbox dies on the 2nd or the 3rd, and when you have a hard deadline, that day matters.
Budgets alert. They don't stop anything. This surprises people. An Azure budget sends an email; it doesn't block spend. And a monthly budget resets on the 1st, so a sandbox that spans a month boundary gets a second full allowance. So the cleanup job doesn't trust the budget's own counter. It asks Cost Management for the total cost since the sandbox started and compares that to the budget amount.
The cleanup job
The cleanup is an Azure Automation runbook on a daily schedule, running as its own managed identity. No secrets anywhere. It calls Azure Resource Manager over REST directly, so there are no PowerShell module versions to keep up with.
It only looks at resource groups tagged isb-sandbox = true and named rg-sandbox-*, and it has a hard-coded list of groups it will never touch. Anything ambiguous, like a missing expires-on tag or a cost lookup that fails, gets reported, not deleted. It ships in dry-run mode, where it only logs what it would delete. You turn deletion on deliberately.
Six permissions, nothing more
The job's identity gets a custom role with exactly six actions:
Microsoft.Resources/subscriptions/readMicrosoft.Resources/subscriptions/resourceGroups/readMicrosoft.Resources/subscriptions/resourceGroups/deleteMicrosoft.Consumption/budgets/readMicrosoft.Consumption/budgets/deleteMicrosoft.CostManagement/query/read
Here's the uncomfortable part: Azure RBAC can't say "delete only resource groups with this tag." Delete is delete, subscription-wide. So the guard rails live elsewhere. The runbook's selection rules have unit tests, dry-run is the default, and the resource group holding Terraform state has a CanNotDelete lock. The job's role has no permission to remove locks, so even a bug can't take out state.
One more detail: the cost query is an HTTP POST, but its RBAC action is query/read. My first draft of the role used an action that doesn't exist. Azure's own operations list had the right name.
Three bugs only live testing found
Every unit test passed. The dry runs were perfect. Then I created a throwaway sandbox that was already expired and ran the job for real.

Bug 1: a question mark ate the URL. The live run picked the right sandbox and then failed to delete it. The request URL came out as https://management.azure.com-version=2021-04-01. In PowerShell 7, ? is a legal character in a variable name, so "$RgId?api-version=..." was read as a variable called RgId?api, which is empty. Dry-run never builds the delete URL, so it never saw this. The fix is ${RgId}, and the URL builder became its own function with a unit test. I checked that the test fails against the old line before trusting it.
It failed safely, which I'm glad I'd designed for. The error was caught per sandbox, counted in the summary, and the job ended as Failed instead of a false "Completed." Nothing was deleted.
Bug 2: budgets outlive their resource group. Second live run: the resource group was gone, the role assignment was gone, and the budget was still there. A budget scoped to a resource group is a separate resource that just points at it, and deleting the group doesn't delete it. Left alone, every cleaned-up sandbox leaves an orphan, and re-creating a sandbox with the same name fails with "already exists." The fix: delete the budgets first, then the group. That needed the sixth permission, budgets/delete, and got its own unit test. A third live run on a fresh throwaway confirmed through the API that the group, the role assignment and the budget were all gone, and nothing else was touched.

Bug 3: Terraform truncated an ID past my redaction. All the tooling masks subscription IDs, tenant IDs and emails in terminal output, so screenshots and logs are safe to share. While the fix for bug 2 was being applied, Terraform printed progress lines like Still modifying... [id=/subscriptions/1111...-...]. It shortens long IDs with ..., which cut the GUID in half, and my pattern only matched whole GUIDs. Half an ID went straight through. The fix masks any GUID fragment, with regression cases for truncated IDs and for the text that must not be masked.
None of these would have shown up in a code review. All three only show up when you run the real thing against real Azure.
Just because it works on paper doesn't mean it works in production. Use throwaway resources, dry-run first, and make sure it fails safe when it does break.
The leaked ID fragment and the pre-commit guard
This one's on the build process, and I'm including it because hiding it would defeat the point.
While fixing bug 3, a code comment needed an example of a truncated Terraform ID. The example that went in was the real one: the first characters of my actual subscription ID. A leak scan caught it. But the scan was a report, not a gate, so the commit went through anyway.
Nothing had been pushed, so the fix was clean:
- Replace the example with a fake value.
- Rewrite the three affected commits to remove it from history, then prove the final file tree was byte-for-byte unchanged.
- Purge the old objects and rescan every commit and every object in the repository: zero hits.
- Add a pre-commit hook that blocks any commit containing the subscription, tenant or object ID, in full or as a 13-character prefix, plus the signed-in email and the tenant name. The values come from the live
azlogin, so nothing sensitive is stored in the repo. If it can't check, it blocks.
I tested the hook by planting the prefix in a file. The commit was refused.
Two lessons. A truncated ID is still an ID. And a scan that doesn't block the commit is just a report. Put the check where it can't be skipped.
Measure three times, cut once. Catch the mistake before it reaches production and turns into a security hole.
What it costs, and what's next
Running the platform costs pennies: a small storage account for Terraform state, and about a minute of Automation a day, inside the free tier. Sandboxes cost whatever their users deploy, up to their budget.
What's not done yet:
- Azure Policy guardrails for regions, SKUs and resource types.
- Closing the tag gap. Contributor on the group lets a sandbox user edit their own
expires-ontag and budget. Terraform puts them back on the next apply, but a custom "Sandbox Contributor" role will close it properly. - Prompt over-budget deletion through budget alerts, instead of waiting for the next daily run.
The repo has the full build log: every step, why, and every gotcha, including the ones above.
Take it and try it for yourself. If you find a gap, open an issue on the repo.
Azure Admin Starter Kit (Free Download)
Get my KQL cheat sheet, 50 Windows + 50 Linux commands, and an Azure RACI template in one free bundle.
Get the Starter Kit →💰 Stop Guessing at Azure Costs
Get the Azure FinOps Framework with cost allocation templates, tag governance policies, and showback dashboards that finance actually understands.
Download FinOps FrameworkExcel template • KQL queries included • No email required