Module 5: Apply On Merge The Cd Half

4. Hands-on: building `apply.yml`

Description

This is the lesson where apply.yml exists complete, for the first time, and runs end-to-end with act push — the same command a real push to main would trigger on a GitHub repository. You're going to see the previous lesson's two jobs working together: fetch-reviewed-plan downloading the exact file ci.yml uploaded, and terraform-apply really attempting to write those twelve resources against LocalStack. The attempt is real. The result, without a valid LOCALSTACK_AUTH_TOKEN, is an honest failure — read with the same care you already learned to read these failures since Module 2.

Connection to the module

This lesson doesn't introduce any new concept — it's the integration of lesson 2 (on: push: branches: [main]) and lesson 3 (needs:, upload-artifact/download-artifact) into a single file, run for real. Lesson 5 picks this same file back up to add a piece it's still missing: protection against two simultaneous runs.


The complete apply.yml

.github/workflows/apply.yml, inside andes-cargo-infra/:

name: apply

on:
  push:
    branches: [main]

jobs:
  fetch-reviewed-plan:
    runs-on: ubuntu-latest
    steps:
      - name: Download the plan reviewed in the pull request
        uses: actions/download-artifact@v4
        with:
          name: terraform-plan

      - name: Confirm the plan file arrived intact
        run: |
          test -s tfplan
          echo "tfplan is present: $(wc -c < tfplan) bytes"

  terraform-apply:
    needs: fetch-reviewed-plan
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: test
      AWS_SECRET_ACCESS_KEY: test
      AWS_DEFAULT_REGION: us-east-1
      AWS_ENDPOINT_URL: http://host.docker.internal:4566
    steps:
      - name: Check out andes-cargo-infra
        uses: actions/checkout@v4

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.15.8"

      - name: Download the plan reviewed in the pull request
        uses: actions/download-artifact@v4
        with:
          name: terraform-plan

      - name: Terraform init
        run: terraform init -input=false

      - name: Install tflocal
        run: pip3 install --quiet --break-system-packages terraform-local

      - name: Terraform apply
        run: tflocal apply -input=false -auto-approve tfplan

Two jobs, two Stages (confirmed with act -l in the previous lesson), a single purpose: apply exactly the plan that was already reviewed. Notice the last step —tflocal apply -input=false -auto-approve tfplan—: unlike every terraform plan/apply you ran by hand in terraform-and-iac-guide, this command doesn't ask anyone for confirmation (-auto-approve) and doesn't recalculate anything (it applies the tfplan file as-is, the same one you downloaded in the previous step) — it's, literally, the automated equivalent of a person already having reviewed the plan and said yes.


Prerequisite: ci.yml already has to have uploaded a plan

apply.yml downloads an artifact called terraform-plan — if you never ran lesson 3's extended ci.yml (with -out=tfplan and upload-artifact), there's nothing to download. Before continuing, confirm that artifact exists, running ci.yml once, exactly like in lesson 3:

export ARTIFACT_ADDR=$(ipconfig getifaddr en0)   # Linux: hostname -I | awk '{print $1}'

act pull_request -e .github/act-events/pr-event.json -j terraform-checks \
  --artifact-server-path ./.artifacts \
  --artifact-server-addr "$ARTIFACT_ADDR"

What to expect (literal, summary — you already saw the plan's complete output in Module 3):

[ci/terraform-checks]   | Plan: 12 to add, 0 to change, 0 to destroy.
[ci/terraform-checks]   | Saved the plan to: tfplan
[ci/terraform-checks] ⭐ Run Main Upload the plan for apply.yml to use later
[ci/terraform-checks]   | Artifact terraform-plan has been successfully uploaded! Final size is 12484 bytes.
[ci/terraform-checks] 🏁  Job succeeded

With this, the ./.artifacts/1/terraform-plan/ folder already has the file apply.yml is going to look for.


Running it: act push, simulating the merge to main

act push -W .github/workflows/apply.yml \
  --artifact-server-path ./.artifacts \
  --artifact-server-addr "$ARTIFACT_ADDR"

-W .github/workflows/apply.yml tells act to run specifically this file — without it, act push would try to run every workflow in this repository that listens for push (apply.yml, and also Module 2's hello-andes-cargo.yml), something you don't want right now. You already know the rest of the command from lesson 3: the same --artifact-server-path/--artifact-server-addr that solved the local artifact server's networking problem.

What to expect (literal output, executed to write this lesson — the complete fetch-reviewed-plan job, succeeding):

[apply/fetch-reviewed-plan] ⭐ Run Set up job
[apply/fetch-reviewed-plan]   ✅  Success - Set up job
[apply/fetch-reviewed-plan] ⭐ Run Main Download the plan reviewed in the pull request
[apply/fetch-reviewed-plan]   | Preparing to download the following artifacts:
[apply/fetch-reviewed-plan]   | - terraform-plan (ID: 2119430229, Size: 96, Expected Digest: undefined)
[apply/fetch-reviewed-plan]   | Redirecting to blob download url: http://192.168.100.35:34567/twirp/github.actions.results.api.v1.ArtifactService/DownloadArtifact
[apply/fetch-reviewed-plan]   | SHA256 digest of downloaded artifact is 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/fetch-reviewed-plan]   | Artifact download completed successfully.
[apply/fetch-reviewed-plan]   | Total of 1 artifact(s) downloaded
[apply/fetch-reviewed-plan]   ✅  Success - Main Download the plan reviewed in the pull request [972.244958ms]
[apply/fetch-reviewed-plan] ⭐ Run Main Confirm the plan file arrived intact
[apply/fetch-reviewed-plan]   | tfplan is present: 15826 bytes
[apply/fetch-reviewed-plan]   ✅  Success - Main Confirm the plan file arrived intact [119.824ms]
[apply/fetch-reviewed-plan] 🏁  Job succeeded

(The 192.168.100.35 address in the download URL is the local IP of the machine this lesson was written on — variable, yours is going to be different, exactly the one you exported as ARTIFACT_ADDR.)

Stage 0 finished green. act automatically moves on to Stage 1terraform-apply, which also needs the same artifact (Module 5, lesson 3: every job runs in its own container):

[apply/terraform-apply    ] ⭐ Run Main Check out andes-cargo-infra
[apply/terraform-apply    ]   ✅  Success - Main Check out andes-cargo-infra [39.331208ms]
[apply/terraform-apply    ] ⭐ Run Main Set up Terraform
[apply/terraform-apply    ]   ✅  Success - Main Set up Terraform [3.2910645s]
[apply/terraform-apply    ] ⭐ Run Main Download the plan reviewed in the pull request
[apply/terraform-apply    ]   | Preparing to download the following artifacts:
[apply/terraform-apply    ]   | - terraform-plan (ID: 2119430229, Size: 96, Expected Digest: undefined)
[apply/terraform-apply    ]   | SHA256 digest of downloaded artifact is 17f7ebe9b774bb3520631bf3e252311300139416f0082e95103304af6e033900
[apply/terraform-apply    ]   | Artifact download completed successfully.
[apply/terraform-apply    ]   ✅  Success - Main Download the plan reviewed in the pull request [970.303916ms]
[apply/terraform-apply    ] ⭐ Run Main Terraform init
[apply/terraform-apply    ]   | Initializing the backend...
[apply/terraform-apply    ]   | Initializing modules...
[apply/terraform-apply    ]   | Initializing provider plugins...
[apply/terraform-apply    ]   | - Installing hashicorp/aws v6.60.0...
[apply/terraform-apply    ]   | Terraform has been successfully initialized!
[apply/terraform-apply    ]   ✅  Success - Main Terraform init [35.464288875s]
[apply/terraform-apply    ] ⭐ Run Main Install tflocal
[apply/terraform-apply    ]   ✅  Success - Main Install tflocal [8.011445042s]
[apply/terraform-apply    ] ⭐ Run Main Terraform apply

Up to here, everything worked exactly as expected: the downloaded artifact's SHA256 matches, in both jobs, the one ci.yml uploaded17f7ebe9b774..., identical in fetch-reviewed-plan and in terraform-apply—. It's the direct confirmation of this lesson's thesis: it's not a new plan, it's exactly the same file, verified bit by bit.


Reading the failure: the real terraform apply attempt

Here's where, without a valid LOCALSTACK_AUTH_TOKEN, the job turns red — the same honest way Module 2's awslocal (lesson 8) and Module 3's awslocal (lesson 5) already failed, but with one important difference: this time it's Terraform's own AWS provider retrying, not awslocal/boto3.

What to expect (literal output, executed to write this lesson):

[apply/terraform-apply    ]   | module.app_server_role.aws_iam_role.this: Creating...
[apply/terraform-apply    ]   | module.lambda_manifest_processor_role.aws_iam_role.this: Creating...
[apply/terraform-apply    ]   | aws_dynamodb_table.shipments: Creating...
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Creating...
[apply/terraform-apply    ]   | module.lambda_manifest_processor_role.aws_iam_role.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.app_server_role.aws_iam_role.this: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | aws_dynamodb_table.shipments: Still creating... [00m10s elapsed]
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m20s elapsed]
[apply/terraform-apply    ]   | module.app_server_role.aws_iam_role.this: Still creating... [00m20s elapsed]
[apply/terraform-apply    ]   | module.lambda_manifest_processor_role.aws_iam_role.this: Still creating... [00m20s elapsed]
[apply/terraform-apply    ]   | aws_dynamodb_table.shipments: Still creating... [00m20s elapsed]
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m30s elapsed]
[apply/terraform-apply    ]   | module.shipment_docs_bucket.aws_s3_bucket.this: Still creating... [00m40s elapsed]
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating AWS DynamoDB Table (Shipments): operation error DynamoDB: CreateTable, exceeded maximum number of attempts, 9, https response error StatusCode: 0, RequestID: , request send failed, Post "http://host.docker.internal:4566/": dial tcp 192.168.65.254:4566: connect: connection refused
[apply/terraform-apply    ]   | │
[apply/terraform-apply    ]   | │   with aws_dynamodb_table.shipments,
[apply/terraform-apply    ]   | │   on dynamodb.tf line 1, in resource "aws_dynamodb_table" "shipments":
[apply/terraform-apply    ]   | │    1: resource "aws_dynamodb_table" "shipments" {
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating IAM Role (AppServerRole): operation error IAM: CreateRole, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating IAM Role (LambdaManifestProcessorRole): operation error IAM: CreateRole, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   | ╷
[apply/terraform-apply    ]   | │ Error: creating S3 Bucket (andes-cargo-shipment-docs): operation error S3: CreateBucket, exceeded maximum number of attempts, 9, ... connect: connection refused
[apply/terraform-apply    ]   | ╵
[apply/terraform-apply    ]   ❗  ::error::Terraform exited with code 1.
[apply/terraform-apply    ]   ❌  Failure - Main Terraform apply [1m3.705523875s]
[apply/terraform-apply    ] exitcode '1': failure
[apply/terraform-apply    ] 🏁  Job failed
Error: Job 'terraform-apply' failed

Read it with the same analytical care you already practiced before, because there's a lot of honest information in this failure:

  • The four resources Terraform tried to create in parallel —the Shipments table, the two IAM roles, the bucket— are exactly Andes Cargo's four root resources, with no dependencies between them (the other resources, like the Lambda function or the inline policies, depend on these four existing first, so Terraform didn't even get around to trying them).
  • exceeded maximum number of attempts, 9 — unlike awslocal (which retries per botocore's policy), Terraform's AWS provider retries 9 times before giving up — the reason this job took over a minute to fail (1m3.7s), much longer than the ~10 seconds you already saw in this guide's earlier failures.
  • connect: connection refused — a message slightly different from the Could not connect to the endpoint URL you already know from awslocal/boto3, but exactly the same phenomenon: the name host.docker.internal resolved correctly (otherwise the error would be a DNS one, not a connection one), the network attempt was real, and nothing was listening on the other end — LocalStack isn't running, the same cause as always.
  • The four errors show up together, not one at a time — because Terraform creates resources with no dependencies between them in parallel, not in sequence; the four attempts failed almost simultaneously, and Terraform reports them all together at the end, not as soon as the first one occurs.

Confirm it independently:

docker ps -a --filter name=localstack_main

What to expect (literal): no rows at all — LocalStack isn't running, exactly the same root cause you already diagnosed in Module 2 and Module 3.


The version you'd see with a valid token (representative)

With LOCALSTACK_AUTH_TOKEN correctly exported and LocalStack started on your host (Module 1, lesson 8, Step 5), that same tflocal apply -input=false -auto-approve tfplan would end like this:

What to expect (representative — the same success pattern already confirmed, in spirit, by every successful terraform apply from terraform-and-iac-guide; without a live run against a valid token at this moment):

Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

Outputs:

process_shipment_manifest_function_name = "process-shipment-manifest"
shipment_docs_bucket_arn = "arn:aws:s3:::andes-cargo-shipment-docs"
shipments_table_name = "Shipments"

Twelve resources created, zero changed, zero destroyed — the same count the plan from lesson 3 promised, applied exactly as-is, without anyone typing terraform apply by hand at any point in this guide.


Common mistakes

Running act push without -W, and having extra workflows run (workflow-based). What happens: someone runs plain act push inside andes-cargo-infra/, expecting only apply.yml to run, and instead also sees hello-andes-cargo.yml (Module 2) trying to execute, because both listen for push. How to spot it: two separate job sections show up in the output, with workflow names you weren't expecting. How to fix it: use -W .github/workflows/apply.yml to scope the run to a single file, as in this lesson.

Forgetting --artifact-server-path/--artifact-server-addr when running apply.yml (configuration-based, revisit lesson 3). What happens: someone runs act push -W .github/workflows/apply.yml without those two flags, and the fetch-reviewed-plan job fails immediately with Unable to get the ACTIONS_RUNTIME_TOKEN env variable or another artifact-related error. How to fix it: these two flags are mandatory on every act invocation in this guide that uses upload-artifact/download-artifact, for both ci.yml and apply.yml — they aren't optional or specific to a single lesson.

Interpreting the terraform apply failure as a problem with the downloaded plan (expectation-based, the most important one in this lesson). What happens: someone sees red Job failed and suspects the artifact arrived corrupted, or that the plan had some error. How to spot it: check the exact message — connection refused after real retries, on the four root resources, is the specific signature of "LocalStack isn't running," not of a problem with the tfplan file. The SHA256 that matches between both jobs (visible in the logs) already confirmed the file arrived intact. How to fix it: docker ps -a --filter name=localstack_main is, once again, the correct first diagnostic command.


Exercises

Exercise 1 — Reconstruct the Stage sequence from memory. Without looking at this lesson, describe which job runs first in apply.yml, what happens if that first job fails, and why the second job downloads the same artifact again instead of reusing the first one's.

See solution

fetch-reviewed-plan runs first (Stage 0, no needs:). If it fails —for example, if the artifact was never uploaded—, terraform-apply (Stage 1, with needs: fetch-reviewed-plan) doesn't run at all, avoiding any attempt to apply without first confirming the plan exists. terraform-apply downloads the file again because every job runs in its own Docker container, with no shared filesystem with other jobs in the same workflow — the artifact mechanism is, precisely, the way to move a file from one container to another.

Exercise 2 — Explain the "9 attempts" to a colleague. A colleague, familiar with earlier modules' awslocal failures, asks you why this failure took over a minute, when awslocal's took only about ten seconds. Answer them precisely.

See solution

A complete answer sounds, roughly, like this: "Terraform's AWS provider, written in Go, has its own retry policy, different from botocore/awslocal's in Python — it retries up to 9 times with exponential backoff before giving up, instead of awslocal's typical 3-4 attempts. Also, Terraform tried to create the four root resources in parallel, so you saw Still creating... for several resources at the same time while each one separately exhausted its own retries, before Terraform reported all four errors together at the end."

Exercise 3 — Verify the plan's integrity with your own eyes. Without looking at this lesson, which line in act's output lets you confirm that terraform-apply received exactly the same file fetch-reviewed-plan downloaded first, with nobody modifying it in between?

See solution

The line SHA256 digest of downloaded artifact is ... — it shows up in both jobs, with the exact same hexadecimal value in both (17f7ebe9b774... in this lesson's example). An identical SHA256 across both downloads is the cryptographic proof that the tfplan file terraform-apply used to apply is, bit for bit, the same one ci.yml originally uploaded — no reprint, no recalculation in between.


Summary and next step

In this lesson you built the complete apply.yml and ran it with act push -W .github/workflows/apply.yml: Stage 0 (fetch-reviewed-plan) downloaded the artifact successfully, confirmed by SHA256; Stage 1 (terraform-apply) checked out, installed Terraform, downloaded the same artifact again, ran terraform init successfully, and finally really attempted tflocal apply -auto-approve tfplan — a real attempt, with real retries from the AWS provider (9 attempts, over a minute), which failed with connection refused because LocalStack isn't running on this machine. You also saw, representatively, the result you'd get with a valid token: Apply complete! Resources: 12 added.

Before moving on you should be able to: run the complete apply.yml with act push, with the correct artifact flags; read a terraform apply failure distinguishing a networking problem from one in the plan itself; and explain why this job, unlike Module 3's terraform plan, can't complete without a real connection to LocalStack.

Lesson 5 adds the last piece of robustness apply.yml still lacks: what happens if two pushes to main trigger two runs of this same workflow at the same time.

Resources

  1. Terraform Docs — Command: apply — official reference for terraform apply, including using a saved plan file.
  2. nektosact.com — User Guide — reference for act push -W, used to scope the run to a single workflow.
  3. AWS SDK for Go — Retry behavior — Terraform's AWS provider's retry policy, the reason for this lesson's "9 attempts."
  4. This guide's Module 5 (03-job-dependencies-with-needs.md) — the complete needs: and upload-artifact/download-artifact mechanism this file integrates.