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.

What cdktf synth leaves on disk What cdktf synth leaves on disk: cdktf.out/ with 4 facets. cdktf.out/ manifest.json stack index: name, working dir, dependencies stacks/<Name>/ one Terraform working directory per stack cdk.tf.json the HCL JSON validate actually reads .terraform/ provider plugins that init downloads
terraform validate reads cdk.tf.json against the provider schemas unpacked into .terraform/.

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

Toolchain the validation step depends on Toolchain the validation step depends on: layered from cdktf-cli, pinned in package.json down to Provider plugins in .terraform/. cdktf-cli, pinned in package.json the synthesizer version decides the JSON shape Generated provider bindings cdktf get writes them under .gen/ before synth Terraform CLI 1.5 or newer supplies init, validate and the -json diagnostics format Provider plugins in .terraform/ installed by init -backend=false, no credentials needed
Every layer below the synthesizer must be version-pinned or the diagnostics stop being reproducible.
  • cdktf-cli installed and pinned in package.json so 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 -json diagnostics 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

The validation sequence for one synthesized stack The validation sequence for one synthesized stack: cdktf get then cdktf synth then init -backend=false then validate -json cdktf get provider bindings cdktf synth writes cdk.tf.json init-backend=false plugins only, no state validate -json schema diagnostics
Each stage is read-only: nothing here contacts a state backend or a cloud API.

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=false prevents init from 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 .tfstate file.

-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

How a schema error surfaces during validate How a schema error surfaces during validate: CI job → Terraform CLI → Provider plugin → cdk.tf.json. CI job Terraform CLI Provider plugin cdk.tf.json validate -json parse config request schema return schema diagnostics + exit 1
validate resolves the emitted JSON against the plugin's schema and returns structured diagnostics.

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

Where synthesized-output validation goes wrong Where synthesized-output validation goes wrong: Failing validate run with 4 facets. Failing validate run Backend error plain init tried to reach S3 without credentials Lock mismatch provider version in cdktf.json drifted from the lock file Silent fmt pass terraform fmt skips .tf.json files entirely Passing but wrong legal schema, wrong subnet or wrong account
Four failure shapes: two block the run, two let a broken configuration through.

Error: Backend initialization required, please run "terraform init". You ran validate after a plain init that tried to configure the backend and failed without credentials. Add -backend=false to the init used 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.hcl in the stack directory was generated for a different constraint than the one now in cdktf.json. Because the lock lives inside cdktf.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 -check appears to pass on freshly synthesized output. It is not checking anything: terraform fmt skips .tf.json entirely. Compare canonical JSON against a snapshot if you want to detect a formatting or shape change caused by a cdktf-cli upgrade.

validate passes 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 reviewed cdktf diff plan gate.

Only one stack gets validated. terraform -chdir accepts a single working directory, and cdktf.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-coding NetworkStack silently ignores everything else.

Cross-stack references validate on both sides but still break at apply. A TerraformRemoteState data source resolves to a legal data block whatever the producing stack actually outputs, so validate cannot 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.

What each pre-apply check can and cannot catch What each pre-apply check can and cannot catch: comparison across Needs credentials, Catches, Misses. Check Needs credentials Catches Misses Snapshot test No Unintended JSON change Illegal attributes terraform validate No Schema and reference errors Wrong-but-legal wiring Policy scan No Compliance violations Provider API rejections cdktf diff Yes Real create/replace actions Nothing cheaper upstream
Validation is one band in a ladder of pre-apply checks, each with a different blind spot.

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.