Skip to the content.

📚 Documentation: Self-service provisioning ➜ · Setup guide · Contributing · Pages info · Source on GitHub


Workshop · Platform Engineering

A self-service platform that provisions Azure infrastructure, driven by GitHub Actions and Terraform. External systems trigger the platform through repository_dispatch or workflow_dispatch events; the platform takes care of standing up secure, observable, production-grade environments without the requesting team having to write any infrastructure code.

The platform supports two operating modes in a single workflow:

Status: functional end-to-end. Plan → apply → verify, application repo creation from template, GitHub Environments + variables, OIDC federated credentials, CI observation and per-run tracking issue are all wired. See Roadmap for what’s next.


What this platform does

Given an existing Azure subscription, the platform provisions an opinionated Azure App Service (Linux, container) stack for an application across three environments — dev, staging, prod — following Microsoft’s well-architected guidance for security, observability, and connectivity:


Architecture: Decoupled App & Infra Templates

The platform separates application code from infrastructure code, and the application phase is entirely optional:

Component Template Repo naming Role
Infra template-terraform-azure-webapp {app-name}-infra IaC: VNet, App Service, monitoring, etc. Terraform modules. Always runs.
App Code (optional) template-helloworld-express {app-name} Runtime: Node.js, Python, Java, etc. Owns CI/CD. Runs only when app_template_repo is set.

When app_template_repo is provided (full mode), the platform:

  1. Creates {app-name}-infra from the infra template (Terraform) ← infrastructure provisioning
  2. Runs Terraform against the infra repo to stand up Azure resources
  3. Creates {app-name} from the app template (e.g., Node.js starter) ← app CI/CD logic
  4. Sets GitHub environment variables and federated credentials on the app repo

When app_template_repo is omitted (infra-only mode), steps 3 and 4 are skipped — useful for Landing Zones, foundational platform components, or any scenario where application code is managed separately.

This decoupling means:

Infrastructure Templates (Archetypes)

Each infra template is a self-contained Terraform module set covering an infrastructure pattern:

When provisioning, you specify which infra template to use as an input parameter to the provision-infrastructure workflow.

State management

State is kept in Azure Storage, with one storage account per subscription + application so that unrelated apps sharing a subscription remain decoupled. The state account is AAD-auth-only — no shared keys.

Beyond the Azure side, the platform also takes care of the application repository: it creates a new repo from a template you choose, configures GitHub Environments + variables, registers the per-env OIDC federated credentials on the platform service principal, observes the auto-triggered CI run, and writes a per-run issue summarising plan deltas and verification test counts.

Architecture at a glance

operator                ┌─────────────────────────────────────────────┐
  ├─ web UI (Pages) ──► │  GitHub Actions: provision-infrastructure   │
  ├─ trigger script ──► │                                             │
  └─ raw curl ────────► │   1. resolve & validate inputs              │
                        │   2. checkov scan (per env)                 │
                        │   3. terraform fmt                          │
                        │   4. bootstrap tfstate (reusable)           │
                        │   5. terraform plan (per env)               │
                        │   6. terraform apply (per env)              │
                        │   7. verify (reusable, per env)             │
                        │   8. create app repo from template          │
                        │   9. open per-run tracking issue            │
                        │  10. configure GitHub Environments + vars   │
                        │  11. register OIDC fed-creds on SP          │
                        │  12. observe CI run (first creation only)   │
                        │  13. summarise + comment on the issue       │
                        └────┬────────────────────────────────┬───────┘
                             │                                │
            OIDC, no secrets │                                │ GH_PAT
                             ▼                                ▼
      ┌──────────────────────────────────┐  ┌────────────────────────────────┐
      │ Azure subscription               │  │  GitHub: app repo              │
      │  ├── rg-tfstate-<app>            │  │   ├── from <app_template_repo> │
      │  │     └── sttf<app><sub>        │  │   ├── envs: dev/staging/prod   │
      │  │           └── tfstate/{env}/  │  │   ├── per-env variables        │
      │  └── rg-<app>-{dev|stg|prod}     │  │   ├── ci.yml — build & test &  │
      │        ├── networking + flow log │  │   │   deploy to dev triggered  │
      │        ├── monitoring (LA, AI)   │  │   │   by push on main          │
      │        └── webapp + PE + slot    │  │   ├── release.yml — deploy to  │
      │                                  │  │   │   staging/prod triggered   │
      │                                  │  │   │   by new release creation  │
      │                                  │  │   └── per-run issue + summary  │
      └──────────────────────────────────┘  └────────────────────────────────┘

Repository layout

.
├── .checkov.nonprod.yaml               # Relaxed skips for dev/staging
├── .checkov.yaml                       # Checkov rules + skips for prod (strict)
├── .github/workflows/
│   ├── bootstrap-tfstate.yml           # Reusable: create the tfstate storage
│   ├── delete-app-resources-all.yml    # Manual: delete all env RGs + tfstate for an app
│   ├── delete-app-resources-single.yml # Manual/callable: delete one env RG for an app
│   ├── delete-resource-group.yml       # Reusable: delete a single Azure resource group
│   ├── detect-drift.yml                # Scheduled/callable: read-only drift sweep
│   ├── provision-infrastructure.yml    # Main workflow: end-to-end pipeline
│   ├── tag-app-resources.yml           # Manual: merge compliance tags onto all app resources
│   └── verify-infrastructure.yml       # Reusable: runs the infra repo's verify script
├── docs/                               # GitHub Pages site (Jekyll-rendered)
│   ├── _config.yml                     # Jekyll config
│   ├── CONTRIBUTING.md                 # Contribution guidelines (Pages mirror)
│   ├── index.md                        # Pages homepage (this file)
│   ├── PAGES.md                        # Pages site info & how to enable
│   ├── provision.html                  # Self-service provisioning form
│   └── SETUP.md                        # Full setup guide
├── scripts/
│   ├── bootstrap-tfstate.sh            # Idempotent az-cli bootstrap script
│   ├── tag-app-resources.sh            # Merge an arbitrary tag set onto all app resources
│   ├── trigger-provision.sh            # CLI wrapper around repository_dispatch
│   └── watch-run.sh                    # Poll a remote workflow run + outputs

Control-plane verification is no longer defined here. It lives in each infra template at the canonical path scripts/verify.sh, so the orchestrator stays template-agnostic — verify-infrastructure.yml checks out the provisioned infra repo and runs that script.

Quick start

A full step-by-step setup (App Registration, federated credentials, RBAC, Graph permission, GitHub Environments, GH_PAT secret) lives in the Setup guide. At a glance:

  1. Push this repo to GitHub.
  2. Create the GitHub Environments dev, staging, prod.
  3. Create an Azure App Registration and configure 4 federated credentials on the platform repo — one branch-scoped (main) and three env-scoped.
  4. Grant the service principal three subscription-scoped roles (Contributor, Storage Blob Data Contributor, User Access Administrator) and the Graph application permission Application.ReadWrite.OwnedBy so it can manage federated credentials on the application repos it provisions.
  5. Add a GH_PAT repo secret with Contents/Administration/Actions/ Environments/Variables/Issues write permissions for cross-repo work.
  6. Trigger the workflow — by the self-service web UI, by scripts/trigger-provision.sh, or by the GitHub Actions UI / API directly.

Triggering the platform

The provisioning form explains the platform, validates inputs in-browser, shows the equivalent curl command for review, and dispatches the workflow with a token the operator pastes. See Pages info for how the page is hosted and what token scope the operator needs.

CLI

scripts/trigger-provision.sh \
      --app-name               myapp \
      --environment            dev \
      --azure-tenant-id        22222222-2222-2222-2222-222222222222 \
      --azure-subscription-id  00000000-0000-0000-0000-000000000000 \
      --azure-client-id        11111111-1111-1111-1111-111111111111 \
      --infra-template-repo    your-org/template-terraform-azure-webapp \
      --app-template-repo      your-org/template-helloworld-express

Flags fall back to upper-case env vars (ENVIRONMENT, APP_NAME, …) and the script auto-detects the platform repo from the current git remote. --help for the full reference.

GitHub UI

Actions → Provision Infrastructure → Run workflow and fill the inputs.

Raw repository_dispatch

curl -X POST \
      -H "Authorization: Bearer $GH_TOKEN" \
      -H "Accept: application/vnd.github+json" \
      https://api.github.com/repos/<org>/<repo>/dispatches \
      -d '{
            "event_type": "provision-infrastructure",
            "client_payload": {
                  "app_name":               "myapp",
                  "environment":            "dev",
                  "azure_tenant_id":        "22222222-2222-2222-2222-222222222222",
                  "azure_subscription_id":  "00000000-0000-0000-0000-000000000000",
                  "azure_client_id":        "11111111-1111-1111-1111-111111111111",
                  "infra_template_repo":    "your-org/template-terraform-azure-webapp",
                  "infra_template_ref":     "v1.2.0",
                  "app_template_repo":      "your-org/template-helloworld-express",
                  "app_template_ref":       "v2.0.1",
                  "container_image":        "mcr.microsoft.com/appsvc/staticsite:latest",
                  "container_registry_url": "myregistry.azurecr.io"
            }
      }'

The token must have repo scope (classic) or Contents: write permission (fine-grained) on the platform repository. Actions: write is not enough for the repository_dispatch endpoint.

Compliance tagging

The tag-app-resources.yml workflow merges an arbitrary set of tags onto every Azure resource that belongs to an app. It is a day-2 operation — run it any time after resources exist to back-fill governance metadata, adopt a new tagging standard, or correct values without reprovisioning.

What it tags

The workflow discovers all resource groups for the app and tags each group and every resource inside it:

Resource group pattern Purpose
rg-<app_name>-dev / staging / prod Per-environment application resources
rg-tfstate-<app_name> Terraform state storage

Tags are merged — existing tags not present in tags_json are left unchanged. Re-running the workflow with updated values is safe.

Inputs

Input Required Description
app_name Yes Application name — used to discover resource groups
azure_tenant_id Yes Azure Tenant ID
azure_subscription_id Yes Azure Subscription ID
azure_client_id Yes Client ID of the OIDC service principal
tags_json Yes JSON object whose keys/values become the tags, e.g. {"airid":"309005","Application":"myapp","CreatedBy":"user"}
dry_run No (default: false) When true, discovers and lists all resources that would be tagged without writing anything

How to run

Actions → Tag Application Resources → Run workflow, fill the form, and paste the JSON tags object. Use dry_run: true first to preview the full scope — the step summary shows the tag list and the run log shows every resource that would be tagged.

The workflow authenticates to Azure via OIDC (same service principal used by provision-infrastructure) — no client secret is required.

Running locally

The underlying script (scripts/tag-app-resources.sh) can also be run directly against an active az login session:

az login --tenant <AZURE_TENANT_ID>
az account set --subscription <AZURE_SUBSCRIPTION_ID>

scripts/tag-app-resources.sh \
  --app-name               myapp \
  --azure-tenant-id        <tenant-guid> \
  --azure-subscription-id  <sub-guid> \
  --azure-client-id        <client-guid> \
  --tags-json              '{"airid":"309005","Application":"myapp","CreatedBy":"user"}' \
  --dry-run

Remove --dry-run to apply. Requires az and jq.


Conventions

Local development

Terraform now lives in the generated {app-name}-infra repository (from infra_template_repo). To inspect it locally, clone that repo and run checks there. State writes should always go through CI:

git clone https://github.com/<org>/<app-name>-infra.git
cd <app-name>-infra/terraform/environments/dev
terraform fmt -check -recursive ../..
terraform init -backend=false                  # local-only, no remote state
terraform validate

To run Checkov locally:

pip install checkov
# Strict ruleset (matches what CI runs against prod)
checkov -d <app-name>-infra/terraform/environments/prod --framework terraform --config-file .checkov.yaml
# Relaxed ruleset (matches what CI runs against dev/staging)
checkov -d <app-name>-infra/terraform/environments/dev  --framework terraform --config-file .checkov.nonprod.yaml

To verify deployed infrastructure against the expected per-env policy, run the verification script that ships with the generated infra repo (an az login session must be active):

cd <app-name>-infra
APP_NAME=<app> ENVIRONMENT=<env> bash scripts/verify.sh

Roadmap

Shipped

Next

Contributing

See Contributing for development guidelines, the change workflow, and how to propose new modules or environment policies.

License

MIT — see the license file for details.