Apply and Deploy
What we’re building
Section titled “What we’re building”The plan workflow tells you what will happen. This one makes it happen. On a merge to main, a second workflow runs terragrunt run --all apply against each cloud’s live/ tree — and because the platform layer (Datadog, Keycloak, GrowthBook) and ShopMicro are themselves helm_release units in that tree, the same apply that provisions the VPC, cluster, and database also rolls out the Helm releases, in dependency order, in one pass.
We wrap that apply in three protections:
- A GitHub Environment per cloud with required reviewers, so an apply pauses for a human to approve before it touches anything.
- An OIDC apply role — separate from, and more privileged than, the read-only plan role from the last lesson.
- Secrets (the Datadog API key, DB passwords, the Keycloak admin) injected as
TF_VAR_*environment variables scoped to the environment, never printed, never committed.
By the end you’ll have .github/workflows/apply.yml: merge, approve, and the platform reconciles itself across three clouds.
Apply-on-merge makes the git repo the single source of truth: the state of the three clouds is whatever main says it is, reconciled automatically. That’s the payoff of putting everything — network, cluster, data, IAM, platform Helm, and ShopMicro — into the Terragrunt dependency graph. run --all apply walks that graph so the database exists before ShopMicro’s helm_release reads its connection string, and the cluster exists before Datadog’s agent lands on it. No “apply the infra, then remember to run Helm” second step to forget.
But apply is not plan. It creates, changes, and destroys real, billable resources, and a bad merge can take the platform down. So the design is deliberately slower on purpose: an approval gate stands between merge and mutation, and the credentials that can mutate live only inside that gated environment. You trade a few seconds of “click approve” for the guarantee that no apply ever runs unwatched.
Pros & cons
Section titled “Pros & cons”Apply through the Terragrunt graph vs. a separate helm upgrade step
- Pros: One command, one dependency order, one source of truth. The database’s
db_passwordoutput flows straight into ShopMicro’shelm_releasevalues without a human copying it; Datadog can’t deploy before its cluster exists. Rollback is “revert the PR and re-apply.” - Cons: Helm rollouts are now coupled to Terraform state — a flaky chart install shows up as a
terragrunt applyfailure, and you reason about app deploys and infra in the same place. Teams that want to ship app changes ten times a day, decoupled from infra, often split ShopMicro into its own CD pipeline (Argo/Flux) — noted in Wrap-up.
Required-reviewer environments vs. fully automatic apply
- Pros: A human sees the merged plan and clicks approve before anything changes; the environment scopes its secrets and its apply role so they only exist during an approved deployment. This is the standard guardrail for production.
- Cons: It’s not continuous deployment — every merge waits on a person, which is friction you feel at 2am. For a low-stakes environment you might drop the gate; for three production clusters, the friction is the feature.
Set it up
Section titled “Set it up”1. GitHub Environments with required reviewers
Section titled “1. GitHub Environments with required reviewers”In the repo’s Settings → Environments, create one environment per cloud — aws-production, gcp-production, azure-production. On each, enable Required reviewers (add yourself or the infra team; up to six users/teams, and only one must approve). Store that cloud’s secrets on the environment, not on the repo — so they’re only readable by a job that names the environment, and only after approval:
aws-production:DATADOG_API_KEY,KEYCLOAK_ADMIN_PASSWORD- …and the same for
gcp-productionandazure-production.
The OIDC role/SA identifiers (which are not secrets) live in environment variables: AWS_APPLY_ROLE_ARN, GCP_WIF_PROVIDER, GCP_APPLY_SA, AZURE_CLIENT_ID, and so on. The apply role is the read-write counterpart to the plan role — it can create and destroy; scope it as tightly as the platform allows (this is the least-privilege work from Identity & IAM).
2. .github/workflows/apply.yml
Section titled “2. .github/workflows/apply.yml”The workflow triggers on push to main, names the per-cloud environment (which is what triggers the approval prompt), and pins a concurrency group so two applies to the same cloud can never overlap:
name: apply
on: push: branches: [main]
permissions: id-token: write # OIDC JWT contents: read
jobs: apply: name: apply (${{ matrix.cloud }}) runs-on: ubuntu-latest # Naming the environment is what makes the job wait for a required reviewer. environment: ${{ matrix.cloud }}-production # Never let two applies to the same cloud run at once; never cancel one mid-apply. concurrency: group: apply-${{ matrix.cloud }} cancel-in-progress: false strategy: fail-fast: false matrix: cloud: [aws, gcp, azure] # Secrets scoped to the environment, exposed only as Terraform vars. env: TF_VAR_datadog_api_key: ${{ secrets.DATADOG_API_KEY }} TF_VAR_keycloak_admin_password: ${{ secrets.KEYCLOAK_ADMIN_PASSWORD }} steps: - uses: actions/checkout@v6
- name: Auth to AWS if: matrix.cloud == 'aws' uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_APPLY_ROLE_ARN }} # read-write role aws-region: us-east-1
- name: Auth to GCP if: matrix.cloud == 'gcp' uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} service_account: ${{ vars.GCP_APPLY_SA }}
- name: Auth to Azure if: matrix.cloud == 'azure' uses: azure/login@v2 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: hashicorp/setup-terraform@v3 with: terraform_wrapper: false
- name: Install Terragrunt run: | curl -sSL -o /usr/local/bin/terragrunt \ https://github.com/gruntwork-io/terragrunt/releases/download/v0.67.4/terragrunt_linux_amd64 chmod +x /usr/local/bin/terragrunt
# Provisions infra AND rolls out the Helm releases, in dependency order. - name: terragrunt run --all apply working-directory: live/${{ matrix.cloud }} run: terragrunt run --all apply --terragrunt-non-interactiveThe apply reads real dependency outputs (not the mock outputs from the plan lesson): terragrunt run --all apply waits for network before cluster, for cluster + data before platform and shopmicro, and wires data’s sensitive db_password output straight into ShopMicro’s chart values without it ever appearing in a log.
3. Secrets handling
Section titled “3. Secrets handling”Three rules keep credentials out of trouble:
- Environment-scoped, not repo-scoped. Secrets live on
aws-productionetc., so only an approved job for that cloud can read them. - Injected as
TF_VAR_*, consumed assensitivevariables. Declare the matching Terraform variablessensitive = trueso Terraform redacts them in plan/apply output and in state diffs. - The best secret is the one GitHub never holds. For anything the cluster itself can fetch — a DB password, a Datadog key — prefer having the workload pull it from the cloud’s secret manager (AWS Secrets Manager / GCP Secret Manager / Azure Key Vault) via the workload identity from Identity & IAM, so the value never round-trips through CI at all.
Verify
Section titled “Verify”Merge a PR that changes one cloud (say, bump ShopMicro’s replica count in live/aws/shopmicro). In the Actions tab the apply (aws) job starts and immediately pauses: “Waiting for review.” Approve it. The apply then runs terragrunt run --all apply, and you’ll see it reconcile in dependency order — infra first, then the Helm releases.
Once it’s green, confirm the workload and platform are actually live on the cluster it just touched:
aws eks update-kubeconfig --name clouddeploy --region us-east-1kubectl get pods -n shopmicrokubectl get pods -n platformExpected output: ShopMicro’s pods Running, and the platform namespace showing the Datadog agent, Keycloak, and GrowthBook — the same helm_releases the apply just rolled out:
NAMESPACE NAME READY STATUS RESTARTS AGEshopmicro shopmicro-gateway-7c9f... 1/1 Running 0 2mshopmicro shopmicro-web-5b8d... 1/1 Running 0 2mplatform datadog-agent-abcde 1/1 Running 0 3mplatform keycloak-0 1/1 Running 0 3mplatform growthbook-6f7a... 1/1 Running 0 3mThree pods Running that a terragrunt run --all apply put there — infra and app deployed by one gated, approved command.
Check your understanding
Section titled “Check your understanding”- Why does naming a GitHub Environment on the job cause the apply to wait, and what would happen to the approval gate if you removed the
environment:key? run --all applydeploys both the RDS database and ShopMicro’s Helm release. Why must Terragrunt apply them in that order, and how does ShopMicro get the DB password without a human copying it?- What does
concurrencywithcancel-in-progress: falseprotect against on the apply workflow specifically? - Give the strongest version of secrets handling here — the one where GitHub never stores the Datadog key at all. How does that work?
Merge-to-main is now a real deployment: terragrunt run --all apply over each cloud’s live/ tree provisions the infrastructure and rolls out the Datadog / Keycloak / GrowthBook / ShopMicro Helm releases in one dependency-ordered pass — behind a GitHub Environment with required reviewers, using a read-write OIDC apply role, with secrets scoped to the environment and marked sensitive. Plan-on-PR shows the diff; apply-on-merge makes it real.
You can now change three clouds from a pull request. The last question is what it’s like to run that platform day to day — state, drift, cost, and how to tear it all down. That’s the next module: Multi-cloud in Practice →.