
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 testsuites 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.
| Rung | Check | Needs credentials? | Needs AWS network? | Time |
|---|---|---|---|---|
| 1 | terraform fmt | No | No | < 1 s |
| 2 | terraform validate | No | No (after init) | seconds |
| 3 | TFLint, Checkov | No | No | seconds |
| 4 | terraform test with mock provider | No | No | seconds |
| 5 | terraform plan offline | No (dummy) | No | seconds |
| 6 | terraform plan real account | Read access | Yes | tens of seconds |
| 7 | apply sandbox | Write access | Yes | minutes |
This lab covers rungs 1–5 completely.
Here is a preview of which layer catches which mistake.
| Mistake | First layer that catches it |
|---|---|
| Inconsistent formatting | terraform fmt -check |
| Typo in a resource attribute | terraform validate |
| Uppercase or reserved bucket name | Variable validation, negative test |
| Unused or undocumented variable | TFLint |
| Public ACL, missing encryption, open SSH | Checkov |
| Bucket policy missing TLS-only deny | Unit test |
| A change that would destroy a resource | Plan JSON guard |
| Bucket name already taken | Real apply only |
| Missing IAM permission or SCP denial | Real 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:
| Setting | Value | Why |
|---|---|---|
| Name | tf-testing-lab | |
| AMI | Ubuntu Server 24.04 LTS | The bootstrap script targets Ubuntu |
| Instance type | t3.small | Checkov is happier with 2 GB of RAM |
| Key pair | Create new, .pem format | For SSH |
| Network | Default VPC, auto-assign public IP: enabled | |
| Security group | One inbound rule: SSH (22) from My IP | Never open SSH to 0.0.0.0/0 |
| Storage | 20 GiB gp3 | Plugin caches add up |
| Advanced → IAM instance profile | No IAM role | The point of the lab |
| Advanced → Metadata version | V2 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.shPaste 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 ~/.bashrcThe 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 withsudo 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 withscp -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/workflows3.1 Pin Terraform and the provider
Create
modules/secure-bucket/versions.tf .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
Create
modules/secure-bucket/main.tf .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_algorithmpicks SSE-S3 or SSE-KMS from one input, so the choice is visible in a single place and testable.- The
#checkov:skipcomments 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_onlines avoid real-world ordering conflicts that no offline check can detect. That is a preview of Step 9.
3.3 Outputs
Create
modules/secure-bucket/outputs.tf .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
Create
modules/secure-bucket/variables.tf .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 validateinit 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 validateYou 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.tf5.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=tfplanAbridged 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=falseThe 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
Create
.tflint.hcl .- The
terraformruleset (presetrecommended) enforces language hygiene: documented variables and outputs, typed variables, no unused declarations, pinned versions. - The
awsruleset 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 duringapply. 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/devNo 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.tfVersion note.tflint --initdownloads 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
Create
.checkov.yaml .1
checkov --config-file .checkov.yamlThe 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.
Create
fixtures/insecure-bucket/main.tf .1
checkov -d fixtures/insecure-bucket --framework terraform --compact --quietExpect many failed checks. In a run for this post, the insecure fixture produced 11 failures, including these:
| Check | What it flags |
|---|---|
CKV_AWS_24 | Security group allows SSH from 0.0.0.0/0 |
CKV_AWS_20 | Bucket ACL allows public read |
CKV2_AWS_6 | No public access block on the bucket |
CKV_AWS_21 | Versioning not enabled |
CKV_AWS_145 | No 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_18 | No 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_145The
--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-failin 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_failuresasserts 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: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.prod_uses_customer_managed_key(command = plan) overrides variables for this run only and asserts that supplying a KMS key flips the algorithm toaws:kms.policy_denies_insecure_transport(command = apply) is the interesting one.applyagainst a mock provider creates nothing real, but it makes computed values likearnavailable from themock_resourcedefaults. 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 testExpected 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 testThe
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.tfExercise 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:
| Limitation | Consequence |
|---|---|
| Mock values are fabricated | Provide defaults for anything you assert on |
| Provider-side validation may not run | Rules enforced by the AWS API are not reliably checked |
| Plans leave computed values unknown | Use command = apply against the mock to assert on arn or id |
| Data sources return generated data | Use override_data with real JSON, or build policies with jsonencode |
| Nothing proves AWS will accept it | Mocks test your logic, not AWS's behavior |
7.6 One command to run everything
Create
Makefile .1
make checkThis 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
Create
.github/workflows/ci.yml .Design decisions worth copying:
- No secrets, no cloud credentials. The workflow has
permissions: contents: readand 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
gatejob 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_DISABLEDandoffline=truemake 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 to3.2.443, and TFLint tov0.58.0via 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 --pushgh 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):
- Name it
protect-main, set enforcement to Active, and target the default branch. - Enable Require a pull request before merging.
- Enable Require status checks to pass and add
all checks passed. - Enable Require branches to be up to date before merging (optional but recommended).
- 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 --fillOn 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 pushThe 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
| Layer | Proves | Cannot prove |
|---|---|---|
fmt | Style consistency | Behavior |
validate | Syntax, types, references | Whether values are acceptable to AWS |
| Variable validation | Your input rules | Anything outside the inputs |
| TFLint | Hygiene, known-invalid AWS values | Account-specific limits |
| Checkov | Known insecure patterns | Intent or design correctness |
terraform test + mock | Module logic and conditionals | Real provider or API behavior |
Offline plan | Resource graph, planned values | Drift, permissions, existing infra |
Real plan (read-only) | Diff against actual state, drift | That write calls will succeed |
apply in sandbox | End-to-end behavior, IAM reality | Production-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_onlines inmain.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:
- 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.
- 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. - 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/localstackThen point the lab at it:
1
2
3
terraform -chdir=envs/dev apply \
-var offline=true \
-var emulator_endpoint=http://localhost:4566Clean 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 to0.0.0.0on 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 -recursivepasses - [ ]
terraform init -backend=falseandterraform validatepass in every module and environment - [ ] TFLint reports no findings
- [ ] Checkov passes with no unexplained skips (every skip has a reason in a comment)
- [ ]
terraform testpasses, including negative tests for every validation rule - [ ] Offline
terraform plansucceeds and the plan scan passes - [ ] The plan contains no unexpected deletes or replacements
- [ ]
.terraform.lock.hclis committed and unchanged unless intentionally upgraded
Human review
- [ ] The plan summary in the pull request matches the intent of the change
- [ ] Any new
#checkov:skiphas 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:
- 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.
- Delete the key pair and the security group if you created them only for this lab.
- If you authenticated
ghon the instance, it is gone with the instance. You can also review authorized apps in your GitHub settings. - Nothing was created in AWS by Terraform, so there is no
destroyto 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.Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article