terraform plan does three things well: it validates syntax, resolves references between resources and refreshes the state against the provider for the resources it already manages. What it does not do is call the provider to ask whether what the configuration takes for granted exists, is allowed or has room. That is the gap between a green plan and a red apply, and it is wider than it looks: a subnet typed by hand, a credential that can only read, a vCPU quota already at its limit. None of those shows up in the plan.
These are the six things plan does not verify. For each one: what the plan sees, what the apply hits, and one read-only command per provider to ask beforehand. The commands are taken from each CLI's official reference.
1. The subnet referenced by name or by a hand-typed ID
What the plan sees: a string. subnet_id = "subnet-0abc..." or a var.subnet_name that arrives from a tfvars file. If the string has the right shape, the plan accepts it; it does not check that the subnet exists in that region or in that account.
What the apply hits: InvalidSubnetID.NotFound, a 404 from the provider or, worse, a subnet that exists but in another VPC, and the VM lands on the wrong network.
# AWS
aws ec2 describe-subnets --filters "Name=tag:Name,Values=snet-app" "Name=vpc-id,Values=$VPC"
# Azure
az network vnet subnet show -g rg-app --vnet-name vnet-app -n snet-app
# OCI
oci network subnet list --compartment-id "$C" --vcn-id "$VCN" --display-name snet-app
# GCP
gcloud compute networks subnets describe snet-app --region us-central1
Partial fix: a data source with filters (data "aws_subnet" with filter, data "azurerm_subnet", data "oci_core_subnets", data "google_compute_subnetwork") moves the check into the plan, because the data source does call the API. Partial because it only covers what was declared as a data source; the ID pasted into a tfvars file is still unverified.
2. The credential's scope
What the plan sees: a credential that can read. plan needs read permissions to refresh the state, and if it has them, it ends green. It does not simulate the Create that comes next.
What the apply hits: UnauthorizedOperation, AuthorizationFailed, NotAuthorizedOrNotFound (OCI blends the two cases on purpose) or a 403 from GCP halfway through the apply, with half the resources already created.
# AWS: who am I, and can I create the instance
aws sts get-caller-identity
aws iam simulate-principal-policy --policy-source-arn "$ROLE_ARN" --action-names ec2:RunInstances ec2:CreateTags
# Azure: effective roles, including those inherited from parent scopes
az role assignment list --assignee "$SP_ID" --include-inherited --all
# OCI: the compartment's policies (the tenancy is the root compartment)
oci iam policy list --compartment-id "$C" --all
# GCP: project bindings
gcloud projects get-iam-policy "$PROJECT"
On OCI the reading is manual: the CLI reference itself says that to know which policies apply to a group you have to read the statements one by one, there is no API that answers it. The AWS simulator does answer allow or deny per action, and it is the only one of the four that does so without attempting the operation.
3. Quota
What the plan sees: nothing. Quotas are not an attribute of any resource, so there is nowhere to refresh them from.
What the apply hits: InstanceLimitExceeded on AWS, an OperationNotAllowed that talks about the cores quota on Azure, LimitExceeded on OCI, Quota 'CPUS' exceeded on GCP.
# AWS: standard on-demand vCPU (L-1216C47A) in the region
aws service-quotas get-service-quota --service-code ec2 --quota-code L-1216C47A
# Azure: usage and limit per family in the region
az vm list-usage -l brazilsouth -o table
# OCI: available and used for one specific limit
oci limits resource-availability get --compartment-id "$T" --service-name compute --limit-name standard-a1-core-count --availability-domain "$AD"
# GCP: the region's quotas list, with usage and limit
gcloud compute regions describe us-central1 --format="yaml(quotas)"
All four answers carry the limit and the usage. If the difference is smaller than what the plan is about to create, the apply will fail and the plan does not know it.
4. Capacity, which is not quota
What the plan sees: nothing either, and here not even a data source helps. Having quota for 8 vCPU does not mean there is a free host for that shape in that availability domain right now.
What the apply hits: InsufficientInstanceCapacity on AWS, AllocationFailed or SkuNotAvailable on Azure, Out of host capacity on OCI. The difference from quota matters because the fix is different: quota is requested; capacity is waited for, or you change zone or size.
The only thing readable beforehand is the list of SKUs with restrictions for the subscription; for capacity at this moment there is no read API, only the attempt:
# Azure: sizes with per-zone restrictions for this subscription
az vm list-skus -l brazilsouth --resource-type virtualMachines --zone --all -o table
On OCI the retry loop from the previous post is the honest answer; on AWS the EC2 troubleshooting guide suggests the same: retry, reduce the count or drop the zone from the request.
5. The way out: NAT, gateway and route
What the plan sees: the VM and, if they are in the same configuration, the subnet and the route table. What it does not see is whether that table has a 0.0.0.0/0 route to anything when the network was created by another team or another state.
What the apply hits: nothing, which is why it is the worst case. The VM gets created, the apply stays green until the provisioner or the user_data that runs apt-get update, and there it hangs or times out. Terraform marks the resource as tainted and the next apply recreates it, to fail the same way.
# AWS: the table associated with the subnet and whether it has 0.0.0.0/0 (igw-* or nat-*)
aws ec2 describe-route-tables --filters "Name=association.subnet-id,Values=$SUBNET" --query "RouteTables[].Routes[?DestinationCidrBlock=='0.0.0.0/0']"
# Azure: route table (null = system routes), NAT gateway and whether the subnet is private
az network vnet subnet show -g rg-app --vnet-name vnet-app -n snet-app --query "{routeTable:routeTable.id, natGateway:natGateway.id, defaultOutboundAccess:defaultOutboundAccess}"
# OCI: route rules and service gateways of the VCN
oci network route-table list --compartment-id "$C" --vcn-id "$VCN" --query 'data[*]."route-rules"'
oci network service-gateway list --compartment-id "$C" --vcn-id "$VCN"
# GCP: the NATs on the region's Cloud Router
gcloud compute routers nats list --router "$ROUTER" --region us-central1
On Azure there is a date: according to the Default Outbound Access page, for the API version released after March 31, 2026, subnets in new VNets are born with defaultOutboundAccess set to false, and a VM there needs a NAT Gateway, a public IP or a load balancer with outbound rules to reach the internet. A configuration that got out fine on an old VNet stops getting out on a new one, and the plan is identical.
6. What lives in another state
What the plan sees: terraform_remote_state or outputs from another module, resolved at plan time. But if the value depends on a resource that does not exist yet (the other state was not applied, or a count depends on something created in this same apply), the plan shows (known after apply) and moves on.
What the apply hits: the resource from the other state is not there, or it is there with another ID because somebody recreated it, and the error comes from the provider, not from Terraform. What you can do is inventory how much of the plan is (known after apply) before approving it:
terraform plan -out plan.out
terraform show -json plan.out | jq -r '.resource_changes[] | select(.change.actions | index("create")) | "\(.address): \(.change.after_unknown | keys | join(","))"'
# and what the other state exports today, not what it exported when the module was written
terraform state list
terraform output -json
after_unknown is a documented field of Terraform's JSON format: it lists every attribute that will not be known until apply. If subnet_id or network_interface_ids are among them, points 1 and 5 of this list could not be verified in the plan by construction.
What can be automated today
All of the above fits in a script that runs before the apply, with no new tool. The skeleton, with AWS as the example:
#!/usr/bin/env bash
set -euo pipefail
terraform plan -out plan.out -input=false
PLAN=$(terraform show -json plan.out)
# 1. every create with unknown attributes
echo "$PLAN" | jq -r '.resource_changes[] | select(.change.actions|index("create")) | "\(.address) unknown: \(.change.after_unknown|keys|join(","))"'
# 2. the subnets the plan references by ID, against the API
for s in $(echo "$PLAN" | jq -r '.resource_changes[].change.after.subnet_id? // empty' | sort -u); do
aws ec2 describe-subnets --subnet-ids "$s" --query 'Subnets[0].SubnetId' --output text >/dev/null || { echo "subnet $s not found"; exit 1; }
done
# 3. quota against what gets created
NEED=$(echo "$PLAN" | jq '[.resource_changes[] | select(.type=="aws_instance" and (.change.actions|index("create")))] | length')
echo "instances to create: $NEED"
aws service-quotas get-service-quota --service-code ec2 --quota-code L-1216C47A --query 'Quota.Value'
# 4. credential (simulate-principal-policy) and 5. routes (describe-route-tables) go here
terraform apply plan.out
Two limits. The script knows one provider at a time and reads what the plan already decided: a subnet that comes in through a variable without going through the plan JSON is invisible to it. And capacity (point 4) has no prior read on any provider.
Control Cloud runs these checks from the app, against the live tenancy and with the read-only credential that is already connected: credential scope, subnet and internet egress, template dependencies and hosts, before the Terraform exists, because the Terraform is generated from the canvas after the check. What it does not do: real quotas (only soft thresholds on IONOS), and there is no CI hook or public API; it runs from the canvas and from Pre-Deploy. The pre-flight video shows the whole path.
Which of the six broke your last apply with the plan in green?
Top comments (2)
The quota-versus-capacity distinction in sections 3 and 4 is the most useful framing here. Teams usually treat them as one check, but having vCPU quota and still hitting InsufficientInstanceCapacity is a different failure mode entirely — one is a limit you can query, the other is real-time availability you can only work around with a retry loop or a smaller count. I also agree section 5 is the worst case: a VM that applies successfully with no egress is a silent failure, whereas the subnet and credential errors at least fail loudly. One addition that fits your pre-flight script idea: checking after_unknown from the plan JSON for cross-state outputs, as you note in section 6, catches the case where the other state's resource exists but with a different ID — the plan looks green because the reference resolved, not because the target is right.
One correction on the last point, because I had it wrong too at first: a terraform_remote_state output is known at plan time, it is read from the other state file, so the subnet ID lands in
after, not inafter_unknown. The plan prints a concrete ID and goes green even if that ID left the account last week.after_unknownonly tells you what the plan could not check; the stale-but-known ID needs a read against the provider, which is why the script loops overafter.subnet_idwith describe-subnets. A data source on that ID does the same job and fails at plan when it is gone.