Validating Synthesized Terraform from CDKTF
This guide shows how to run terraform validate against the HCL JSON that CDK for Terraform emits into cdktf.out, how to read the structured diagnostics it returns, and how to turn those checks into a CI gate that catches provider-schema errors before any plan or apply. It is the validation half of CDKTF Testing and CI/CD, within the broader CDKTF Workflows & Terraform Synthesis practice.
Context
CDKTF unit tests prove your Python builds the resource graph you intended, but they cannot prove that graph is legal for the providers you pinned — a snapshot test will happily freeze an attribute the provider rejects. terraform validate against the synthesized output closes that gap by checking the emitted cdk.tf.json against the actual provider schemas. Running it after CDKTF architecture and synthesis produces the artifact, and before cdktf diff, is the cheapest place to catch a misconfigured resource.
Treat cdktf synth as a compile step and terraform validate as the type checker that runs on its object code. Synthesis writes one directory per stack under cdktf.out/stacks/, each containing a cdk.tf.json file that is a complete, self-contained Terraform configuration, plus a top-level manifest.json that records every stack name, its working directory and its inter-stack dependencies. Nothing in that tree has been checked against a provider yet: the Python type checker validated your construct arguments against the generated bindings, and the bindings were generated from a provider schema, but attributes that are conditionally required, mutually exclusive, or deprecated are enforced by the provider itself and only surface when Terraform loads the plugin.
Concretely, validate catches a missing required argument, an argument the provider does not recognise, a value whose type cannot be converted, a reference to a resource or output that does not exist, a duplicate resource address, and a depends_on pointing at nothing. It does not call a cloud API, does not read or lock state, and cannot know whether an S3 bucket name is already taken or an AMI ID exists in the target region. That boundary is exactly what makes it useful in CI: it is fast, deterministic, needs no credentials, and can therefore run on a pull request from a fork where a plan step never could.
Prerequisites
cdktf-cliinstalled and pinned inpackage.jsonso synthesis is reproducible across developer machines and CI runners.- Provider versions pinned in
cdktf.json, following pinning Terraform provider versions in CDKTF. - The generated bindings present (
cdktf get) so synthesis succeeds and imports resolve. - Terraform CLI on
PATH(terraform version≥ 1.5, which is where the-jsondiagnostics format is stable). - No backend credentials required — validation runs with
init -backend=false.
Confirm the toolchain:
# CLI: validation needs only the Terraform CLI and a synthesized output dir
cdktf --version && terraform version
Implementation
Step 1 — Synthesize, Then Initialize Providers Without a Backend
terraform validate needs the provider plugins installed but does not need state. Run init with -backend=false so the step stays read-only and needs no credentials.
# CLI: produce cdk.tf.json, then install providers without touching state
cdktf get
cdktf synth --output cdktf.out
terraform -chdir=cdktf.out/stacks/NetworkStack init -backend=false -input=false
State implication:
-backend=falsepreventsinitfrom contacting the remote backend, so this step can run on any runner without backend access, without acquiring a DynamoDB lock, and without a risk of writing a.tfstatefile.
-input=false matters as much as -backend=false: without it, a provider block that is missing a required attribute makes Terraform prompt on stdin and the CI job hangs until the runner times out rather than failing with a diagnostic.
Step 2 — Run validate and Understand What fmt Does Not Cover
validate walks the configuration, loads each provider plugin, and reports every diagnostic it can find without contacting an API. A common addition to this step is terraform fmt -check, and it is worth being precise about it: fmt only rewrites .tf and .tfvars files, so pointing it at a tree of cdk.tf.json files processes nothing and always exits zero. It is not a guard against CDKTF version skew.
# CLI: schema validation for one synthesized stack
terraform -chdir=cdktf.out/stacks/NetworkStack validate
# CLI: this passes trivially — fmt ignores .tf.json, it is not a synthesis check
terraform fmt -check -recursive cdktf.out
If you want to detect a synthesizer version change, compare the emitted JSON against a committed snapshot instead, as described in snapshot testing CDKTF stacks with pytest. That comparison is on intent; validate is on legality. Neither replaces the other.
Step 3 — Wrap the Checks in a Typed Validator for CI
Driving the checks from Python lets you iterate every synthesized stack, parse the machine-readable diagnostics, and surface one clean pass/fail — which is what the GitHub Actions pipeline consumes.
# CLI: python scripts/validate_synth.py
# Provider note: validate needs providers installed (init), but no cloud creds.
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
@dataclass(frozen=True)
class Diagnostic:
severity: str
summary: str
detail: str
line: int | None
@dataclass(frozen=True)
class StackValidation:
stack: str
ok: bool
diagnostics: list[Diagnostic] = field(default_factory=list)
setup_error: str = ""
def _init(stack_dir: Path) -> subprocess.CompletedProcess[str]:
# State implication: -backend=false keeps init away from S3/DynamoDB entirely.
return subprocess.run(
["terraform", f"-chdir={stack_dir}", "init",
"-backend=false", "-input=false", "-no-color"],
capture_output=True, text=True,
)
def validate_stack(stack_dir: Path) -> StackValidation:
name = stack_dir.name
init = _init(stack_dir)
if init.returncode != 0:
return StackValidation(name, False, setup_error=init.stderr.strip())
result = subprocess.run(
["terraform", f"-chdir={stack_dir}", "validate", "-json"],
capture_output=True, text=True,
)
report = json.loads(result.stdout)
diags = [
Diagnostic(
severity=d["severity"],
summary=d["summary"],
detail=d.get("detail", ""),
line=(d.get("range") or {}).get("start", {}).get("line"),
)
for d in report.get("diagnostics", [])
]
return StackValidation(name, bool(report["valid"]), diags)
terraform validate -json always writes a JSON document to stdout, even on failure, so parse stdout rather than branching on the exit code. A failing run looks like this:
{
"format_version": "1.0",
"valid": false,
"error_count": 1,
"warning_count": 0,
"diagnostics": [
{
"severity": "error",
"summary": "Unsupported argument",
"detail": "An argument named \"acl\" is not expected here.",
"range": {
"filename": "cdk.tf.json",
"start": { "line": 148, "column": 9, "byte": 4210 }
}
}
]
}
Step 4 — Fail the Job and Annotate the Diff
Turn the parsed diagnostics into workflow annotations so a reviewer sees the error on the pull request rather than buried in a log. Every stack is validated before the process exits, so one run reports all failures instead of stopping at the first.
# CLI: python scripts/validate_synth.py (continues the module above)
# Provider note: line numbers point into cdk.tf.json, not your Python source.
def main() -> int:
stacks = sorted(p for p in Path("cdktf.out/stacks").iterdir() if p.is_dir())
results = [validate_stack(s) for s in stacks]
for r in results:
if r.setup_error:
print(f"::error title=init failed ({r.stack})::{r.setup_error}")
for d in r.diagnostics:
level = "error" if d.severity == "error" else "warning"
location = f",line={d.line}" if d.line else ""
print(f"::{level} file=cdktf.out/stacks/{r.stack}/cdk.tf.json{location}"
f"::{d.summary}: {d.detail}")
failed = [r.stack for r in results if not r.ok]
print(f"validated {len(results)} stack(s); {len(failed)} failed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
Verification
A clean run prints the per-stack summary and exits zero:
# CLI: validate every synthesized stack; non-zero exit fails the CI job
cdktf synth && python scripts/validate_synth.py && echo "all stacks valid"
Terraform reports Success! The configuration is valid. in human-readable mode and "valid": true with an empty diagnostics array under -json; the wrapper's exit code is what your pipeline keys on. Verify the gate actually bites before you trust it: add a deliberately bogus argument to one resource in Python, re-synthesize, and confirm the run fails with Unsupported argument pointing at the right stack. A validation step that has never gone red is indistinguishable from one that is silently skipping stacks — for example because cdktf.out/stacks was empty when the loop ran.
Gotchas & Edge Cases
Error: Backend initialization required, please run "terraform init". You ranvalidateafter a plaininitthat tried to configure the backend and failed without credentials. Add-backend=falseto theinitused for validation.
Error: Inconsistent dependency lock file— "provider registry.terraform.io/hashicorp/aws: required by this configuration but no version is selected". The.terraform.lock.hclin the stack directory was generated for a different constraint than the one now incdktf.json. Because the lock lives insidecdktf.out, wiping the synth output between runs makes this disappear; if you cache that directory in CI, cache the provider mirror rather than the stack directories.
fmt -checkappears to pass on freshly synthesized output. It is not checking anything:terraform fmtskips.tf.jsonentirely. Compare canonical JSON against a snapshot if you want to detect a formatting or shape change caused by acdktf-cliupgrade.
validatepasses but the resource is still wrong. It only checks schema legality, not intent — it cannot tell that you attached the wrong subnet, used the staging KMS key, or sized an instance ten times too large. Pair it with snapshot tests, a policy scan, and a reviewedcdktf diffplan gate.
Only one stack gets validated.
terraform -chdiraccepts a single working directory, andcdktf.out/stacks/holds one per stack once you follow splitting a CDKTF app into multiple stacks. Iterating the directory, as the wrapper does, is the only way to cover them all; hard-codingNetworkStacksilently ignores everything else.
Cross-stack references validate on both sides but still break at apply. A
TerraformRemoteStatedata source resolves to a legaldatablock whatever the producing stack actually outputs, sovalidatecannot detect a renamed output. Only an ordered plan against real state catches that.
Operational Notes
Validation is cheapest when it runs on the synthesized cdk.tf.json before a single resource is touched. Wire terraform validate and a policy scan such as Checkov into the same CI stage that runs cdktf synth, so a malformed configuration or a compliance violation fails the pull request rather than the deploy.
The dominant cost of this stage is init downloading provider plugins once per stack directory — a multi-stack app with a large AWS provider can spend several minutes doing the same download repeatedly. Set TF_PLUGIN_CACHE_DIR to a cached path on the runner so every stack hard-links the same unpacked plugin, and the second and subsequent init calls finish in under a second:
# CLI: share one provider download across every synthesized stack
export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"
mkdir -p "$TF_PLUGIN_CACHE_DIR"
python scripts/validate_synth.py
Run validation on every pull request, including from forks, precisely because it needs no secrets. Keep the plan gate on a separate job that requires an OIDC role, and make the plan job depend on the validation job so a schema error never burns a plan slot or a state lock. Because synthesis is deterministic, the same commit always produces the same JSON, so the same commit always produces the same diagnostics — which makes it safe to key a cache on the content hash of cdktf.out and skip re-validation for an unchanged stack.
Finally, treat warnings as data rather than noise. Deprecation diagnostics from a provider (Warning: Argument is deprecated) are the earliest signal that a provider major-version bump will break your stacks; counting them per stack over time turns a future forced upgrade into scheduled work. Emit the counts alongside the pass/fail so the trend is visible in build logs, but keep the gate keyed on errors only, or a provider deprecation on a Friday afternoon blocks every merge.
FAQ
Do I need cloud credentials to run terraform validate on CDKTF output?
No. With init -backend=false, Terraform installs the provider plugins to read their schemas but never contacts the backend or cloud APIs. Credentials are only needed later, at cdktf diff and cdktf deploy, which is why validation can safely run on pull requests from forks.
What is the difference between a CDKTF snapshot test and terraform validate?
A snapshot test asserts your Python emitted the exact JSON you expect — it checks intent. terraform validate checks that JSON against the provider schema — it checks legality. A snapshot can freeze an invalid attribute, and validate can pass a graph that wires the wrong resources, so you need both.
Why does terraform fmt -check not catch anything on CDKTF output?
fmt only rewrites .tf and .tfvars files. CDKTF emits cdk.tf.json, which fmt skips, so the command exits zero without inspecting a single file. Use a canonical-JSON snapshot comparison instead if you want to detect output-shape drift after a cdktf-cli upgrade.
Should validation run per stack or once for the whole output directory?
Per stack. Each directory under cdktf.out/stacks/ is its own Terraform working directory with its own providers and its own lock file, so init and validate must be run with -chdir pointed at each one — which the typed wrapper above iterates automatically.
How do I map a validate error back to the Python that produced it?
The diagnostic range points at a line in cdk.tf.json, not at your construct. Open that line and read the surrounding resource address: CDKTF derives it from the construct path, so aws_subnet.network_private_subnet_a_1B2C3D tells you which construct ID and which parent scope to look for in your Python.
Can terraform validate replace a plan in CI?
No. Validation never reads state, so it cannot tell you that a change will replace a database or delete a security group. It is a fast pre-filter that keeps broken configurations out of the plan job; the plan remains the gate that a human approves before an apply.
Related
- CDKTF Testing and CI/CD — where this validation step sits in the full pipeline alongside unit and snapshot tests.
- Running CDKTF Pipelines in GitHub Actions — wiring this validation into a pull-request gate with OIDC.
- CDKTF Architecture & Synthesis — how the
cdk.tf.jsonyou validate is produced from Python constructs. - Pinning Terraform Provider Versions in CDKTF — keeping the schemas that validate reads stable between runs.