AWS Builder Center
Terraform Testing Without AWS: Build an Offline Infrastructure Validation Pipeline

Terraform Testing Without AWS: Build an Offline Infrastructure Validation Pipeline

A hands-on guide to Terraform validation, mock-provider testing, TFLint, Checkov, offline planning, and GitHub Actions—without provisioning AWS resources.

Catch infrastructure mistakes before a single cloud resource exists.
Repository: https://github.com/manishpcp/terraform-offline-testing
Most Terraform tutorials teach you to write code and then run terraform apply to find out whether it works. That is the most expensive way to learn you made a typo. In this hands-on lab you will build a small Terraform repository where every layer of checking runs without creating anything in AWS, and then wire those checks into GitHub Actions so a pull request cannot merge when they fail.
You will do all of it on a disposable EC2 instance that has no IAM role at all. If everything in this post works, you have proven the checks need no cloud access.
What you will build
  • A reusable S3 module with typed variables, validation rules, and outputs
  • Formatting, validation, and plan checks that run offline
  • TFLint and Checkov for static and security checks
  • terraform test suites for valid inputs and expected failures, using a mock provider
  • A GitHub Actions workflow with a single gate that blocks pull requests
  • A clear list of what none of this can prove, plus a pre-deployment checklist
Time: about 90 minutes. Cost: a few cents of EC2 time, nothing else.
Versions used. Terraform 1.9 or newer is required (cross-variable validation needs 1.9, mock providers need 1.7). At the time of writing the 1.12 line is current. AWS provider 6.x (6.68.0 is the version locked in this repository), TFLint 0.58.0 with the terraform and aws rulesets, and Checkov 3.x. Pin versions in your own repos and check release pages for newer ones.

Step 1. Why terraform apply should never be your first check

apply is the last step of validation, not the first. Using it to find mistakes has real costs: slow feedback (minutes per attempt), blast radius (mistakes go live immediately), cost (billable resources created), dirty state (half-created resources on failure), name collisions (S3 names are global), and credentials everywhere (every contributor needs write access just to check their work).
A better mental model is a ladder. Each rung is cheaper and faster than the one above it.
RungCheckNeeds credentials?Needs AWS network?Time
1terraform fmtNoNo< 1 s
2terraform validateNoNo (after init)seconds
3TFLint, CheckovNoNoseconds
4terraform test with mock providerNoNoseconds
5terraform plan offlineNo (dummy)Noseconds
6terraform plan real accountRead accessYestens of seconds
7apply sandboxWrite accessYesminutes
This lab covers rungs 1–5 completely.
Here is a preview of which layer catches which mistake.
MistakeFirst layer that catches it
Inconsistent formattingterraform fmt -check
Typo in a resource attributeterraform validate
Uppercase or reserved bucket nameVariable validation, negative test
Unused or undocumented variableTFLint
Public ACL, missing encryption, open SSHCheckov
Bucket policy missing TLS-only denyUnit test
A change that would destroy a resourcePlan JSON guard
Bucket name already takenReal apply only
Missing IAM permission or SCP denialReal plan or apply only

Step 2. Build the EC2 lab machine

2.1 Launch the instance

In the AWS Console, open EC2 → Launch instance and use these settings:
SettingValueWhy
Nametf-testing-lab
AMIUbuntu Server 24.04 LTSThe bootstrap script targets Ubuntu
Instance typet3.smallCheckov is happier with 2 GB of RAM
Key pairCreate new, .pem formatFor SSH
NetworkDefault VPC, auto-assign public IP: enabled
Security groupOne inbound rule: SSH (22) from My IPNever open SSH to 0.0.0.0/0
Storage20 GiB gp3Plugin caches add up
Advanced → IAM instance profileNo IAM roleThe point of the lab
Advanced → Metadata versionV2 only (token required)Hardens the metadata service
Prefer the CLI? From a machine that already has AWS credentials:
1
2
3
4
5
6
7
8
aws ec2 run-instances \
--image-id resolve:ssm:/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
--instance-type t3.small \
--key-name <your-key-pair> \
--security-group-ids <sg-that-allows-ssh-from-your-ip> \
--block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=20,VolumeType=gp3}' \
--metadata-options 'HttpTokens=required' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=tf-testing-lab}]'

2.2 Connect

1
2
chmod 400 tf-lab.pem
ssh -i tf-lab.pem ubuntu@<instance-public-ip>

2.3 Prove the instance has no cloud access

1
2
3
4
5
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
You should see 404. With a role attached you would get 200. The lab disables other credential sources via AWS_EC2_METADATA_DISABLED=true in CI.

2.4 Install the toolchain

Create the repository folder and the bootstrap script:
1
2
3
mkdir -p ~/terraform-testing-lab/scripts && cd ~/terraform-testing-lab
git init -b main
nano scripts/bootstrap-ec2.sh
Paste the contents of scripts/bootstrap-ec2.sh , save, then run it:
1
2
3
chmod +x scripts/bootstrap-ec2.sh
./scripts/bootstrap-ec2.sh
source ~/.bashrc
The script installs Terraform from the HashiCorp apt repository, TFLint as a binary, and Checkov into a virtualenv (Ubuntu 24.04 blocks system-wide pip install). It prints three version numbers at the end.
Amazon Linux 2023? Replace the Terraform apt lines with sudo dnf install -y dnf-plugins-core && sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo && sudo dnf install -y terraform.
Want to skip the typing? Everything in this post is also available as a ready-made repository at https://github.com/manishpcp/terraform-offline-testing. Upload it with scp -i tf-lab.pem terraform-testing-lab.zip ubuntu@<ip>:~ and unzip it. You will learn more by building it file by file, but the archive is a safe fallback.

Step 3. Create the reusable module

The module creates one hardened S3 bucket. S3 is a good teaching target because secure configuration is spread across several resources, which is exactly where mistakes hide.
1
mkdir -p modules/secure-bucket/tests envs/dev fixtures/insecure-bucket .github/workflows

3.1 Pin Terraform and the provider

Version constraints are your first guardrail. They make terraform init fail fast on a wrong Terraform or provider version instead of failing mysteriously later.

3.2 The resources

Things worth noticing:
  • One concern per resource. Since AWS provider v4, versioning, encryption, lifecycle, and public access settings are separate resources. Forgetting one is easy, and later steps catch exactly that.
  • local.sse_algorithm picks SSE-S3 or SSE-KMS from one input, so the choice is visible in a single place and testable.
  • The #checkov:skip comments record deliberate exceptions next to the code, with a reason. A skip without a reason is a hidden risk. A skip with one is a documented decision.
  • The depends_on lines avoid real-world ordering conflicts that no offline check can detect. That is a preview of Step 9.

3.3 Outputs

Outputs are the module's public contract. Every one has a description, and TFLint will enforce that in Step 6.

Step 4. Typed variables and validation rules

Three techniques are doing the work here.
1. Types. string, bool, number, and map(string) stop a whole class of mistakes immediately. Passing "true" to a bool or a list to a string fails before anything else runs.
2. Validation rules. Each validation block has a condition that must be true and an error_message that tells the caller how to fix it. Notice the use of can(regex(...)) to turn a failed regex into false instead of an error, and startswith, endswith, alltrue, and contains for readable checks.
3. Cross-variable rules (Terraform 1.9+). kms_key_arn, versioning_enabled, and force_destroy all look at var.environment. This lets one module enforce a policy such as "prod must use a customer-managed key, keep versioning on, and never allow force destroy". Before Terraform 1.9 a validation could only reference its own variable, so these rules had to live in precondition blocks on resources.
Write error messages for the person reading the pull request. "kms_key_arn is required when environment is prod" is something a teammate can act on. "Invalid value" is not.

Step 5. Format, initialize, validate, and plan

5.1 The root module that uses the module

The key idea is the offline variable. By default the AWS provider contacts STS to verify credentials, looks up the account ID, and may query the instance metadata service. A plan with offline = true skips all three and supplies a dummy access key, so the provider configures itself without touching the AWS network. The optional emulator_endpoint is for Step 9 and does nothing until you set it.
Terminology note. "Offline" in this lab means no AWS API calls and no AWS credentials required. The toolchain still requires internet access to download the Terraform AWS provider, TFLint plugins, and Checkov unless they are already cached. In a fully air-gapped environment you would need to pre-download and vendor these dependencies.

5.2 terraform fmt

1
2
terraform fmt -check -recursive -diff
echo "exit code: $?"
Exit code 0 means every file is canonically formatted. Now break it on purpose:
1
2
3
echo 'locals { messy = 1 }' >> envs/dev/main.tf
terraform fmt -check -recursive -diff
echo "exit code: $?"
You get a diff and exit code 3. That non-zero code is what CI will use later. Fix it two ways: terraform fmt -recursive to rewrite, or remove the line. Remove it for now:
1
2
sed -i '$ d' envs/dev/main.tf # delete the last line you appended
terraform fmt -check -recursive && echo "clean"

5.3 terraform init and validate

1
2
3
4
terraform -chdir=modules/secure-bucket init -backend=false -input=false
terraform -chdir=envs/dev init -backend=false -input=false
terraform -chdir=modules/secure-bucket validate
terraform -chdir=envs/dev validate
init downloads the AWS provider from the registry. -backend=false skips remote state. Commit both lock files immediately — they pin the exact provider version and checksums so every developer and CI run uses the same build.
1
2
git add modules/secure-bucket/.terraform.lock.hcl envs/dev/.terraform.lock.hcl
git commit -m "Add Terraform provider lock files"
Now see what validate catches. Introduce a typo:
1
2
sed -i 's/aws_s3_bucket.this.id/aws_s3_bucket.this.idd/' modules/secure-bucket/main.tf
terraform -chdir=envs/dev validate
You get an Unsupported attribute error with no AWS call involved. Restore:
1
sed -i 's/aws_s3_bucket.this.idd/aws_s3_bucket.this.id/' modules/secure-bucket/main.tf

5.4 terraform plan without credentials

A plain plan fails with No valid credential sources found — correct, the instance has no credentials. Run it offline:
1
terraform -chdir=envs/dev plan -var offline=true -input=false -out=tfplan
Abridged output:
1
2
3
4
5
6
7
8
Plan: 7 to add, 0 to change, 0 to destroy.

Changes to Outputs:
+ artifacts_bucket_arn = (known after apply)
+ artifacts_encryption = {
+ algorithm = "AES256"
+ kms_key_arn = null
}
Seven resources are planned and not one AWS API call was made. Note (known after apply): values AWS generates cannot exist yet, which limits what a plan can prove.

5.5 Make the validation rules fail on purpose

1
2
3
4
5
# prod without a KMS key
terraform -chdir=envs/dev plan -var offline=true -var environment=prod -input=false

# an invalid project name produces an invalid bucket name
terraform -chdir=envs/dev plan -var offline=true -var project=Bad_Project -input=false
The first reports kms_key_arn is required when environment is prod. The second reports the bucket naming message. Both fail during validation, before the plan graph is even built.

5.6 Read the plan as data

A saved plan converts to JSON for policy tools and scripts:
1
2
3
4
5
6
7
8
9
10
11
terraform -chdir=envs/dev show -json tfplan > envs/dev/tfplan.json

# every resource and what Terraform intends to do to it
jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"' envs/dev/tfplan.json

# the planned public access block settings
jq '.resource_changes[] | select(.type=="aws_s3_bucket_public_access_block") | .change.after' envs/dev/tfplan.json

# a guard: exit non-zero if the plan deletes anything
jq -e '[.resource_changes[] | select(.change.actions | index("delete"))] | length == 0' envs/dev/tfplan.json
echo "guard exit code: $?"
The last command is a cheap, powerful guard. In a real pipeline, a plan that deletes a database should stop and ask for a human.
Commit your work:
1
2
git add -A
git commit -m "Add secure-bucket module and dev environment"

Step 6. Static security and consistency checks

Static tools read your code without running it. Two complement each other: TFLint finds mistakes and inconsistencies, Checkov finds insecure configuration.

6.1 TFLint

  • The terraform ruleset (preset recommended) enforces language hygiene: documented variables and outputs, typed variables, no unused declarations, pinned versions.
  • The aws ruleset knows AWS-specific facts the Terraform language does not, such as invalid instance types, malformed S3 bucket names, and deprecated runtimes. It catches problems that would otherwise appear only when AWS rejects the API call during apply.
  • call_module_type = "local" makes TFLint look inside local modules.
Run it:
1
2
3
tflint --init # downloads the plugins from GitHub
tflint --chdir=modules/secure-bucket
tflint --chdir=envs/dev
No output means no findings. Now add a sloppy variable and watch it get caught:
1
2
3
4
5
6
7
8
cat >> envs/dev/variables.tf <<'EOF'

variable "debug_mode" {
type = bool
default = false
}
EOF
tflint --chdir=envs/dev; echo "exit code: $?"
You get two findings: the variable has no description (terraform_documented_variables) and it is declared but never used (terraform_unused_declarations). Exit code 2 means issues were found, and TFLint returns it even for low-severity notices, so CI blocks on them. Remove the sloppy variable:
1
git checkout -- envs/dev/variables.tf
Version note. tflint --init downloads the AWS ruleset from GitHub (pinned to v0.44.0 in .tflint.hcl). Check the TFLint releases  and ruleset releases  for newer versions.

6.2 Checkov

1
checkov --config-file .checkov.yaml
The module scan should report zero failed checks and a few skipped ones. Skipped means the #checkov:skip comments you wrote in main.tf were honored, and each carries its reason. That is the right way to take an exception: next to the code, with a reason a reviewer can challenge.

Watch Checkov fail

Create a deliberately bad fixture. It lives outside the scanned folders so normal runs ignore it.
1
checkov -d fixtures/insecure-bucket --framework terraform --compact --quiet
Expect many failed checks. In a run for this post, the insecure fixture produced 11 failures, including these:
CheckWhat it flags
CKV_AWS_24Security group allows SSH from 0.0.0.0/0
CKV_AWS_20Bucket ACL allows public read
CKV2_AWS_6No public access block on the bucket
CKV_AWS_21Versioning not enabled
CKV_AWS_145No default encryption configured (the insecure fixture has none; the secure module uses SSE-S3 by default, which satisfies this check for non-prod)
CKV_AWS_18No access logging
Exact check names and counts change between Checkov releases, so treat this as a shape, not a snapshot.

Scan the plan, not just the code

Static scanning of .tf files cannot always resolve values computed from variables and expressions. A plan JSON has them resolved:
1
checkov -f envs/dev/tfplan.json --framework terraform_plan --compact --quiet --skip-check CKV_AWS_145
The --skip-check CKV_AWS_145 is explicit here because dev uses SSE-S3 (AES256) by default, which is correct for non-prod. The prod requirement for a customer-managed KMS key is enforced by variable validation, not Checkov. Running both the code scan and the plan scan is cheap and catches different things.

Managing noise

  • Skip per resource with a reason, as in main.tf. Avoid global skips unless a check is irrelevant for your whole organization.
  • A baseline (--create-baseline) lets you adopt Checkov on an existing repo without fixing everything at once.
  • Do not use --soft-fail in the gating job. A check that cannot fail is documentation, not a control.

Step 7. Tests for valid inputs and expected failures

Terraform has a built-in test framework: any file ending in .tftest.hcl under a tests/ directory of a module runs with terraform test. Two features make it ideal for offline work:
  • mock_provider "aws" {} replaces the real provider with a fake that returns generated values. No credentials, no network, nothing created.
  • expect_failures asserts that a validation rule rejects bad input. If the rule stops firing, the test fails.

7.1 Tests for valid inputs

There are three run blocks, each demonstrating a different idea:
  1. secure_defaults (command = plan) asserts on configured values: all four public access settings are true, versioning is enabled, encryption defaults to AES256, tags exist. A plan can only assert on values Terraform already knows.
  2. prod_uses_customer_managed_key (command = plan) overrides variables for this run only and asserts that supplying a KMS key flips the algorithm to aws:kms.
  3. policy_denies_insecure_transport (command = apply) is the interesting one. apply against a mock provider creates nothing real, but it makes computed values like arn available from the mock_resource defaults. That lets the test decode the bucket policy and assert it denies non-TLS requests and covers both the bucket and its objects.

7.2 Tests for expected failures

Every run here passes only if the named variable's validation fails. This protects the rules themselves: if someone loosens the regex or deletes the prod check, a test turns red.

7.3 Run them

terraform test needs the provider schema even when mocking, so initialize first:
1
2
terraform -chdir=modules/secure-bucket init -backend=false -input=false
terraform -chdir=modules/secure-bucket test
Expected result (abridged; the exact progress lines vary by Terraform version):
1
2
3
4
tests/invalid_inputs.tftest.hcl... pass
tests/valid_inputs.tftest.hcl... pass

Success! 13 passed, 0 failed.
If an assertion fails because of a block-type detail (for example a one() wrapper on a list or set), run terraform test -verbose and read the planned object. Adjusting the expression to match the provider's schema for your version is a normal part of writing these tests.
Add -verbose to see the planned values for each run.

7.4 Mutation exercises: prove the tests can fail

A test you have never seen fail is a test you cannot trust. Commit first so you can revert:
1
git add -A && git commit -m "Add module tests and lint config"
Exercise A. Weaken the security setting.
1
2
sed -i 's/block_public_acls = true/block_public_acls = false/' modules/secure-bucket/main.tf
terraform -chdir=modules/secure-bucket test
The secure_defaults run fails with your message: All four public access block settings must be true. Checkov would catch this too. Two independent layers agreeing is the goal.
1
git checkout -- modules/secure-bucket/main.tf
Exercise B. Delete a validation rule. Open modules/secure-bucket/variables.tf, delete the second validation block in kms_key_arn (the one that requires a key in prod), and run terraform test again. The prod_requires_kms_key run now fails because the expected failure never happened. Restore the file with git checkout -- modules/secure-bucket/variables.tf.

7.5 Limits of mock providers

Know these before you trust a green test:
LimitationConsequence
Mock values are fabricatedProvide defaults for anything you assert on
Provider-side validation may not runRules enforced by the AWS API are not reliably checked
Plans leave computed values unknownUse command = apply against the mock to assert on arn or id
Data sources return generated dataUse override_data with real JSON, or build policies with jsonencode
Nothing proves AWS will accept itMocks test your logic, not AWS's behavior

7.6 One command to run everything

Create Makefile .
1
make check
This runs formatting, validation, TFLint, Checkov, the test suite, an offline plan, a plan scan, and the delete guard in one go. Developers run the same command locally that CI will run.

Step 8. Add CI that blocks pull requests

8.1 The workflow

Design decisions worth copying:
  • No secrets, no cloud credentials. The workflow has permissions: contents: read and never configures AWS. A pull request from a fork cannot exfiltrate what does not exist.
  • Three parallel jobs (static, unit-tests, plan) so a failure tells you which layer broke, and total time is the slowest job rather than the sum.
  • A gate job that depends on all three and fails if any of them failed, was cancelled, or was skipped. You mark only this one job as required. That way, if you rename or split a job later, branch protection does not silently stop protecting you.
  • AWS_EC2_METADATA_DISABLED and offline=true make sure the plan job never tries to reach AWS.
  • The plan appears in the job summary, so reviewers see what would change without running anything.
  • All versions are pinned. Terraform is pinned to 1.12.2, Checkov to 3.2.443, and TFLint to v0.58.0 via exact version strings. Third-party GitHub Actions are pinned to full commit SHAs. Update these deliberately rather than letting a range pick a new release mid-run. Check the respective release pages when you adopt this.

8.2 Publish the repository from EC2

1
2
3
sudo apt-get install -y gh
gh auth login # choose GitHub.com, HTTPS, and "Login with a web browser"
gh repo create terraform-testing-lab --public --source=. --remote=origin --push
gh prints a one-time code that you enter in a browser on your own computer, so no token is typed into the instance. Terminate the instance when you finish so the login does not linger.
Open the Actions tab. The terraform-checks workflow should run on the push, and all four jobs should go green.

8.3 Make the checks blocking

In GitHub, go to Settings → Rules → Rulesets → New ruleset → New branch ruleset (or use classic Branch protection rules):
  1. Name it protect-main, set enforcement to Active, and target the default branch.
  2. Enable Require a pull request before merging.
  3. Enable Require status checks to pass and add all checks passed.
  4. Enable Require branches to be up to date before merging (optional but recommended).
  5. Save.
Two practical notes. A check only appears in the picker after it has run at least once recently, which is why you pushed first. And on private repositories, rule enforcement depends on your GitHub plan, so confirm it applies to yours. A public repository is a simple way to follow this lab.

8.4 Watch a pull request get blocked

1
2
3
4
5
git switch -c break-ci
echo 'locals { messy = 1 }' >> envs/dev/main.tf
git commit -am "Intentionally broken formatting"
git push -u origin break-ci
gh pr create --fill
On the pull request, static checks fails at terraform fmt, all checks passed fails, and the merge button is disabled. Fix it:
1
2
git revert --no-edit HEAD
git push
The checks re-run, go green, and the merge unblocks. You have now seen the whole loop: a bad change cannot reach main, and no AWS account was involved.

Step 9. What needs a real provider or a live environment

Everything so far answers one question: is this configuration well-formed, consistent, and policy-compliant? It does not answer: will AWS accept it, and will it behave correctly? Be honest with your team about that line.

9.1 What each layer can and cannot prove

LayerProvesCannot prove
fmtStyle consistencyBehavior
validateSyntax, types, referencesWhether values are acceptable to AWS
Variable validationYour input rulesAnything outside the inputs
TFLintHygiene, known-invalid AWS valuesAccount-specific limits
CheckovKnown insecure patternsIntent or design correctness
terraform test + mockModule logic and conditionalsReal provider or API behavior
Offline planResource graph, planned valuesDrift, permissions, existing infra
Real plan (read-only)Diff against actual state, driftThat write calls will succeed
apply in sandboxEnd-to-end behavior, IAM realityProduction-only conditions

9.2 Concrete things a plan cannot tell you

  • Bucket name already taken. S3 names are global. Only the create call fails.
  • Missing permissions. Plan does not call s3:CreateBucket. IAM denials, permission boundaries, and SCPs surface at apply time.
  • Quotas. Account limits on buckets, VPCs, or elastic IPs are enforced on create.
  • KMS key validity. The regex proves the ARN is well-formed, not that the key exists, is enabled, or has a compatible key policy.
  • Values known only after apply. ARNs and IDs show as (known after apply) and cannot be asserted in a plan.
  • Ordering and eventual consistency. Real APIs sometimes need seconds between calls — hence the depends_on lines in main.tf.
  • Runtime behavior. Whether the policy really denies HTTP only a live test shows.
  • Drift. An offline plan has no state to compare.
  • Cost. A plan lists resources, not their price.

9.3 Where to add live checks

Climb the ladder only as far as risk requires:
  1. Plan against a real account, read-only. Use an IAM role that can read but not write. This adds drift detection and real data-source lookups.
  2. Apply to a dedicated sandbox account from a protected branch or a manual workflow, then run terraform destroy. Authenticate CI with GitHub OIDC to a short-lived role rather than long-lived access keys.
  3. Post-deploy smoke tests (for example, confirm an HTTP request to the bucket is denied).

9.4 Optional: an AWS emulator on the same EC2 instance

An emulator exposes AWS-shaped APIs on localhost so you can run a real apply without an AWS account. It catches wiring problems that mocks and plans miss. It is not a substitute for the real service: coverage of newer features varies and IAM enforcement is often simplified.
LocalStack is the best-known option. Its packaging and licensing have evolved, so read the current documentation before choosing it.
1
2
3
sudo apt-get install -y docker.io
sudo usermod -aG docker "$USER" && newgrp docker
docker run -d --name emulator -p 127.0.0.1:4566:4566 localstack/localstack
Then point the lab at it:
1
2
3
terraform -chdir=envs/dev apply \
-var offline=true \
-var emulator_endpoint=http://localhost:4566
Clean up with terraform -chdir=envs/dev destroy ... using the same flags, then docker rm -f emulator.
Never point an emulator-configured provider at real AWS, and never bind the emulator port to 0.0.0.0 on an internet-facing instance.

Step 10. The pre-deployment checklist

Use this as your pull request template, or as the definition of "ready to apply".
Automated (blocking in CI)
  • [ ] terraform fmt -check -recursive passes
  • [ ] terraform init -backend=false and terraform validate pass in every module and environment
  • [ ] TFLint reports no findings
  • [ ] Checkov passes with no unexplained skips (every skip has a reason in a comment)
  • [ ] terraform test passes, including negative tests for every validation rule
  • [ ] Offline terraform plan succeeds and the plan scan passes
  • [ ] The plan contains no unexpected deletes or replacements
  • [ ] .terraform.lock.hcl is committed and unchanged unless intentionally upgraded
Human review
  • [ ] The plan summary in the pull request matches the intent of the change
  • [ ] Any new #checkov:skip has a reason that a reviewer accepts
  • [ ] For prod environments: the plan uses a customer-managed KMS key (SSE-KMS), not SSE-S3
  • [ ] Naming, tags, and ownership follow your standard
  • [ ] Anything marked (known after apply) that matters has been thought through
Requires a real environment (not proven by this repo)
  • [ ] Names that must be globally unique are available
  • [ ] The deploying role has the required permissions and is not blocked by SCPs or boundaries
  • [ ] Service quotas are sufficient
  • [ ] Referenced external resources (KMS keys, VPCs, hosted zones) exist, are in the right region, and have compatible policies
  • [ ] A read-only plan against the target account shows no surprising drift
  • [ ] Applied to a sandbox first, with a smoke test and a successful destroy
  • [ ] A rollback or recovery path exists for stateful resources

Clean up

When you are done, remove everything so nothing keeps costing money or holding access:
  1. Terminate the EC2 instance (EC2 → Instances → Instance state → Terminate). Because it used no IAM role and held no AWS credentials, there is nothing else to revoke.
  2. Delete the key pair and the security group if you created them only for this lab.
  3. If you authenticated gh on the instance, it is gone with the instance. You can also review authorized apps in your GitHub settings.
  4. Nothing was created in AWS by Terraform, so there is no destroy to run. That was the point.

What you built

You now have a Terraform starter repository where a typo, a bad input, an insecure setting, a weakened guardrail, or a destructive plan is caught in seconds, on a machine with no cloud access, and where a pull request cannot merge until all of it passes. You also have an honest list of what these checks leave for a real environment, which is the first thing to put in front of anyone who asks, "so is it safe to deploy?"
Where to go next: add more modules and give each its own tests/ folder, add a read-only plan job using GitHub OIDC, wire the plan-guard into an approval workflow, and run a scheduled sandbox apply-and-destroy job to cover the rungs this lab deliberately leaves out.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article