Import Existing AWS Resources into CDKTF Python

Two AWS objects will teach you everything the rest of an adoption wave repeats: a bucket, because it is really four resources wearing one name, and a security group, because its arguments are the ones most likely to disagree with reality. This guide walks both of them end to end — the Python you write first, the address synthesis hands you, the terraform import invocation you run from inside cdktf.out, and the empty plan that proves you are done. It belongs to importing existing infrastructure into CDKTF, inside CDKTF workflows and Terraform synthesis, and assumes you already know why adoption is worth doing.

Four systems that must agree Four systems that must agree: Live AWS object then Python construct then Synthesized address then State entry then Empty plan Live AWS object made by someone else Python construct S3Bucket(self, 'reports') Synthesizedaddress aws_s3_bucket.reports State entry written by import Empty plan exit code 0
Adoption succeeds only when all four line up; you author just one of them directly.

Context

Four things have to agree before an AWS object is genuinely under CDKTF management, and they are produced by four different systems. The object lives in AWS and was made by someone else. The Python construct is yours. The resource address is computed by cdktf synth and written into cdktf.out/stacks/<stack>/cdk.tf.json. The state entry is written by Terraform when you import. You control exactly one of those directly, which is why the workflow below always starts from the live object and works outward.

The mechanical detail that surprises people arriving from HCL is where the import runs. There is no cdktf import subcommand that takes an address and an ID. CDKTF's job ends at synthesis; from that point you are operating on an ordinary Terraform working directory that happens to have been generated. Every command in this guide therefore targets cdktf.out/stacks/<stack>/ explicitly, and every one of them will fail in a confusing way if you run it from the repository root, where there is no .terraform directory and no backend configuration.

The second detail is ordering. Terraform will not import into an address that does not exist in the configuration, so the Python always comes first — even a stub. Write the construct, synthesize, confirm the key is present in the JSON, and only then import.

Prerequisites

  • CDKTF 0.20 or newer and Terraform 1.5 or newer available on your PATH
  • cdktf-cdktf-provider-aws installed in the project virtualenv, pinned to the version you intend to keep
  • A remote backend already declared on the stack, and terraform init already run once in the synthesized directory
  • AWS credentials in the same account and region as the objects you are adopting — a region mismatch is indistinguishable from a missing object
  • A recorded state backup: for an S3 backend, the current object version ID of the state file, written somewhere you can find it
# CLI: confirm the toolchain and the account before touching state
cdktf --version
terraform version
python3 -c "import cdktf_cdktf_provider_aws as p; print(p.__name__)"
aws sts get-caller-identity --query 'Account' --output text
aws s3api list-object-versions --bucket acme-tfstate \
  --prefix prod-data/terraform.tfstate --max-items 1 --query 'Versions[0].VersionId'

Implementation

Step 1 — Describe the live bucket before writing any Python

An aws_s3_bucket in provider version 4 and later is deliberately thin. Versioning, encryption, lifecycle rules, public access blocking and ownership controls were all split into their own resource types, and each one is imported separately. If you only adopt the bucket you have adopted roughly a quarter of it.

# CLI: read the live configuration so the construct can mirror it
aws s3api get-bucket-versioning --bucket acme-prod-reports
aws s3api get-bucket-encryption --bucket acme-prod-reports
aws s3api get-public-access-block --bucket acme-prod-reports
aws s3api get-bucket-tagging --bucket acme-prod-reports
One bucket, four importable resources One bucket, four importable resources: layered from aws_s3_bucket down to aws_s3_bucket_public_access_block. aws_s3_bucket import ID: the bucket name aws_s3_bucket_versioning import ID: the bucket name aws_s3_bucket_server_side_encryption_configuration import ID: the bucket name aws_s3_bucket_public_access_block import ID: the bucket name
Adopting only the first line leaves three configurations unmanaged.

Write down what those four calls return. Status: Enabled on versioning, an SSE algorithm of aws:kms with a specific key ARN, all four public-access flags true, and whatever tags the organisation policy has attached. Every one of those becomes an argument, and every one you omit becomes a proposed change on the first plan.

Step 2 — Write the construct that matches the live bucket

Declare all four resources on the stack directly. A flat construct tree gives you logical IDs identical to the construct IDs, which keeps the import commands readable — nesting is a refactor for later, once the state entries exist.

# stacks/prod_data.py — the four resources that make up one adopted bucket
# CLI: cdktf synth
from constructs import Construct
from cdktf import App, S3Backend, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.s3_bucket import S3Bucket
from cdktf_cdktf_provider_aws.s3_bucket_public_access_block import S3BucketPublicAccessBlock
from cdktf_cdktf_provider_aws.s3_bucket_server_side_encryption_configuration import (
    S3BucketServerSideEncryptionConfigurationA,
    S3BucketServerSideEncryptionConfigurationRuleA,
    S3BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefault,
)
from cdktf_cdktf_provider_aws.s3_bucket_versioning import (
    S3BucketVersioningA,
    S3BucketVersioningVersioningConfiguration,
)

BUCKET: str = "acme-prod-reports"
KMS_KEY: str = "arn:aws:kms:eu-west-1:111122223333:key/8f0c1e2a-1b3d-4c5e-9a7b-2d6f4e8c0a11"


class ProdDataStack(TerraformStack):
    def __init__(self, scope: Construct, ns: str) -> None:
        super().__init__(scope, ns)
        AwsProvider(self, "aws", region="eu-west-1")
        S3Backend(self, bucket="acme-tfstate", key=f"{ns}/terraform.tfstate",
                  region="eu-west-1", dynamodb_table="acme-tfstate-locks")

        self.reports: S3Bucket = S3Bucket(
            self, "reports", bucket=BUCKET,
            tags={"owner": "platform", "data-class": "internal"},
        )

        S3BucketVersioningA(
            self, "reports_versioning", bucket=self.reports.id,
            versioning_configuration=S3BucketVersioningVersioningConfiguration(status="Enabled"),
        )
        S3BucketServerSideEncryptionConfigurationA(
            self, "reports_sse", bucket=self.reports.id,
            rule=[S3BucketServerSideEncryptionConfigurationRuleA(
                apply_server_side_encryption_by_default=(
                    S3BucketServerSideEncryptionConfigurationRuleApplyServerSideEncryptionByDefault(
                        sse_algorithm="aws:kms", kms_master_key_id=KMS_KEY)),
                bucket_key_enabled=True)],
        )
        S3BucketPublicAccessBlock(
            self, "reports_pab", bucket=self.reports.id,
            block_public_acls=True, block_public_policy=True,
            ignore_public_acls=True, restrict_public_buckets=True,
        )
        # Provider note: the trailing "A" on two of these class names is a jsii collision
        # rename, not a typo — the struct S3BucketVersioning already owns the short name.


def main() -> None:
    app = App()
    ProdDataStack(app, "prod-data")
    app.synth()


if __name__ == "__main__":
    main()

Step 3 — Synthesize and find the addresses in cdk.tf.json

cdk.tf.json is the only authority on what an import can target. Its resource object is keyed first by Terraform resource type and then by logical ID, so listing every valid target is a two-level key walk.

# CLI: enumerate the import targets this synthesis produced
cdktf synth
jq -r '.resource | to_entries[] | .key as $t | .value | keys[] | "\($t).\(.)"' \
  cdktf.out/stacks/prod-data/cdk.tf.json | sort
# aws_s3_bucket.reports
# aws_s3_bucket_public_access_block.reports_pab
# aws_s3_bucket_server_side_encryption_configuration.reports_sse
# aws_s3_bucket_versioning.reports_versioning

If an address you expect is missing, do not improvise. Re-check whether the construct is nested — a construct declared inside an intermediate Construct gets its path components joined and a short path hash appended, so reports becomes something like storage_reports_C4F19A2B. Import against whatever the JSON says, character for character.

Step 4 — Import each address from inside the synthesized directory

Now the actual adoption. All four S3 resource types take the bucket name as their import ID, which makes this batch unusually pleasant; most services are not so uniform.

# CLI: attach four live objects to four synthesized addresses
cd cdktf.out/stacks/prod-data
terraform init -input=false
terraform import aws_s3_bucket.reports acme-prod-reports
terraform import aws_s3_bucket_versioning.reports_versioning acme-prod-reports
terraform import aws_s3_bucket_server_side_encryption_configuration.reports_sse acme-prod-reports
terraform import aws_s3_bucket_public_access_block.reports_pab acme-prod-reports
# State implication: four instance records are written to the remote backend and the
# lock is taken and released four times; nothing in S3 is modified.

Two failures show up here constantly. Running the import before cdktf synth produces:

Error: resource address "aws_s3_bucket.reports" does not exist in the configuration.

Before importing this resource, please create its configuration in the root module.

Running it against the wrong region or a mistyped name produces:

Error: Cannot import non-existent remote object

While attempting to import an existing object to "aws_s3_bucket.reports", the provider
detected that no object exists with the given id. Only pre-existing objects can be
imported; check that the id is correct and that it is associated with the provider's
configured region or endpoint.
Import ID formats you meet in the first wave Import ID formats you meet in the first wave: comparison across Import ID, Read it from. Resource type Import ID Read it from aws_s3_bucket acme-prod-reports the bucket name aws_security_group sg-0a1b2c3d... describe-security-groups aws_vpc_security_group_ingress_rule sgr-0f3b8c1d... describe-security-group-rules aws_iam_role acme-batch-runner the role name aws_security_group_rule sg-...ingress_tcp_443_443_cidr assembled by hand
Only the S3 family is uniform; everything else needs a lookup before you type the command.

For anything past a handful of addresses, drive the loop from Python rather than by hand, so the mapping from address to import ID is a reviewable artefact instead of shell history.

# tools/import_wave.py — replayable adoption driven from a checked-in mapping
# CLI: python3 tools/import_wave.py --stack prod-data --plan tools/wave1.json
from __future__ import annotations

import argparse
import json
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Set


@dataclass(frozen=True)
class ImportTarget:
    address: str
    import_id: str


def load_targets(path: Path) -> List[ImportTarget]:
    raw: Dict[str, str] = json.loads(path.read_text())
    return [ImportTarget(address=a, import_id=i) for a, i in sorted(raw.items())]


def synthesized_addresses(stack_dir: Path) -> Set[str]:
    doc = json.loads((stack_dir / "cdk.tf.json").read_text())
    return {f"{t}.{lid}" for t, block in doc.get("resource", {}).items() for lid in block}


def run_import(stack_dir: Path, target: ImportTarget) -> int:
    proc = subprocess.run(
        ["terraform", f"-chdir={stack_dir}", "import", "-input=false",
         target.address, target.import_id],
        capture_output=True, text=True,
    )
    if proc.returncode != 0:
        already = "Resource already managed by Terraform" in proc.stderr
        print(f"  {'already imported' if already else 'FAILED'}: {target.address}")
        return 0 if already else proc.returncode
    print(f"  imported: {target.address} <- {target.import_id}")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description="Import a wave of AWS objects into a CDKTF stack.")
    ap.add_argument("--stack", required=True)
    ap.add_argument("--plan", type=Path, required=True)
    args = ap.parse_args()

    stack_dir = Path("cdktf.out/stacks") / args.stack
    targets = load_targets(args.plan)
    known = synthesized_addresses(stack_dir)

    missing = [t.address for t in targets if t.address not in known]
    if missing:
        print("addresses absent from cdk.tf.json — run cdktf synth first:", *missing, sep="\n  ")
        return 2

    failures = sum(1 for t in targets if run_import(stack_dir, t) != 0)
    print(f"imported {len(targets) - failures}/{len(targets)} targets")
    # State implication: this is not transactional — a failure halfway leaves the earlier
    # imports in state, which is why the mapping file must be safe to re-run.
    return 1 if failures else 0


if __name__ == "__main__":
    raise SystemExit(main())

Step 5 — Adopt the security group, where the arguments fight back

The bucket was easy because its arguments are mostly booleans. A security group is the opposite: it carries a rule set whose ordering, descriptions and CIDR formatting all participate in the diff, and it has two incompatible ways of being modelled.

Decide the model before importing. Inline ingress and egress blocks on aws_security_group come across with the group itself in one import. Standalone aws_vpc_security_group_ingress_rule resources are one import each, keyed by the rule's own sgr- identifier, which you must look up. The standalone form is the better long-term model — a rule can be added without a whole-group diff — but it costs more at adoption time.

# CLI: read the live group, then the individual rule IDs the standalone model needs
aws ec2 describe-security-groups --group-ids sg-0a1b2c3d4e5f6a7b8 \
  --query 'SecurityGroups[0].{name:GroupName,desc:Description,vpc:VpcId}'
aws ec2 describe-security-group-rules \
  --filters Name=group-id,Values=sg-0a1b2c3d4e5f6a7b8 \
  --query 'SecurityGroupRules[].{id:SecurityGroupRuleId,egress:IsEgress,port:FromPort,cidr:CidrIpv4}'
# stacks/prod_data.py (continued) — the group plus one standalone rule per direction
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-data plan
from cdktf_cdktf_provider_aws.security_group import SecurityGroup
from cdktf_cdktf_provider_aws.vpc_security_group_egress_rule import VpcSecurityGroupEgressRule
from cdktf_cdktf_provider_aws.vpc_security_group_ingress_rule import VpcSecurityGroupIngressRule

self.web_sg: SecurityGroup = SecurityGroup(
    self, "web",
    name="acme-prod-web",
    description="Managed by Terraform",   # the provider's own default; match it exactly
    vpc_id="vpc-04c9e1f7b3a25d6e0",
    tags={"owner": "platform"},
)
# Provider note: omitting description does NOT mean "leave it alone" — the provider
# substitutes "Managed by Terraform", and description forces replacement on
# aws_security_group, so a mismatch destroys and recreates the group.

VpcSecurityGroupIngressRule(
    self, "web_https",
    security_group_id=self.web_sg.id,
    cidr_ipv4="10.0.0.0/16",
    from_port=443, to_port=443, ip_protocol="tcp",
    description="https from the VPC",
)
VpcSecurityGroupEgressRule(
    self, "web_all_out",
    security_group_id=self.web_sg.id,
    cidr_ipv4="0.0.0.0/0",
    ip_protocol="-1",
)
# CLI: the group imports by sg- id, each standalone rule by its own sgr- id
terraform -chdir=cdktf.out/stacks/prod-data import \
  aws_security_group.web sg-0a1b2c3d4e5f6a7b8
terraform -chdir=cdktf.out/stacks/prod-data import \
  aws_vpc_security_group_ingress_rule.web_https sgr-0f3b8c1d9e4a2b6c7
terraform -chdir=cdktf.out/stacks/prod-data import \
  aws_vpc_security_group_egress_rule.web_all_out sgr-05d7a9e3c1b8f2064

The legacy aws_security_group_rule type, if you inherit one, imports by a composite string and rejects anything else with Error: unexpected format of ID (sg-0a1b2c3d4e5f6a7b8), expected SECURITYGROUPID_TYPE_PROTOCOL_FROMPORT_TOPORT_SOURCE. That message is the clearest signal in the whole workflow that you are guessing at an ID format rather than reading it from the API.

Verification

An import is finished when a plan proposes nothing at all. Anything less — "only tags", "only a description" — is an unfinished adoption that will surprise the next person to run an apply.

# CLI: the acceptance check, suitable for CI
terraform -chdir=cdktf.out/stacks/prod-data plan -detailed-exitcode -out=adopt.tfplan
# 0 = no changes, 2 = changes pending, 1 = error
Reading a non-empty first plan Reading a non-empty first plan: choose among 3 options. Plan after import is notempty update An argument ismissing from theconstruct replace An identity argumentdisagrees with AWS create That address neverreceived a stateentry
Three shapes, three responses — only one of them is routine.

When the exit code is 2, the plan JSON tells you precisely which of the three shapes you are in, and each one has a different response.

# tools/check_adoption.py — classify a non-empty plan instead of eyeballing it
# CLI: terraform -chdir=cdktf.out/stacks/prod-data show -json adopt.tfplan | python3 tools/check_adoption.py
from __future__ import annotations

import json
import sys
from typing import Any, Dict, List


def differing_keys(before: Dict[str, Any], after: Dict[str, Any]) -> List[str]:
    return sorted(k for k in after if before.get(k) != after.get(k))


def main() -> int:
    plan: Dict[str, Any] = json.load(sys.stdin)
    problems: List[str] = []
    for change in plan.get("resource_changes", []):
        actions: List[str] = change["change"]["actions"]
        if actions in (["no-op"], ["read"]):
            continue
        keys = differing_keys(change["change"].get("before") or {},
                              change["change"].get("after") or {})
        problems.append(f"{change['address']}: {','.join(actions)} -> {keys[:6]}")
    for line in problems:
        print(line)
    # State implication: a delete/create pair on an adopted resource means an identity
    # argument in the Python disagrees with the live object — fix the Python, not the state.
    return 1 if problems else 0


if __name__ == "__main__":
    raise SystemExit(main())

Run the same check once more with -refresh-only. If that plan is also empty, state and AWS agree; if it is not, the object changed underneath you and the problem is not in your Python.

Gotchas & Edge Cases

cdktf synth regenerates cdktf.out. Anything you left in that directory — a generated.tf, a local terraform.tfstate, a scratch variables file — is disposable. Adopt into a remote backend, always, or you will import into a state file that the next synthesis deletes.

The bucket sub-resources are create-safe; the bucket is not. If you forget to import aws_s3_bucket_versioning, the apply calls PutBucketVersioning on an already-versioned bucket and nothing bad happens. If you forget to import aws_s3_bucket itself, the apply calls CreateBucket and fails with BucketAlreadyOwnedByYou: Your previous request to create the named bucket succeeded and you already own it. That asymmetry makes partial S3 adoption feel safer than it is.

Provider default_tags produce phantom diffs. If the stack sets default_tags and the live object already carries those tags with those values, the plan settles. If the object carries the same tag key with a different value, the plan proposes an update on every run until one side gives way.

terraform import has no plan step. It writes state the moment it succeeds. There is no dry run, so the safety net is the state backup you recorded in the prerequisites plus terraform state rm to undo an entry — which detaches the record without deleting anything in AWS.

Re-importing an address is an error, not a refresh. The second attempt fails with Error: Resource already managed by Terraform and a suggestion to remove it from state first. That is why the driver above treats that string as "already done" rather than as a failure.

Order matters when the group is referenced. Import the security group before anything whose configuration interpolates self.web_sg.id, or the dependent plan shows an unknown value where a concrete sg- identifier should be, and you cannot tell a real diff from a placeholder.

Operational Notes

Once an address is imported and its plan is clean, add import_from() to the construct in the same commit. It costs nothing — an import block whose target is already in state is a no-op — and it means a colleague who bootstraps a fresh state file reproduces the adoption from the code instead of from your terminal history.

# stacks/prod_data.py — make the adoption reproducible from source
# CLI: cdktf synth && terraform -chdir=cdktf.out/stacks/prod-data plan
self.reports.import_from(id="acme-prod-reports")
self.web_sg.import_from(id="sg-0a1b2c3d4e5f6a7b8")
# State implication: no effect when the entry already exists; on a fresh state the
# next apply adopts the live objects rather than creating new ones.

Keep the address-to-ID mapping in version control next to the stack, one JSON file per wave. It is the only durable record of why a given object sits at a given address, and it makes the whole wave replayable against a rebuilt backend.

Put prevent_destroy on anything holding data before the first import, not after. The window between the state entry appearing and the lifecycle block landing is exactly when a careless apply does damage, and closing it costs one line.

Finally, gate the merge on the empty plan. A CI job that runs cdktf synth followed by terraform plan -detailed-exitcode and fails on 2 turns adoption from a judgement call into a build status, which is the only way a wave of two hundred resources stays honest. The same job protects the imports you already finished from being quietly broken by an unrelated refactor that shifts a logical ID.

FAQ

Why can't I run the import from the repository root?

Because there is no Terraform working directory there. The provider configuration, the backend block and the resource definitions all live in cdktf.out/stacks/<stack>/cdk.tf.json, which is where terraform init created .terraform. Running from the root gives you a missing-configuration error or a backend prompt, not an import.

The plan wants to destroy and recreate the security group I just imported. Why?

Almost certainly description, name or vpc_id. All three force replacement on aws_security_group, and description in particular defaults to Managed by Terraform rather than being left alone. Read the # forces replacement annotations in the plan output — each one names the offending argument.

Do I have to re-import after every cdktf synth?

No. Synthesis rewrites the configuration; the state entry lives in the backend and is untouched. You only re-import if the logical ID changes, and in that case the correct fix is a moved block rather than a fresh import.

How do I find the import ID for a resource type I have not adopted before?

Look it up in the provider documentation for that exact resource type, then verify it against the live object with the AWS CLI before running anything. Guessing costs a failed import at best; at worst you attach the wrong object to an address and the next apply modifies something you did not intend.

Can I import several hundred addresses in one command?

Not with terraform import, which handles one address per invocation. For large waves, generate import blocks — or import_from() calls — and let a single plan and apply do the work, keeping each batch scoped to one wave so a failure is easy to unwind.

Should the import_from() calls stay in the code permanently?

They are harmless permanently, but they accumulate. A reasonable convention is to leave them for one release cycle after the wave lands, then delete them in a single sweep once every state entry is confirmed present in the backend.