# Local execution `cdkd local *` runs AWS workloads on the developer's machine via Docker — no AWS deploy, no `template.yaml` to maintain, no `cdk synth | sam ...` round-trip. Reuses cdkd's synthesis / asset / construct-path plumbing directly. ## Subcommands | Subcommand | Emulates | Backed by | | --- | --- | --- | | `cdkd local invoke ` | One-shot Lambda invoke | AWS Lambda Runtime Interface Emulator (RIE) container | | `cdkd local start-api` | Long-running API Gateway (REST v1 / HTTP API / Function URL) | RIE container pool + `node:http` listener (one server per discovered API) | | `cdkd local run-task ` | ECS `RunTask` for one task | docker network + ECS metadata sidecar (`amazon/amazon-ecs-local-container-endpoints`) | | `cdkd local start-service ` | Long-running ECS `Service` emulator | `run-task` machinery per replica + per-replica docker subnet allocator + restart-on-exit watcher | | `cdkd local invoke-agentcore ` | One-shot Bedrock AgentCore Runtime invoke | AgentCore container on port 8080 (HTTP `/invocations` / MCP `/mcp` / A2A `/a2a` / WebSocket `/ws`) | | `cdkd local start-agentcore [target]` | Long-running serve of a Bedrock AgentCore Runtime against a warm container (all four protocols) | AgentCore container on port 8080 (shimmed from cdk-local), booted once + kept warm. HTTP / AGUI: host `node:http` server proxies `POST /invocations` + `GET /ping` and fronts the bidirectional `/ws` endpoint (injects the session-id / Authorization a header-less browser client cannot set), both on one port. MCP serves `POST /mcp`; A2A serves `POST /` (no `/ws`) | | `cdkd local start-alb ` | Long-running local ALB front-door for ECS / Lambda backing services | shared ECS service emulator engine (shimmed from cdk-local) + per-listener `node:http(s)` front-door with path / host / header / weighted / redirect / fixed-response routing | | `cdkd local start-cloudfront [target]` | Long-running local CloudFront distribution (viewer-request -> S3 / Lambda Function URL origin -> viewer-response) | in-process `node:http(s)` server (shimmed from cdk-local) — CloudFront Functions in a `node:vm` sandbox + S3 origin served from the `BucketDeployment` source asset; Lambda Function URL origins run locally via the RIE container (Docker) | ## Requirements Most `cdkd local *` commands require Docker on the developer's machine. The first run pulls the relevant base image (~600MB for the language-specific Lambda images, ~50MB for `provided.*`, plus the ECS metadata sidecar for `run-task`). Subsequent runs reuse the cached image; pass `--no-pull` to skip the `docker pull` round-trip altogether (per-command `--no-pull` semantics may differ — see each section below). The exception is `local start-cloudfront` for a CloudFront-Functions + S3-origin-only distribution, which serves entirely in-process (CloudFront Functions in a `node:vm` sandbox, S3 origin from local files) and needs no Docker — but a distribution with a Lambda Function URL origin runs that origin's backing Lambda locally via the RIE container, so Docker is required in that case. ## Common flags Shared across all three subcommands: - `-a, --app ` — CDK app command or pre-synthesized `cdk.out` directory. Defaults to synth-every-time; pass `-a cdk.out` to iterate faster. - `--env-vars ` — SAM-compatible JSON override: `{"LogicalId":{"KEY":"VALUE"}, "Parameters":{...}}`. `null` clears a key. For Lambda (`local invoke` / `local start-api`) the function-specific key may also be a **CDK display path** (`MyStack/MyHandler` — the same form `cdkd local invoke ` accepts), matched against the resource's `Metadata['aws:cdk:path']`. Logical-ID and display-path entries coexist; if both list the same key, the later JSON entry wins (matches SAM's apply-in-order semantics). - `--no-pull` — Skip `docker pull` (per-command semantics differ; consult each section). - `--from-state` — Resolve intrinsic-valued properties against cdkd's deployed S3 state. Off by default; the target stack must have been deployed via `cdkd deploy` first. - `--stack-region ` — Disambiguate when the same stack name has cdkd state in multiple regions (only with `--from-state`). - `--container-host ` — Bind IP for published ports (default `127.0.0.1`). Must be a numeric IP; Docker rejects hostnames in `-p ::`. ## `local invoke` (run Lambda functions locally) `cdkd local invoke ` runs a Lambda function from a CDK app on the developer's machine, inside a Docker container that bundles the AWS Lambda Runtime Interface Emulator (RIE). Modeled on `sam local invoke` but reusing cdkd's synthesis / asset / construct-path plumbing. **Requires Docker.** The first invocation pulls the Lambda base image (`public.ecr.aws/lambda/nodejs:`, `public.ecr.aws/lambda/python:`, `public.ecr.aws/lambda/ruby:`, `public.ecr.aws/lambda/java:`, `public.ecr.aws/lambda/dotnet:`, or `public.ecr.aws/lambda/provided:` — ~600MB for the language-specific images, ~50MB for the OS-only `provided.*`); subsequent invocations reuse the cached image. Pass `--no-pull` to skip the `docker pull` round-trip altogether. Supported runtimes: `nodejs18.x` / `nodejs20.x` / `nodejs22.x` / `nodejs24.x` / `python3.11` / `python3.12` / `python3.13` / `python3.14` / `ruby3.2` / `ruby3.3` / `java8.al2` / `java11` / `java17` / `java21` / `dotnet6` / `dotnet8` / `provided.al2` / `provided.al2023`. The deprecated `go1.x` runtime is rejected with a migration pointer to `provided.al2023`. Java, .NET, and `provided.*` are **asset-backed only** — inline `Code.ZipFile` is rejected with a routing message ("use `lambda.Code.fromAsset(...)`") because the Handler shape names a compiled artifact (`package.Class::method` for Java's JVM class; `Assembly::Namespace.Class::Method` for .NET's CLR assembly; an arbitrary `bootstrap` binary for `provided.*`). A ZIP Lambda's `Architectures: [x86_64]` (default) / `[arm64]` is pinned to `--platform linux/amd64` / `linux/arm64` on the container's `docker run` (matching the container-image path). On an arch-mismatched host Docker emulates the function's declared arch, so a `provided.*` `bootstrap` compiled for the other architecture runs instead of failing with `fork/exec /var/runtime/bootstrap: exec format error` / `Runtime.InvalidEntrypoint`. The same pinning applies to `cdkd local start-api`'s warm-container pool. **Container Lambdas (PR 5 of #224)** — `lambda.DockerImageFunction(...)` / `Code.ImageUri` is supported in addition to ZIP Lambdas. cdkd reads the function's local `Dockerfile` from `cdk.out` (via the asset manifest keyed off the `:` suffix on `Code.ImageUri`) and runs `docker build` locally, then `docker run` against the resulting image. When no asset matches (typically: invoking a stack deployed elsewhere), cdkd falls back to `docker pull` from ECR. **Cross-account / cross-region pull is supported**: cdkd auto-detects cross-account from `sts:GetCallerIdentity`, builds the ECR client for the URI's region, and (when `--ecr-role-arn ` is passed) issues `sts:AssumeRole` to pick up permissions in the target account. Without `--ecr-role-arn`, cdkd falls through to the caller's credentials — works when the target ECR repository's resource policy grants the caller directly (AWS surfaces `AccessDenied` if missing, with a hint at the flag). `Architectures: [x86_64]` (default) and `[arm64]` are honored via `--platform linux/amd64` / `linux/arm64` on both the build and the run. ### Target resolution The positional `` accepts two forms: - **CDK display path** — `MyStack/MyApi/Handler`. Matches the same prefix-rule cdkd uses for `cdkd orphan`: an L2 path resolves to the synthesized L1 child (`MyStack/MyApi/Handler/Resource`). - **Stack-qualified logical ID** — `MyStack:MyApiHandler1234ABCD`. The colon is unambiguous because logical IDs cannot contain `/` or `:`. Single-stack apps may omit the stack prefix entirely: `cdkd local invoke MyHandler` is valid when the app contains exactly one stack (mirrors `cdkd deploy` / `cdkd destroy` auto-detect). When the target does not match anything, the error lists every Lambda in the resolved stack so the user can copy/paste a valid one. ### Options | Option | Default | Description | | --- | --- | --- | | `-e, --event ` | `{}` | JSON event payload file. | | `--event-stdin` | off | Read event JSON from stdin (mutually exclusive with `--event`). | | `--env-vars ` | — | JSON env-var overrides, SAM-compatible shape: `{"LogicalId":{"KEY":"VALUE"}}` plus an optional top-level `"Parameters"` block applied to every invoke. `null` clears a key. The function-specific key may also be a **CDK display path** (`MyStack/MyHandler` — same form `cdkd local invoke ` accepts). Both forms coexist; later JSON entry wins on conflict (SAM apply-in-order). | | `--no-pull` | off | Skip `docker pull`. Semantics differ by code path: **ZIP Lambdas** — skip pulling the public Lambda base image. **Container Lambdas, local-build path** — no-op (docker build's default does not refresh the FROM cache). **Container Lambdas, ECR-pull fallback** — skip `docker pull` AND error if the image is not in the local cache (re-run without `--no-pull` or pre-pull manually). | | `--no-build` | off | Skip `docker build` on the **Container Lambdas, local-build path** (`Code.ImageUri`). Requires the deterministic `cdkd-local-invoke-` tag to already be in the local docker registry from a prior `cdkd local invoke` (or manual `docker build`); errors clearly when missing. **No-op for ZIP Lambdas** (no docker build runs there) AND for the **Container Lambdas, ECR-pull fallback** (use `--no-pull` to control that path). Compatible with `--no-pull`. | | `--ecr-role-arn ` | — | Role ARN to assume before authenticating against ECR on the **Container Lambdas, ECR-pull fallback** path. Issues `sts:AssumeRole` via the default credential chain and uses the resulting temp creds for `ecr:GetAuthorizationToken` + `docker pull`. Required for cross-account pulls when the caller's identity does not already have direct cross-account access. Same-account / same-region pulls do not need this flag; cross-account without the flag falls back to the caller's credentials (succeeds when an IAM resource policy on the ECR repo grants the caller directly, else AWS surfaces `AccessDenied`). No-op when `--no-pull` is set. | | `--debug-port ` | off | Set `NODE_OPTIONS=--inspect-brk=0.0.0.0:` and publish the port; attach a Node debugger to step through the handler. | | `--container-host ` | `127.0.0.1` | Host to bind the RIE port to. | | `--assume-role [arn]` | off | STS-assume the deployed function's execution role and forward the resulting temp credentials to the container, so the handler runs under the deployed role's narrow permissions instead of the developer's typically-admin shell credentials. Three forms: (1) `--assume-role ` assumes the explicit ARN (precedence wins); (2) `--assume-role` (bare) auto-resolves the function's `Properties.Role` from cdkd state (requires `--from-state`); (3) `--no-assume-role` explicitly opts out (forces dev creds even with `--from-state`). Off by default — when omitted, `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` / `AWS_REGION` are passed through unchanged (SAM-compatible default). STS failures degrade to a warn + dev-creds fallback. | | `-a, --app ` | — | CDK app command or pre-synthesized `cdk.out` directory. Default: synth every time (Q2 recommendation C). Pass `-a cdk.out` to skip synthesis when iterating. | | `--output ` | `cdk.out` | Output directory for synthesis. | | `--from-state` | off | Read cdkd's S3 state for the target stack and substitute `Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join` placeholders + AWS pseudo parameters (`${AWS::AccountId}` / `${AWS::Region}` / `${AWS::Partition}` / `${AWS::URLSuffix}`) in env vars with the deployed physical IDs / attributes. Off by default — keeps PR 1's literal-only / warn-and-drop behavior. See [State-driven env recovery (`--from-state`)](#state-driven-env-recovery---from-state) below. | | `--from-cfn-stack [cfn-stack-name]` | off | Read a deployed CloudFormation stack via `DescribeStackResources` and substitute `Ref` / `Fn::ImportValue` placeholders in env vars with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the cdkd stack name; pass an explicit value when the CFn stack name differs. **Mutually exclusive with `--from-state`** — pick one source. `Fn::GetAtt` in a consumer Lambda's own env vars is recovered from the deployed function config (`lambda:GetFunctionConfiguration`, via `cdk-local@0.10.0`); `Fn::GetAtt` at other sites still warn-and-drops, except a same-stack ECR repository's `Arn` / `RepositoryUri` in a container image URI (synthesized from the recovered physical name + pseudo parameters). See [CloudFormation-driven env recovery (`--from-cfn-stack`)](#cloudformation-driven-env-recovery---from-cfn-stack) below. | | `--state-bucket ` | auto | S3 bucket containing cdkd state. Falls back to `CDKD_STATE_BUCKET` env or `cdk.json context.cdkd.stateBucket`, then the default `cdkd-state-{accountId}`. Only used with `--from-state`. | | `--state-prefix ` | `cdkd` | S3 key prefix for state files. Only used with `--from-state`. | | `--stack-region ` | auto | Region of the state record to read. Required for `--from-state` when the same stack name has state in multiple regions. Also drives the CFn client region for `--from-cfn-stack` (cdkd does not have a separate `--cfn-stack-region` flag). | ### Environment variables Template `Properties.Environment.Variables` entries: - **Literal values** (string / number / boolean) are passed through as-is. - **Intrinsic-valued entries** (`Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join`, plus the `${AWS::AccountId}` / `${AWS::Region}` / `${AWS::Partition}` / `${AWS::URLSuffix}` pseudo parameters) need state (and a single `sts:GetCallerIdentity` for `${AWS::AccountId}`) to resolve. Without `--from-state` v1 emits a warning naming the variable and **drops** it (rather than silently substituting garbage); pass `--from-state` (see below) to recover deployed values from cdkd's S3 state, or override intrinsics via `--env-vars`. Standard Lambda runtime env vars are always set: `AWS_LAMBDA_FUNCTION_NAME`, `AWS_LAMBDA_FUNCTION_MEMORY_SIZE`, `AWS_LAMBDA_FUNCTION_TIMEOUT`, `AWS_LAMBDA_FUNCTION_VERSION`, `AWS_LAMBDA_LOG_GROUP_NAME`, `AWS_LAMBDA_LOG_STREAM_NAME`. The handler's `context.*` fields look real. ### State-driven env recovery (`--from-state`) When the target stack has been deployed with `cdkd deploy`, the function's intrinsic-valued env vars (`Ref` / `Fn::GetAtt` / `Fn::Sub`) reference resources whose physical IDs only exist in AWS. PR 1's behavior is to drop those entries with a warn — correct when there's no source of truth, but unhelpful when cdkd already knows them. `--from-state` opts in to reading cdkd's S3 state and substituting the deployed values before the env block reaches the container. **Resolution priority** (highest priority wins): 1. `--env-vars` file function-specific entry (`{LogicalId: {KEY: VALUE}}`). 2. `--env-vars` file global `Parameters` block. 3. `--from-state` substituted intrinsic (when the flag is set AND the template entry was a supported intrinsic AND substitution succeeded). 4. Template literal value. **Supported intrinsics**: `Ref` (→ `state.resources[id].physicalId`), `Fn::GetAtt` (→ `state.resources[id].attributes[attr]`, JSON-stringified when the cached value is an object/array), `Fn::Sub` (single-string and two-arg forms; `${LogicalId}` / `${LogicalId.attr}` / `${AWS::*}` placeholders are substituted in place — the two-arg form's bindings map can also carry intrinsic values, recursively resolved), `Fn::Join` (every element recursively resolved, then joined), and `Ref: AWS::*` pseudo parameters (`AccountId` / `Region` / `Partition` / `URLSuffix`) resolved against STS `GetCallerIdentity` + the configured region. **Failure mode**: per-key best-effort. When a substitution can't be produced (state missing for the referenced resource, attribute not captured at deploy time, unsupported intrinsic in `Fn::Sub`), the key is reported via warn and dropped — same UX as PR 1. State-load failures (no state record, multi-region ambiguity without `--stack-region`, bucket-resolution error) degrade to warn-and-fall-back rather than aborting the whole invoke. **Auto-assume execution role**: when `--from-state` is paired with bare `--assume-role` (no ARN argument), cdkd reads the function's `Properties.Role` from cdkd state, resolves `Fn::GetAtt: [, 'Arn']` shapes against the sibling IAM Role resource's recorded `Arn` attribute, and STS-assumes that role automatically — no manual ARN lookup required. When `--from-state` is set WITHOUT `--assume-role`, the legacy hint path fires instead: cdkd logs the deployed role ARN once so users can re-run with `--assume-role`. Pass `--no-assume-role` to explicitly opt out even with `--from-state`; pass `--assume-role ` to override the resolved ARN with an explicit one. STS failures (insufficient permissions / trust-policy mismatch) degrade to a warn + dev-creds fallback — this is a developer-loop tool, not a security boundary. **Pseudo parameters**: when the function's template env contains any intrinsic value, `cdkd local invoke --from-state` issues a single `sts:GetCallerIdentity` (for `${AWS::AccountId}`) and derives `partition` / `urlSuffix` from the resolved region (`--region` > `AWS_REGION` > `AWS_DEFAULT_REGION` > the synth-derived stack region). STS failures degrade to warn — substitution still runs for non-`AWS::*` refs; affected `${AWS::*}` placeholders fall back to warn + drop. Literal-only env maps skip the STS hop. **Out of scope** (deferred): cross-stack `Fn::ImportValue` / `Fn::GetStackOutput`, other intrinsics (`Fn::Select`, `Fn::Split`, `Fn::If`, etc.). Anything beyond the listed supported intrinsics is treated as unresolved (warn + drop). ```bash # Single-region stack: --from-state alone is enough cdkd deploy MyStack cdkd local invoke MyStack/MyApi/Handler --from-state # Multi-region: disambiguate the state record cdkd local invoke MyStack/MyApi/Handler --from-state --stack-region us-west-2 # Combine with --env-vars to override a single key (override wins) cdkd local invoke MyStack/MyApi/Handler --from-state \ --env-vars '{"Parameters":{"DEBUG":"1"}}' ``` ### CloudFormation-driven env recovery (`--from-cfn-stack`) `--from-state` only works when the target stack was deployed via `cdkd deploy` — cdkd reads its own S3 state and that state only exists for cdkd-deployed stacks. For CDK apps deployed via the upstream CDK CLI (`cdk deploy` → CloudFormation), use `--from-cfn-stack` instead: cdkd calls `cloudformation:DescribeStackResources` against the named CFn stack to populate the same per-logical-id physical-id map that `--from-state` would have built from cdkd state, then runs the existing substitution engine against it. ```bash # Bare flag — uses the cdkd stack name as the CFn stack name # (typical for CDK apps where they match). cdk deploy MyStack cdkd local invoke MyStack/MyApi/Handler --from-cfn-stack # Explicit CFn stack name — use when the deployed CFn stack name # differs from the cdkd / CDK display name (e.g. when CDK's `stackName` # prop was overridden). cdkd local invoke MyStack/MyApi/Handler --from-cfn-stack MyExplicitCfnStackName # Cross-region CFn stack — --stack-region drives the CFn client region. cdkd local invoke MyStack/MyApi/Handler --from-cfn-stack --stack-region eu-west-1 ``` **What's resolved**: `Ref: ` against `DescribeStackResources` (one CFn API call per stack) and `Fn::ImportValue: ` against `cloudformation:ListExports` (paginated, memoized for one substitution pass). **`Fn::GetAtt` is recovered for a consumer Lambda's OWN env vars; other sites warn-and-drop.** CFn's `DescribeStackResources` does NOT return per-attribute values — it only exposes `(LogicalResourceId, PhysicalResourceId, ResourceType)` triplets. But CloudFormation already resolved every intrinsic at deploy time, so a consumer Lambda's `Environment.Variables` already carries the concrete value. As of `cdk-local@0.10.0` (which cdkd consumes through the `--from-cfn-stack` shim), env keys whose template value is an `Fn::GetAtt` the static substituter could not resolve are filled at runtime by reading the deployed function's config (`lambda:GetFunctionConfiguration`) — this covers `Fn::GetAtt` / `Fn::Sub` / `Fn::ImportValue` / cross-stack `Ref` in Lambda env vars uniformly, without provider-specific describe calls. `Fn::GetAtt` at NON-Lambda-env sites (e.g. ECS container env) is still warn-and-dropped; override the affected entry via `--env-vars` if the value is critical. **`Fn::GetStackOutput` is rejected** with a clear warn naming the cdkd- vs-CFn gap: it's a cdkd-specific intrinsic with no CloudFormation equivalent (CFn cross-stack vocabulary is `Fn::ImportValue` against an explicit `Outputs..Export` block). Use `Fn::ImportValue` or pass `--from-state` instead. **Mutually exclusive with `--from-state`** — the CLI rejects the combination at parse time. The two flags target different state sources (cdkd's S3 state vs CloudFormation); asking for both is ambiguous about which wins. **Region handling**: the CFn client is region-bound at construction time using the precedence `--stack-region` > `--region` > `AWS_REGION` > `AWS_DEFAULT_REGION` > the synth-derived stack region. There is intentionally no separate `--cfn-stack-region` flag — `--stack-region` does double duty. When NONE of these signals is set the CLI **throws** with a remediation message (distinct from `--from-state`'s silent `us-east-1` fallback; CFn `DescribeStackResources` queries a specific region and silently picking `us-east-1` would query the wrong stack environment). **Multi-stack guard**: `local start-api` / `local start-service` route multiple stacks in one invocation. Bare `--from-cfn-stack` works there because each routed stack uses its own cdkd stack name as the CFn stack name. **Explicit `--from-cfn-stack ` is rejected** when more than one stack is routed (the explicit name would apply to every routed stack and silently mismap `Ref` lookups whose logical IDs happen to collide between siblings). Use bare `--from-cfn-stack` for multi-stack apps, or run one cdkd invocation per stack. **Failure modes**: `DescribeStackResources` failures (stack not found, access denied, throttling) degrade to a per-key warn + drop, same UX as the `--from-state` warn-and-fall-back path. `ListExports` failures only affect `Fn::ImportValue` resolution; same-stack `Ref` substitutions still succeed because they only need the `DescribeStackResources` result. ### Asset resolution **ZIP Lambdas**: cdkd uses the CDK-blessed `Metadata['aws:asset:path']` hint on each Lambda's CFn resource (the same source SAM uses) to find the local unzipped asset directory under `cdk.out`, and bind-mounts it at `/var/task` read-only. `Code.ZipFile` (inline) functions are materialized to a tmpdir using the file path implied by the function's `Handler` property (`index.handler` → `tmpdir/index.js`). ### Lambda Layers Same-stack `AWS::Lambda::LayerVersion` references in `Properties.Layers` are resolved automatically and bind-mounted at `/opt` (read-only) inside the container. The flow: 1. `cdkd local invoke` walks `Properties.Layers` left-to-right. 2. Each entry must be `{Ref: ''}` or `{Fn::GetAtt: ['', 'Ref']}` pointing at an `AWS::Lambda::LayerVersion` resource in the same stack. The layer's `Metadata['aws:asset:path']` is read the same way Lambda code is located — the layer asset is unzipped under `cdk.out/asset./` ready to bind-mount. 3. cdkd produces a single bind mount at `/opt`: - **Single layer**: the layer's asset dir is bind-mounted directly (no copy). - **Multiple layers**: each layer's contents are copied into a freshly-allocated tmpdir IN ORDER (later layers overwrite earlier files via `cpSync({force: true})`); the merged tmpdir is then bind-mounted at `/opt` and removed in the cleanup path. - The merge mirrors AWS Lambda's actual runtime behavior: AWS extracts every layer ZIP into `/opt` in template order so later layers shadow earlier files (**"last layer wins on file collision"**). cdkd cannot rely on multiple `-v ...:/opt:ro` entries — Docker rejects duplicate bind mounts at the same target path with `Error response from daemon: Duplicate mount point: /opt`. 4. The layer's directory layout (`/opt/python/...`, `/opt/nodejs/...`, `/opt/lib/...`, etc.) is the user's responsibility — cdkd does NOT inspect the contents. **Out of scope (v1)** — hard-errors with a clear pointer at the offending entry: - Literal-ARN layer entries (`arn:aws:lambda:...`) — these are external / pre-existing layers including cross-account / cross-region. No asset on disk to mount; deferred to a follow-up PR. - Same-stack refs that don't point at an `AWS::Lambda::LayerVersion` (typo'd logical ID). - Same-stack refs to a `LayerVersion` whose `Metadata['aws:asset:path']` is missing. **Container Lambdas** (`Code.ImageUri`): the `Layers` property is silently ignored — matches AWS behavior, since container images bake their layers at build time and AWS rejects `Layers` on container Lambdas at deploy time. **Container Lambdas** (`Code.ImageUri`): cdkd extracts the asset hash from the `:` tail of the image URI (CDK synthesizes the URI as a `Fn::Sub` whose body ends in the asset hash) and looks the matching entry up in the stack's asset manifest (`cdk.out/.assets.json`, `dockerImages[]`). When the lookup hits, `cdkd local invoke` calls `docker build` against the recorded build context. When the lookup misses AND the manifest contains exactly one Docker asset, that single asset is used (single-asset fallback — covers digest-pinned URIs). When both miss, cdkd falls back to **ECR pull** with cross-account / cross-region support: cdkd builds the ECR client for the URI's region and (when `--ecr-role-arn ` is passed) issues `sts:AssumeRole` to gain credentials in the target account before authenticating to ECR and pulling. Without `--ecr-role-arn`, cdkd uses the caller's credentials directly (works when the ECR repo's resource policy grants the caller, else AWS surfaces `AccessDenied` with a hint at the flag). `ImageConfig.Command` becomes the docker run CMD; `ImageConfig.EntryPoint` (when set) becomes `--entrypoint ` plus the rest as positional args; `ImageConfig.WorkingDirectory` becomes `--workdir`. When `EntryPoint` is unset (the common case), the image's default entrypoint stays in charge — for AWS Lambda base images that's `/lambda-entrypoint.sh`, which routes to RIE on port 8080. ### Ephemeral storage (`/tmp` cap) When a Lambda's template declares `Properties.EphemeralStorage.Size` (typical CDK shape: `new lambda.Function(this, 'X', { ephemeralStorageSize: cdk.Size.gibibytes(2) })`), `cdkd local invoke` adds `--tmpfs /tmp:rw,size=m` to the `docker run` command so the container's `/tmp` is a memory-backed filesystem capped at the templated value (`N` MiB; `cdk.Size.gibibytes(2)` serializes to `2048`). Handlers that exceed the deployed cap fail locally with `ENOSPC` the way they would on AWS, and handlers that detect free space via `statvfs` / `df` see the configured cap rather than the host's overlay-fs. Applies to both ZIP and IMAGE (container) Lambdas — `--tmpfs` overlays mount-time inside any container regardless of base image. Container Lambdas get an `[info]` log line at startup so users notice the `/tmp` override on top of whatever their Dockerfile placed there. When `EphemeralStorage` is absent, no `--tmpfs` is emitted and the container's `/tmp` is whatever the base image provides (AWS Lambda base images don't mount a sized tmpfs themselves, so the pre-#440 behavior is preserved). Templates over the AWS 10240 MiB (10 GiB) ceiling hard-error at resolve time with an actionable message rather than hanging on a `docker run` that AWS would have refused anyway. Intrinsic-valued `Size` entries (the `{Ref: 'SomeParam'}` shape) drop silently to no-`--tmpfs` since local invoke cannot resolve them without the Parameters context the deploy engine has. The same cap applies to `cdkd local start-api`'s warm container pool — each cold-started container for a Lambda with `EphemeralStorage` gets the same sized `/tmp`. ### Reaching a server on the host (`host.docker.internal`) The Lambda container can reach a server bound on the host loopback — an `AWS_ENDPOINT_URL_*` local endpoint (e.g. a local DynamoDB / S3 mock), or a tunneled VPC resource — via the `host.docker.internal` hostname. Docker Desktop (macOS / Windows) resolves it natively; on Linux native dockerd cdkd injects the `--add-host host.docker.internal:host-gateway` mapping automatically (Docker 20.10+). On an older / unavailable daemon the mapping is silently skipped (never an error). The same applies to `cdkd local run-task` container runs, and — inherited from cdk-local's ECS service emulator engine — to `cdkd local start-service` / `cdkd local start-alb`. ### `local invoke` exit codes - `0` — RIE answered, regardless of whether the handler returned a success payload OR an error payload. Lambda-style: a thrown handler produces a 200 with an error structure on AWS, and we mirror that. - `1` — cdkd-side errors before/after the handler ran: Docker not installed, image pull failed, target not found, RIE port unreachable after the readiness window, container exited before responding. ### v1 scope (out of scope, deferred) | Out of scope | Deferred to | | --- | --- | | Java / Go / Ruby / .NET runtimes | Future PRs | | Cross-account / cross-region / pre-existing-ARN Lambda Layers | Future PR (same-stack `AWS::Lambda::LayerVersion` refs are supported in v1; literal ARNs hard-error — see "Lambda Layers" section above) | | Cross-stack `Fn::ImportValue` / `Fn::GetStackOutput` in `--from-state` | Future PR | | `Fn::Select` / `Fn::Split` / `Fn::If` etc. in `--from-state` | Future PR (warn + drop today) | | SQS / S3 event source emulation | Future PR | | VPC simulation | Never (local can't replicate VPC) | | Custom Resources (`Custom::*`) | Never — these are invoked by the deploy framework, not by users. cdkd surfaces a clear error pointing at the underlying ServiceToken Lambda. | ## `local start-api` (long-running local API server) `cdkd local start-api` stands up a long-running HTTP server that maps synthesized API Gateway routes (REST v1, HTTP API, Function URL) to local Lambda invocations against the AWS Lambda Runtime Interface Emulator. Modeled on `sam local start-api` but reusing cdkd's synthesis, asset, and route-discovery plumbing — no `template.yaml` round-trip. **Requires Docker.** As with `cdkd local invoke`, the first run pulls the Lambda base image (~600MB once per machine). Pass `--no-pull` on subsequent runs to skip the layer check. ```bash cdkd local start-api # auto-allocate one port PER discovered API cdkd local start-api --port 3000 # first API → 3000, second API → 3001, ... cdkd local start-api MyAdminApi # logical id (single-stack apps) cdkd local start-api MyStack/MyAdminApi # OR: CDK Construct path (prefix-matched) cdkd local start-api --warm # pre-start one container per Lambda ``` ### One server per API (v0.81+) Every discovered API surface (`AWS::ApiGatewayV2::Api`, `AWS::ApiGateway::RestApi`, `AWS::Lambda::Url`) gets its own HTTP server on its own port. cdkd prints one `Server listening on http://: ( ())` line per server at startup, and one route table per server underneath. This is a deliberate departure from `sam local start-api`'s single-server-per-template model: realistic CDK apps usually define multiple APIs (admin + public, internal + external) with different authorizer setups, different CORS configs, and overlapping paths. Lumping them into one server forced an awkward "first-match-wins" semantic that didn't mirror AWS Lambda's actual routing. Pre-v0.81 versions did this — see [issue #260](https://github.com/go-to-k/cdkd/issues/260) for the background. Port assignment: | `--port` value | Per-API port allocation | | --- | --- | | `0` (default) | Every server auto-allocates its own port. | | `3000` | First API → `3000`, second API → `3001`, third → `3002`, ... | Pass an optional positional `` to launch exactly one server for the named API. The same target syntax `cdkd local invoke` / `cdkd local run-task` use applies here — the whole `cdkd local *` family addresses resources consistently: 1. **Bare logical id** — `MyHttpApi`. **Single-stack apps only**; in multi-stack apps cdkd rejects this form with the same disambiguation hint `local invoke` / `local run-task` produce. The id is the HTTP API / REST API logical id, or (for Function URLs) the backing Lambda's logical id. 2. **Stack-qualified logical id** — `MyStack:MyHttpApi`. Works in any app size; required when the same bare id exists in two stacks. 3. **CDK Construct path / display path** — `MyStack/MyHttpApi/Resource`. Exact match against the resource's `aws:cdk:path` metadata. 4. **CDK Construct path prefix** — `MyStack/MyHttpApi`. Matches when the input is a strict ancestor of the resource's `aws:cdk:path` (same prefix rule `cdkd orphan` uses): CDK's `new apigw2.HttpApi(stack, 'MyHttpApi')` synthesizes the L1 child at `MyStack/MyHttpApi/Resource`, so `cdkd local start-api MyStack/MyHttpApi` resolves cleanly without having to type the synthesized `/Resource` suffix. For Function URLs, the path forms reference the **backing Lambda's** `aws:cdk:path`, not the auto-generated URL resource — so `cdkd local start-api MyStack/MyHandler` matches the Function URL declared by `new lambda.Function(this, 'MyHandler').addFunctionUrl()`. Routes from templates without `aws:cdk:path` metadata (hand-rolled `cfn.Resource` defs, or older CDK that didn't emit the metadata) still match by bare logical id (form 1) and by stack-qualified logical id (form 2) — only the path forms (3, 4) need the metadata. **Deprecated `--api ` alias.** Earlier versions used a `--api` flag for the same purpose. The flag is still accepted in this release (emitting a deprecation warn on use) and accepts the same four forms; it will be removed in a future major release. Migrate scripts / CI to the positional form. Passing both positional and `--api` at once produces an error — they're mutually exclusive. ### Discovered routes | Source | CFn types | | --- | --- | | HTTP API | `AWS::ApiGatewayV2::Api` (`ProtocolType: HTTP`), `AWS::ApiGatewayV2::Route`, `AWS::ApiGatewayV2::Integration` | | REST v1 | `AWS::ApiGateway::RestApi`, `AWS::ApiGateway::Resource`, `AWS::ApiGateway::Method`, `AWS::ApiGateway::Stage` | | Function URL | `AWS::Lambda::Url` | Per-route classification (boot never aborts on per-integration unsupportedness): | Class | Trigger | Behavior | | --- | --- | --- | | Normal AWS_PROXY | AWS_PROXY integration with a resolvable Lambda Arn | Dispatched to the Lambda via the container pool. | | Synthetic CORS preflight | REST v1 `HttpMethod: OPTIONS` + `Integration.Type: MOCK` + `IntegrationResponses[].ResponseParameters` carries literal `method.response.header.*` pairs (the shape CDK's `defaultCorsPreflightOptions` synthesizes) | Captured at boot. The HTTP server returns the captured status + headers directly on OPTIONS without invoking any Lambda. | | Streaming Function URL | `AWS::Lambda::Url` with `InvokeMode: RESPONSE_STREAM` (issue #467) | Dispatched via the RIE streaming protocol: the request goes out with `Lambda-Runtime-Function-Response-Mode: streaming` and the response body's JSON prelude (`{statusCode, headers, cookies?}` + an 8-NULL-byte separator + raw body) is parsed; the body Readable is piped to the HTTP client with `Transfer-Encoding: chunked`. Note: AWS's local RIE buffers the response (verified empirically against `public.ecr.aws/lambda/nodejs:20`), so curl observes the chunks in one block locally even though cdkd's pipe / chunked-encoding machinery works correctly — real incremental delivery only manifests against the deployed Lambda runtime. | | REST v1 non-AWS_PROXY (closes #457) | `Integration.Type` is one of `MOCK` (non-CORS-preflight), `HTTP_PROXY`, `HTTP`, or `AWS` (Lambda non-proxy). | Dispatched via the per-kind handler in `src/local/rest-v1-integrations.ts`. MOCK / HTTP / AWS apply VTL request + response templates via the hand-rolled engine at `src/local/vtl-engine.ts`. HTTP_PROXY forwards verbatim with `RequestParameters` mappings. AWS Lambda non-proxy uses the same container pool as AWS_PROXY but transforms event payload + response via VTL and routes errors through `IntegrationResponses[].SelectionPattern`. | | Deferred-error unsupported | REST v1 AWS integration targeting a non-Lambda service (`:s3:path/...` / `:sqs:action/...` etc.); HTTP_PROXY / HTTP with a non-literal `Uri` (cdkd does not resolve Fn::Sub / Fn::Join in HTTP Uris); HTTP API v2 service integrations (`IntegrationSubtype` set); WebSocket APIs (`ProtocolType: WEBSOCKET`); Function URLs with an unrecognized `AuthType` (anything other than `'NONE'` / `'AWS_IAM'`); routes whose Lambda Arn intrinsic cannot be resolved against the same template (cross-stack / imported references) | Boot continues. The route appears in the route table tagged `[501 Not Implemented]` and a `[warn]` line per route is printed up front. When the route is hit at request time, the HTTP server returns HTTP 501 with `{"message": "Not Implemented", "reason": ""}` in the JSON body, without invoking any Lambda. | | Hard error | Template-structural problems the discovery layer cannot generate a meaningful route from: missing `Integration` on a Method, non-Ref `RestApiId` / `ApiId`, malformed Route `Target`, ParentId chain failures, missing `PathPart`, unresolvable `TargetFunctionArn` on a Function URL | Boot aborts via `RouteDiscoveryError` with every offending route listed in a single message. | The deferred-error class lets you run the supported subset of an API locally even when the CDK app contains direct AWS-service integrations, WebSocket routes, or other unimplemented shapes — only the unsupported routes themselves return 501; everything else dispatches as normal. ### REST v1 non-AWS_PROXY integrations (#457) `cdkd local start-api` emulates all four non-AWS_PROXY REST v1 integration types end-to-end: | Type | Behavior | Notes | | --- | --- | --- | | `MOCK` | Renders `Integration.RequestTemplates['application/json']` (VTL) to extract `{"statusCode": N}`; matches against `IntegrationResponses[].StatusCode`; renders the picked entry's `ResponseTemplates[]` (VTL) against an empty input context (`$inputRoot = null`). | When no request template is set, defaults to the entry with no `SelectionPattern`. `ResponseParameters` header literals (`'value'`) apply; mapping expressions (`integration.response.*` / `context.*`) are warn-and-skipped. | | `HTTP_PROXY` | Forwards the HTTP request to `Integration.Uri` with `{paramName}` path-placeholder substitution. Honors `Integration.IntegrationHttpMethod`. Applies `Integration.RequestParameters` (header `'literal'` / `method.request.header.X` mappings; querystring / path mappings are recognized but logged-and-skipped — use `{param}` URI substitution instead). | Forwards the upstream body verbatim. `IntegrationResponses[].SelectionPattern` (regex against the upstream status as a string) drives the final HTTP status; `ResponseParameters` applies. | | `HTTP` (non-proxy) | HTTP_PROXY + VTL on both directions: `RequestTemplates[]` transforms the body before sending; `IntegrationResponses[].ResponseTemplates[]` transforms the upstream body before returning. | Same `RequestParameters` semantics as HTTP_PROXY. | | `AWS` (Lambda non-proxy) | VTL request template synthesizes the Lambda event payload (parsed as JSON when the rendered template is valid JSON, otherwise passed through as a string — matches AWS-deployed behavior). The Lambda runs in the same warm RIE container pool as AWS_PROXY. Error envelope (`{errorMessage, errorType?, stackTrace?}`) routes through `SelectionPattern` against `errorMessage`. Response template runs with `$inputRoot = `. | Direct AWS-service integrations (`Type: 'AWS'` with `Uri` pointing at `:s3:path/...` / `:sqs:action/...` / etc.) are NOT emulated locally — they surface as deferred-501 unsupported routes. Deploy to AWS or pin a public HTTP_PROXY to a mock service. | The VTL engine at [src/local/vtl-engine.ts](../src/local/vtl-engine.ts) implements a hand-rolled minimal subset of AWS API Gateway's VTL spec. Supported features: - Variable references: `$var`, `${var}`, `$obj.field.subField` - Built-ins: - `$input.body` — raw request body - `$input.json('$.path')` — JSON-stringified slice (primitives JSON-quoted) - `$input.path('$.path')` — native value - `$input.params()` — `{header, querystring, path}` union - `$input.params('name')` — path > query > header precedence - `$input.params('header').` / `.querystring` / `.path` - `$context.requestId` / `httpMethod` / `resourcePath` / `stage` - `$context.identity.sourceIp` / `userAgent` - `$util.escapeJavaScript(s)` / `base64Encode` / `base64Decode` / `urlEncode` / `urlDecode` / `parseJson` - Directives: `#set($var = expr)`, `#if(cond)` / `#elseif` / `#else` / `#end`, `#foreach($x in $list)` / `#end`, `##` line comments - Operators: `&&`, `||`, `!`, `==`, `!=`, `<`, `<=`, `>`, `>=` - JSONPath subset: `$`, `$.field`, `$.field.sub`, `$.array[index]`, quoted-string bracket keys **Intentionally NOT supported** (any usage surfaces `VtlEvaluationError` with the offending construct named in the message — converted to HTTP 502 + reason JSON body at request time): - Velocity arithmetic operators (`+ - * /`) outside literal concat - User-defined `#macro` - `#parse` / `#include` - Range operator (`[1..5]`) - `$velocityCount` and other Velocity context built-ins - JSONPath filter expressions (`$..items`, `$.items[?(@.x > 5)]`) ### Routing precedence 3 tiers per AWS docs: full match → greedy `{proxy+}` → `$default`. Within "full match" tier, more literal segments win as a best-effort tie-break (AWS does not formally specify multi-route precedence within the same tier; cdkd uses literal-segment count as a heuristic). ### Flags | Flag | Default | Notes | | --- | --- | --- | | `--port ` | auto-allocate | First API server's port (subsequent APIs get `port+1`, `port+2`, ...). Pass `0` (default) to auto-allocate each. The actual port assignment is printed at startup. | | `--host ` | `127.0.0.1` | Bind address. | | `--api ` | unset | **Deprecated** — use the positional `` argument instead. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Emits a deprecation warn on use. Mutually exclusive with the positional `` — passing both produces an error. Will be removed in a future major release. | | `--stack ` | single-stack auto-detect | Required when the app has multiple stacks AND no other selector identifies the target. In multi-stack apps the synth stack is picked from the first match of: (1) `--stack `, (2) `--from-cfn-stack `, (3) the positional target's stack-name prefix (e.g. `MyStack/MyApi` → `MyStack`). | | `--warm` | off | Pre-start one container per discovered Lambda at server boot. Trades RAM for first-request latency. | | `--per-lambda-concurrency ` | `2` | Pool size cap per Lambda. Max 4 in v1; above-cap values are clamped with a warn. | | `--no-pull` | off | Skip `docker pull`. | | `--container-host ` | `127.0.0.1` | IP the host uses to bind/probe the RIE port. Must be a numeric IP — `docker run -p ::8080` rejects hostnames like `host.docker.internal`. | | `--debug-port-base ` | unset | Allocate a contiguous `--inspect-brk` port range across Lambdas (one per Lambda). | | `--env-vars ` | unset | SAM-shape JSON: `{"LogicalId":{"KEY":"VALUE"}, "Parameters":{...}}`. Same format as `cdkd local invoke` — the function-specific key may also be a **CDK display path** (`MyStack/MyHandler`). | | `--assume-role ` | unset | Repeatable. Bare `` = global default; `=` = per-Lambda override. Per-Lambda > global > (`--assume-role-auto` OR global default) > unset (developer creds passed through). | | `--assume-role-auto` | off | Auto-resolve EACH routed Lambda's OWN execution role per-Lambda instead of a single global default: tries the synthesized template's literal-ARN `Properties.Role`, then a deployed-state lookup (pair with `--from-state` / `--from-cfn-stack`), then warns-and-passes-through dev creds on a miss. Slower boot (one STS call per Lambda) but the right shape when each Lambda's deployed role differs. **Mutually exclusive** with the global-default `--assume-role ` form (errors at boot); **compatible** with per-Lambda `--assume-role =` overrides (the map wins for named Lambdas, auto-resolve handles the rest). | | `--watch` | off | Hot reload: watch the CDK app **source tree** (the synth working directory, where `cdk.json` lives) and re-synth + re-discover routes on a source edit, mirroring `cdk watch`. `cdk.out` / `node_modules` / `.git` are excluded and `cdk.json`'s `watch.include` / `watch.exclude` are honored. 500ms debounce. Synth failures keep the previous version serving (warn-and-continue, never crashes the server). | | `--stage ` | first attached | Select an API Gateway Stage by `StageName`. Drives `event.stageVariables` (REST v1 + HTTP API v2). When the override doesn't match any Stage on a given API, that API's routes get `stageVariables: null` and the CLI emits a warn line up front. | | `--from-state` | off | Read cdkd S3 state for every routed stack and substitute `Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join` placeholders + AWS pseudo parameters (`${AWS::AccountId}` / `${AWS::Region}` / `${AWS::Partition}` / `${AWS::URLSuffix}`) in Lambda env vars with the deployed physical IDs / attributes. Off by default — keeps the pre-PR literal-only / warn-and-drop behavior. Mirrors `cdkd local invoke --from-state` and `cdkd local run-task --from-state`. Re-runs against fresh state on every hot-reload firing (`--watch`). State load failures degrade per-stack to warn-and-fall-back so a missing or unreadable state file never aborts the server. | | `--from-cfn-stack [cfn-stack-name]` | off | Read a deployed CloudFormation stack via `DescribeStackResources` and substitute `Ref` / `Fn::ImportValue` in Lambda env vars with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). **The bare form is the typical shape** — `cdkd local start-api MyStack/MyApi --from-cfn-stack` resolves to the routed stack's CDK name (`MyStack` here) per routed stack. Pass an explicit value (`--from-cfn-stack `) only when the deployed CFn stack name differs from the CDK stack name (e.g. CDK's `stackName` prop was overridden); the explicit form is rejected when more than one stack is routed in one invocation. **Mutually exclusive with `--from-state`**. `Fn::GetAtt` in a consumer Lambda's own env vars is recovered from the deployed function config (`cdk-local@0.10.0`); other `Fn::GetAtt` sites still warn-and-drop. Same semantics as `cdkd local invoke --from-cfn-stack`. | | `--state-bucket ` | auto | S3 bucket containing cdkd state. Falls back to `CDKD_STATE_BUCKET` env or `cdk.json context.cdkd.stateBucket`, then the default `cdkd-state-{accountId}`. Only used with `--from-state`. | | `--state-prefix ` | `cdkd` | S3 key prefix for state files. Only used with `--from-state`. | | `--stack-region ` | auto | Region of the state record to read. Required for `--from-state` when the same stack name has state in multiple regions. Also drives the CFn client region for `--from-cfn-stack`. | | `--mtls-truststore ` | unset | PEM-encoded CA bundle for client-certificate verification. When set, the server switches from HTTP to HTTPS and the TLS handshake rejects clients whose certificate doesn't chain to one of these CAs. Must be set together with `--mtls-cert` + `--mtls-key`; partial flag sets are rejected. See the "mTLS (mutual TLS)" section below for the openssl recipe + event-shape details. | | `--mtls-cert ` | unset | PEM-encoded server certificate for mutual TLS. Self-signed is fine for local dev. Must be set together with `--mtls-truststore` + `--mtls-key`. | | `--mtls-key ` | unset | PEM-encoded server private key matching `--mtls-cert`. Must be set together with `--mtls-truststore` + `--mtls-cert`. | ### Hot reload (`--watch`) When `--watch` is set, cdkd installs a [chokidar](https://github.com/paulmillr/chokidar)-backed file watcher over the CDK app's **source tree** (the synth working directory, where `cdk.json` lives), excluding `cdk.out` / `node_modules` / `.git` and honoring `cdk.json`'s `watch.include` / `watch.exclude` (mirroring `cdk watch`). A source edit triggers a debounced (500ms window) reload: 1. Re-run `cdk synth` (skipped when `-a ` was passed at server boot — the directory is treated as already-synthesized). 2. Re-run route discovery, stage resolution, and CORS-config extraction. 3. Build per-Lambda specs + a fresh container pool. 4. Atomically swap the server state. Routes added / removed / changed take effect on the next request. 5. Dispose the previous pool in the background — in-flight requests complete against the old containers; new requests hit the new pool. Synth failures during reload do NOT crash the server. The previous version keeps serving and the CLI emits a `[warn]` line naming the failure. Reloads serialize, so a burst of file changes coalesces to one synth. ### CORS preflight cdkd's HTTP server intercepts OPTIONS preflight requests for HTTP API v2 routes whose `AWS::ApiGatewayV2::Api` has a `CorsConfiguration`: - Match `Origin` against `AllowOrigins` (literal entries or `*`). - Match `Access-Control-Request-Method` against `AllowMethods`. - Match each `Access-Control-Request-Headers` entry against `AllowHeaders` (case-insensitive). - Respond `204 No Content` with the canonical `Access-Control-Allow-*` headers, plus `Access-Control-Max-Age` / `Access-Control-Expose-Headers` / `Access-Control-Allow-Credentials` when configured. - Always set `Vary: Origin` so downstream caches (browser / CDN) do not share the response across origins (load-bearing whenever `Access-Control-Allow-Origin` was derived from the request — the wildcard echo, literal-origin echo, and `AllowCredentials` echo paths all qualify). When `AllowCredentials: true` AND the origin matched via `*`, the response echoes the request's literal `Origin` (browser fetch spec disallows `*` + credentials). `Access-Control-Request-Headers` lists are validated strictly: a malformed entry (e.g. `"Content-Type,,Authorization"` — a trailing / embedded empty entry) rejects the preflight rather than silently skipping the empty entry. This matches AWS's stricter HTTP API behavior on preflight headers. When the user has registered an explicit OPTIONS method on a path (an `AWS::ApiGatewayV2::Route` whose `RouteKey` is `OPTIONS /...`) **on the same API as the matched route**, preflight interception is skipped — the user's Lambda owns the OPTIONS surface. The same-API filter is load-bearing in multi-API stacks: an explicit OPTIONS route on Stack B's REST v1 API at the same path no longer suppresses preflight on Stack A's HTTP API v2. REST v1 (`AWS::ApiGateway::*`) CORS via Mock OPTIONS methods IS intercepted when the synthesized template matches CDK's `defaultCorsPreflightOptions` shape: `HttpMethod: 'OPTIONS'` + `Integration.Type: 'MOCK'` + `IntegrationResponses[].ResponseParameters` carrying literal `method.response.header.Access-Control-Allow-*` pairs. The headers are extracted at boot (AWS's `"'value'"` single-quote wrappers are stripped) and the HTTP server returns the captured status and headers directly on OPTIONS requests — no Lambda invocation, no VTL evaluation. The default status code is 204 (matches the CDK default); intrinsic-valued (`Fn::Sub` / `Ref` etc.) `ResponseParameters` are dropped silently because cdkd cannot evaluate VTL locally, and if the drop leaves zero header literals the route falls back to the deferred- error 501 class. Other REST v1 MOCK shapes (non-OPTIONS methods, MOCK without literal header parameters, MOCK with VTL `RequestTemplates` that produce custom bodies) are dispatched via the full MOCK handler in #457 — see the "REST v1 non-AWS_PROXY integrations" section above. ### Stage variables `event.stageVariables` is populated from the selected Stage's `Variables` (REST v1) / `StageVariables` (HTTP API v2) map. - **Default**: the first Stage attached to each API in template order. - **`--stage `**: select a Stage by `StageName`. Applied per-API — a `--stage prod` override against an app with three APIs picks the matching Stage on each. APIs without a matching Stage get `stageVariables: null` and surface a warn line at startup. The resolved stage name is threaded into `event.requestContext.stage` for **both** REST v1 and HTTP API v2 routes. AWS supports named stages on HTTP API v2 (`CreateStage` accepts any name; `$default` is the auto-deploy default but not the only option), so a v2 template that pins a named Stage gets that name surfaced through the integration event — matching what the deployed endpoint would emit. v2 APIs without a templated Stage continue to use `'$default'`. - **Function URL** routes don't have a Stage — `stageVariables` stays `null` regardless of the flag. - **Intrinsic-valued entries** (`Ref`, `Fn::GetAtt`, `Fn::Sub`) in the Stage's `Variables` map are dropped with a warn (mirrors PR 1's env-var policy — the local server has no deploy state to resolve them against). ### Container lifecycle - One pool per Lambda. Each container's RIE port is bound to its own free host port (`pickFreePort`); the user-facing HTTP server stays on the single `--port`. - `acquire()` returns the first idle container in the pool; lazy-grows up to `--per-lambda-concurrency` under a per-Lambda mutex. Above the cap, requests queue. - `release()` returns the container to the pool and starts a 60s idle timer. Idle GC fires after 60s of inactivity per pool. - Containers are named `cdkd-local---` so an external sweep can mop up orphans (`docker ps --filter name=cdkd-local-`). ### Lambda Layers in `local start-api` `cdkd local start-api` resolves same-stack `AWS::Lambda::LayerVersion` references the same way `cdkd local invoke` does — see the **Lambda Layers** section under `local invoke` above for the full rules (supported reference shapes, last-layer-wins on file collision, the single merged `/opt` bind mount, hard-error cases). The merge happens once per Lambda at server boot (not per request); the merged tmpdir is removed by the graceful shutdown path. Single-layer Lambdas skip the copy and bind-mount the layer's asset dir directly. ### Container Lambdas (`Code.ImageUri`) in `local start-api` `cdkd local start-api` supports `lambda.DockerImageFunction` / `Code.ImageUri` on the same terms as `cdkd local invoke` (see the **Container Lambdas** section under `local invoke` above). At server boot — and on every `--watch` reload — cdkd resolves each container Lambda's image once: **local-build** from the `cdk.out` asset manifest when the synthesizer produced a matching `dockerImages` entry (then `docker build` runs against the recorded build context), or **ECR-pull** fallback when no asset matches (same-account / same-region only, cross-account / cross-region deferred to a follow-up). The resulting deterministic `cdkd-local-invoke-` tag goes into the warm container pool; the pool runs `docker run` against it verbatim — no `/var/task` bind-mount, no base-image pull, `ImageConfig.Command` / `ImageConfig.EntryPoint` / `ImageConfig.WorkingDirectory` / `--platform` (from `Architectures`) all threaded through. Container Lambdas silently ignore `Properties.Layers` (matches AWS's invoke-time behavior — layers are baked into the image at build time on the IMAGE branch). Hot reload (`--watch`) detects Dockerfile / build-context changes via the content-addressed image tag: a real source edit flips the tag at the next reload's `docker build`, the spec signature compares unequal, and the pool entry tears down + restarts so the next request sees the new image. ### Graceful shutdown `SIGINT` / `SIGTERM` / `uncaughtException` / `unhandledRejection` all run the same dispose path: drain in-flight requests, tear down every container (tolerating per-container removal failures — logged at warn, loop continues). The verify-time `docker ps --filter` sweep is the defense-in-depth backstop. Double-`^C` bypasses dispose and exits immediately so the user can escape a hung Docker daemon. The skipped containers are reported with the `docker ps` cleanup command in the warning. ### `local start-api` exit codes - `0` — server started cleanly and shut down on SIGTERM. - `1` — startup failure (Docker missing, port bind failed, route discovery rejected) OR uncaught exception during the run. - `130` — exited via SIGINT. ### `local start-api` authorizers cdkd supports four authorizer kinds in front of any discovered route: - **Lambda TOKEN** (REST v1) — `AWS::ApiGateway::Authorizer.Type: 'TOKEN'`. The header named in `IdentitySource` (default `method.request.header.Authorization`) is forwarded to the authorizer Lambda as `event.authorizationToken`. The Lambda's response must carry a `policyDocument` with at least one `{ Effect: 'Allow', Resource: }` statement; cdkd matches `Resource` against the request's methodArn (literal or `*`/`?` wildcard) on every request — cached verdicts get re-evaluated against the new methodArn so a narrow-Resource Allow doesn't leak across routes. Allow → context flat under `event.requestContext.authorizer`. Policy-deny → HTTP 403, missing identity header → HTTP 401 without invoking the Lambda. - **Lambda REQUEST** — REST v1 (`Type: 'REQUEST'`) and HTTP v2 (`AuthorizerType: 'REQUEST'`). The full request snapshot (headers, query string, path parameters) is passed to the authorizer Lambda. HTTP v2 also accepts the simple `{ isAuthorized, context }` response shape in addition to the IAM-policy shape. REST v1 missing-identity → HTTP 401 without invoking the Lambda; HTTP v2 falls through. - **Cognito User Pool** (REST v1) — `Type: 'COGNITO_USER_POOLS'`. The Bearer token from `Authorization: Bearer ` is verified locally against the user pool's published JWKS. Allow → claims under `event.requestContext.authorizer.claims`. Deny → HTTP 403. - **JWT** (HTTP v2) — `AuthorizerType: 'JWT'`. Same JWKS-based verification, with `aud` / `client_id` matched against the `JwtConfiguration.Audience` allowlist. Allow → claims under `event.requestContext.authorizer.jwt.claims`. Deny → HTTP 401. Authorizer results are cached per `(authorizer, identity)` for the TTL declared by the authorizer (REST v1: `AuthorizerResultTtlInSeconds`, default 300s, max 3600s; HTTP v2: 0 by default = no cache; JWT: cached for `min(remaining-exp, 300s)`). **JWKS-fetch failure → pass-through.** When the JWKS endpoint is unreachable at startup, cdkd warns and falls back to a pass-through mode where every Bearer token is accepted as if valid (including malformed / non-JWT garbage — a real JWT still gets its claims surfaced into `event.requestContext.authorizer`, a malformed token gets a synthetic `unknown` principal and an empty claims map): ```text [warn] [cognito-jwt] JWKS unreachable at https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xyz/.well-known/jwks.json: ... JWT validation will allow all tokens — local dev fallback. Configure network access to the JWKS URL to enable real signature verification. ``` The failure entry has a short TTL (~60s) so a transient blip doesn't lock pass-through for the full 1hr success TTL — the next minute's request retries the JWKS fetch. The pass-through warn line itself fires at most once per JWKS URL per server lifecycle (the warn-set is constructed once at server startup, not per request). This is a deliberate dev-tool tradeoff: surprising deny is worse than warn+allow when the developer is iterating on a function and the JWKS URL is blocked by a corporate proxy. **Do NOT rely on this in any shared environment** — the dev's machine accepts every token, including forged ones. `AWS_IAM` authorization is supported with **signature-verification-only** semantics on BOTH REST v1 (`AuthorizationType: 'AWS_IAM'`) and Function URLs (`AuthType: 'AWS_IAM'`, issue #621) — see the next section. mTLS authorizers and any non-TOKEN/REQUEST/COGNITO_USER_POOLS Type / non-REQUEST/JWT AuthorizerType still hard-error at discovery with the offending route's location named. ### `local start-api` AWS_IAM authorizer (REST v1 + Function URL, signature verification only) Routes that declare REST v1 `AuthorizationType: 'AWS_IAM'` OR Function URL `AuthType: 'AWS_IAM'` boot and serve requests; cdkd verifies the inbound `Authorization: AWS4-HMAC-SHA256 ...` SigV4 signature against the developer's **local** AWS credentials (the same default credential chain every other cdkd command uses): 1. Parse the header into `(Credential, SignedHeaders, Signature)`. 2. Reconstruct the canonical request per the AWS SigV4 spec. 3. Derive the signing key from the local secret access key + the request's date / region / service scope. 4. Constant-time compare the recomputed signature with the header's. Outcomes: - **Valid signature with the dev's credentials** → request reaches the handler. - **REST v1**: the handler sees the access-key-id as `event.requestContext.authorizer.principalId` (flat v1 overlay). - **Function URL**: NO authorizer block is synthesized. The base v2 event's `requestContext.authorizer` stays `null`. AWS-deployed Function URLs write principal context under `event.requestContext.authorizer.iam.{accessKey, accountId, callerId, userArn, ...}`, and cdkd has no local IAM data plane to populate that block (no STS GetCallerIdentity per request, no policy emulation). Emitting principalId under `.lambda` would mislead handlers that defensive-read `.iam`, so the deployed and local behavior diverge only by absence of identity context — never by location. - **No / malformed `Authorization` header**, **signature mismatch under the dev's own credentials**, or any other rejection → 403 matching the deployed response: - REST v1: 403 (`{"message":"Missing Authentication Token"}`) for missing-identity, 403 (`{"message":"Forbidden"}`) for policy-deny — matches AWS-deployed API Gateway REST v1 IAM rejection (lowercase `message`). - Function URL: 403 (`{"Message":"Forbidden"}`) for both deny kinds — matches Lambda's deployed Function URL IAM rejection (capital `Message`). - **Different `Credential` access-key-id than the dev has** → warn-and-pass. The local server cannot reproduce a signing key it doesn't have, and refusing every foreign-identity request would defeat the dev-tool purpose. The warn fires at most once per foreign access-key-id per server lifecycle. Pass `--strict-sigv4` to opt IN to fail-closed mode — every unverifiable signature (foreign access-key-id, missing local AWS credentials, etc.) is denied with the same 403 the deployed API Gateway would return. Use this when you want local parity with the deployed signature-enforcement boundary. The default (warn-and-pass) matches cdk-local's `cdkl start-api`. **What is NOT verified locally** (deliberately out of scope): - IAM resource / action / condition policy evaluation. The local server has no IAM data plane. Signature-verified callers reach the handler under their own identity; downstream authorization is the dev's responsibility. Use the deployed API to test the full IAM policy surface. - STS temporary credentials' session-token validation against AWS. We accept whatever session-token the request was signed with. At startup cdkd emits a one-line warn naming every IAM-protected route so the developer is aware of the signature-verification-only boundary: ```text [warn] 2 route(s) declare AuthorizationType: AWS_IAM — cdkd local start-api verifies SigV4 signatures against your local AWS credentials, but does NOT emulate IAM policy evaluation (resource / action / condition rules). Signature-verified callers reach the handler under their own identity; downstream authorization is the dev's responsibility. [warn] - MyStack/ProtectedMethod [warn] - MyStack/AnotherProtectedMethod ``` Tooling that signs requests works out of the box — common helpers include `aws-sigv4-sdk` (AWS SDK v3 signer), `curl --aws-sigv4`, Postman's AWS Signature auth, and the `awscurl` CLI. ### `local start-api` VPC-config Lambdas Lambdas with `Properties.VpcConfig` set still run locally — cdkd does NOT block these — but the local container does NOT get attached to the deployed VPC's subnets. Calls from the handler to private RDS / ElastiCache / VPC-only endpoints will fail. cdkd surfaces a one-line warn at startup naming each affected Lambda: ```text [warn] Lambda MyVpcLambda has VpcConfig — local container will reach external services via the host's network, NOT through the deployed VPC's NAT/private subnets. Calls to private RDS/ElastiCache will fail. ``` AWS SDK calls from the container still use the developer's shell credentials (or `--assume-role`-issued temp creds) and reach the public AWS endpoints; nothing about that path changes. ### `local start-api` mTLS (mutual TLS) `cdkd local start-api` supports API Gateway custom-domain mutual TLS: when all three `--mtls-truststore ` / `--mtls-cert ` / `--mtls-key ` flags are set, the server switches from plain HTTP to HTTPS and the TLS handshake itself enforces the client-certificate trust check against the supplied CA bundle. Clients without a cert, with a self-signed cert, or with a cert that doesn't chain to one of the CAs in the trust store are rejected by Node's `tls` module BEFORE the request reaches cdkd's per-request handler — no per-request code path is needed. The verified client certificate is surfaced on the Lambda event under: - **REST v1**: `event.requestContext.identity.clientCert` - **HTTP API v2**: `event.requestContext.authentication.clientCert` Both shapes match AWS API Gateway's deployed-mTLS event shape: ```json { "clientCertPem": "-----BEGIN CERTIFICATE-----\n...", "subjectDN": "CN=client,O=example,C=US", "issuerDN": "CN=My CA,O=example,C=US", "serialNumber": "01:23:45:...", "validity": { "notBefore": "May 22 03:30:00 2026 GMT", "notAfter": "May 22 03:30:00 2027 GMT" } } ``` mTLS runs ORTHOGONALLY to the existing TOKEN / REQUEST / COGNITO_USER_POOLS / JWT authorizers — the TLS handshake completes first (rejecting unknown-CA clients), then the authorizer pipeline runs against the already-authenticated client. **Partial flag sets are rejected at CLI parse time** (the server never boots in a half-configured state): if any of the three flags is set, all three must be set. Leave all three unset for plain HTTP (the pre-PR default). #### Generating a local CA + server + client cert with openssl ```bash # 1. Create a local CA openssl req -x509 -newkey rsa:2048 -nodes \ -keyout ca-key.pem -out ca.pem \ -subj "/CN=cdkd-local-ca" -days 365 # 2. Generate a server cert signed by the local CA openssl req -newkey rsa:2048 -nodes \ -keyout server-key.pem -out server-csr.pem \ -subj "/CN=localhost" openssl x509 -req -in server-csr.pem \ -CA ca.pem -CAkey ca-key.pem -CAcreateserial \ -out server-cert.pem -days 365 # 3. Generate a client cert signed by the local CA openssl req -newkey rsa:2048 -nodes \ -keyout client-key.pem -out client-csr.pem \ -subj "/CN=client" openssl x509 -req -in client-csr.pem \ -CA ca.pem -CAkey ca-key.pem -CAcreateserial \ -out client-cert.pem -days 365 # 4. Start the server with mTLS enabled cdkd local start-api \ --mtls-truststore ca.pem \ --mtls-cert server-cert.pem \ --mtls-key server-key.pem # 5. curl the server with the client cert curl --cacert ca.pem \ --cert client-cert.pem --key client-key.pem \ https://localhost:/items ``` #### mTLS scope - The mTLS configuration is at the SERVER level (the equivalent of an API Gateway custom-domain `MutualTlsAuthentication.TruststoreUri`). cdkd does NOT parse the synth template's `AWS::ApiGateway::DomainName` / `AWS::ApiGatewayV2::DomainName` resources — the CLI flags are the authoritative source. If your CDK app declares mTLS on a DomainName, you can re-use the same CA bundle locally by pointing `--mtls-truststore` at the file you uploaded to the deployed truststore S3 location. - The server cert and key are for the LOCAL server only (clients connect to `localhost`). Self-signed is the typical case. - AWS-deployed mTLS uses `MutualTlsAuthentication.TruststoreVersion` for live trust-store updates; the local server reads the `--mtls-truststore` file once at boot. Restart `cdkd local start-api` to pick up a new CA bundle (the `--watch` reload pipeline does NOT re-read the mTLS materials). ### `local start-api` v1 scope (out of scope, deferred) | Out of scope | Deferred to | | --- | --- | | AWS_IAM authorizer (REST v1 + Function URL) — IAM policy evaluation (resource/action/condition). Signature verification IS implemented on both surfaces (#447 for REST v1, #621 for Function URL). | Out of scope (the local server has no IAM data plane) | | REST v1 AWS integration with non-Lambda service backend (`:s3:path/...` / `:sqs:action/...` / `:dynamodb:action/...` / etc.) | Future PR — requires per-service SDK clients, IAM credential threading, and a per-service compatibility matrix. v1 emulates Lambda non-proxy AWS integrations only (#457). | | VTL features outside the supported subset (arithmetic outside literal concat, `#macro` / `#parse` / `#include`, range operator, `$velocityCount`, JSONPath filter expressions) | Surface as `VtlEvaluationError` → HTTP 502 + reason body. Hand-roll the missing feature in `src/local/vtl-engine.ts` if a real workload needs it. | | WebSocket APIs | Never (different protocol) | | Throttling / quotas / usage plans / API keys | Never | | Per-Lambda concurrency above 4 | Future PR if a real workload needs it | ## `local run-task` (run an ECS task definition locally) `cdkd local run-task ` is the ECS counterpart of `cdkd local invoke`. It takes an `AWS::ECS::TaskDefinition` defined in a CDK app and starts every container on the developer's Docker host — no AWS deploy needed. Implementation Phase 1: synchronous run of one task, stream every container's stdout/stderr with a `[]` prefix, propagate the essential container's exit code. Phase 2 (`cdkd local start-service` — ECS Service replicas + restart policy) and Phase 3 (Service Connect / Cloud Map cross-service discovery via `--add-host` DNS overlay) are implemented; ALB-emulated path/host-based routing remains deferred. **Requires Docker.** The first run pulls the AWS-published `amazon/amazon-ecs-local-container-endpoints:latest-amd64` sidecar (a small Go binary maintained by awslabs) plus each container's image. ### `local run-task` target resolution Same target-syntax rules as `cdkd local invoke`: - CDK display path (`MyStack/MyService/TaskDef`) — preferred - Stack-qualified logical id (`MyStack:MyServiceTaskDefXYZ1234`) - Single-stack apps may omit the stack prefix (`MyTaskDef`) Path matching is prefix-based: an L2 path like `MyStack/MyService/TaskDef` resolves to the synthesized L1 child (`MyStack/MyService/TaskDef/Resource`). ### `local run-task` options | Flag | Default | Behavior | | --- | --- | --- | | `--cluster ` | `cdkd-local` | Surfaced as `ECS_CONTAINER_METADATA_URI_V4`'s `Cluster` field and used as the docker network prefix (`-task-`). | | `--env-vars ` | unset | SAM-shape JSON overlay. Top-level keys are container names; `Parameters` is a global overlay. Same shape as `cdkd local invoke --env-vars`. | | `--container-host ` | `127.0.0.1` | Bind IP for `PortMappings` published ports. Must be a numeric IP — Docker rejects hostnames in `-p ::`. | | `--assume-task-role []` | unset (host creds pass through) | Bare flag uses the task definition's `TaskRoleArn`. Resolves a flat-string ARN directly; for `{Ref: }` / `{Fn::GetAtt: [, 'Arn']}` against a same-stack `AWS::IAM::Role`, cdkd substitutes the caller's account id (via STS `GetCallerIdentity`) into `arn:aws:iam:::role/`. Pass an explicit ARN to override. Either way, `sts:AssumeRole` runs once at startup; the resulting creds are exposed via the local metadata sidecar at `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`. | | `--from-state` | off | Load cdkd S3 state for the target stack and substitute deployed values into (a) `Fn::Sub` / `Fn::GetAtt` ECR image URIs that reference a same-stack `AWS::ECR::Repository`, AND (b) intrinsic-valued `ContainerDefinitions[].Environment[].Value` + `Secrets[].ValueFrom` entries (`Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join`). Without this flag, env / secret intrinsics are dropped with a per-key warning (matching `cdkd local invoke --from-state` semantics). See "ECR image resolution" and "Env / Secrets substitution" below. Off by default. The stack must have been deployed via `cdkd deploy` first. | | `--from-cfn-stack [cfn-stack-name]` | off | Read a deployed CloudFormation stack via `DescribeStackResources` and substitute `Ref` / `Fn::ImportValue` in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the cdkd stack name; pass an explicit value when the CFn stack name differs. **Mutually exclusive with `--from-state`**. `Fn::GetAtt` is warn-and-dropped in v1 (CFn `DescribeStackResources` does not return per-attribute values), except a same-stack ECR repository's `Arn` / `RepositoryUri` in a container image URI, which is synthesized from the recovered physical name + pseudo parameters. | | `--stack-region ` | unset | Region of the state record to read. Used with `--from-state` when the same stack name has state in multiple regions, and with `--from-cfn-stack` as the CFn client region. | | `--no-pull` | off | Skip `docker pull` for every container image and the metadata sidecar. | | `--ecr-role-arn ` | — | Role ARN to assume before authenticating against ECR for cross-account / centralized registry pulls. Issues `sts:AssumeRole` via the default credential chain and uses the resulting temp creds for `ecr:GetAuthorizationToken` + `docker pull` on every container whose `Image` resolves to an `.dkr.ecr..amazonaws.com/...` URI. Required when the caller's identity does not already have cross-account access to the target repository. Same-account / same-region pulls do not need this flag. No-op when `--no-pull` is set. | | `--platform ` | inferred from `RuntimePlatform.CpuArchitecture` | `linux/amd64` or `linux/arm64`. Threaded into every container's `docker run --platform`. | | `--keep-running` | off | Don't `docker rm -f` user containers on task exit (network + sidecar are still torn down). Use when you want to `docker exec` into a stopped container for post-mortems. | | `--detach` | off | Start the containers and return without streaming logs or auto-tearing them down. Useful in CI smoke tests; caller manages container lifecycle. | Plus the standard shared options: `-a/--app`, `-c/--context`, `--profile`, `--role-arn`, `--region`, `--verbose`, `--output`. ### Networking model For every task invocation cdkd: 1. Creates a fresh docker network `cdkd-local-task-` (or `--cluster -task-`) with subnet `169.254.170.0/24`. 2. Starts the AWS-published `amazon/amazon-ecs-local-container-endpoints:latest-amd64` sidecar on the network at the well-known IP `169.254.170.2`. 3. Starts every user container on the same network with `--network-alias ` so siblings resolve each other by their CFn `ContainerDefinitions[].Name`. 4. Injects per-container env vars: `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/` and (when `--assume-task-role` is set) `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/role/`. `awsvpc` network mode is mapped to `bridge` locally with a warn line — docker cannot emulate ENI-per-task. AWS SDK calls from inside the container still reach public AWS endpoints via the developer network. ### ECR image resolution `ContainerDefinitions[].Image` is parsed in three tiers: 1. **Public images** — `public.ecr.aws/...`, `docker.io/...`, `nginx:latest`, etc. → plain `docker pull` (subject to `--no-pull`). 2. **Direct ECR URIs** — `.dkr.ecr..amazonaws.com/:` (flat string, no intrinsics) → `pullEcrImage` (STS check + ECR auth + `docker pull`). Cross-account / cross-region supported: cdkd builds the ECR client for the URI's region and (when `--ecr-role-arn ` is passed) issues `sts:AssumeRole` to gain credentials in the target account. Without `--ecr-role-arn`, cdkd falls through to the caller's credentials (succeeds when an IAM resource policy grants the caller direct cross-account access). 3. **CDK-asset images** (`ContainerImage.fromAsset` / `DockerImageAsset`) → `cdk.out/.assets.json` lookup → `docker build` via the shared `src/assets/docker-build.ts` helper, tagged `cdkd-local-run-task-`. For `Fn::Sub` / `Fn::GetAtt` shapes pointing at AWS pseudo parameters or a same-stack ECR repository (the typical `ContainerImage.fromEcrRepository(repo)` synthesis), two additional resolution tiers fire **before** the URI is fed to tier 2: - **Tier 1 — AWS pseudo-parameter substitution (no state needed)**: `${AWS::AccountId}` → STS `GetCallerIdentity` (lazy, cached for the run); `${AWS::Region}` → `--region` / `AWS_REGION` / `AWS_DEFAULT_REGION`; `${AWS::Partition}` → derived from region (`cn-*` → `aws-cn`, `us-gov-*` → `aws-us-gov`, else `aws`); `${AWS::URLSuffix}` → matches partition. Substituted URI then routes through tier 2. - **Tier 2 — same-stack ECR Repository reference (state needed)**: when the `Fn::Sub` body contains `${}` against an `AWS::ECR::Repository`, or when the template uses `Fn::GetAtt: [, 'RepositoryUri']`, cdkd needs the deployed physical repo name. Pass `--from-state` (the stack must have been deployed via `cdkd deploy`); cdkd loads state, substitutes the physical name, then routes through tier 2. Without `--from-state` the error message points back at this flag as the resolution path. ### Env / Secrets substitution (`--from-state`) `ContainerDefinitions[].Environment[].Value` and `Secrets[].ValueFrom` entries are commonly intrinsic-valued in real-world CDK ECS apps — `table.tableName` synthesizes as `Ref`, `table.tableArn` as `Fn::GetAtt`, `ecs.Secret.fromSecretsManager(secret)` as `Ref` against the secret (returns the deployed ARN), `ecs.Secret.fromSsmParameter(p)` as `Fn::Join` over pseudo parameters + a `Ref` to the parameter, etc. Without `--from-state` these intrinsics are silently dropped (matching `cdkd local invoke` v1 semantics) and the developer sees an empty env var or a missing secret. `cdkd local run-task --from-state` substitutes every intrinsic-valued entry against cdkd's deployed S3 state plus AWS pseudo parameters: | Intrinsic | Source | | --- | --- | | `Ref: ` | `state.resources[].physicalId` | | `Fn::GetAtt: [, ]` | `state.resources[].attributes[]` | | `Fn::Sub: '...${X}...${AWS::Region}...'` | recursive substitution against state + pseudo parameters | | `Fn::Join: [, []]` | recursive substitution of every element, then `Array.join` | | `Ref: AWS::AccountId` / `AWS::Region` / `AWS::Partition` / `AWS::URLSuffix` | STS `GetCallerIdentity` (lazy, cached) + the resolved region + region-derived partition / URL suffix | Per-key best-effort: when a substitution can't be produced (state missing for a referenced logical ID, attribute not captured at deploy time, unsupported intrinsic), the env / secret entry is dropped and a per-key warning surfaces on the task's warnings line — the run-task invocation never aborts. State-load failures (no record, multi-region ambiguity without `--stack-region`, bucket resolution error) also degrade to warn-and-fall-back rather than hard-fail. Resolved `Secrets[].ValueFrom` strings then flow into the standard SecretsManager / SSM resolver below. ### Secrets / SSM parameter resolution `ContainerDefinitions[].Secrets[].ValueFrom` entries are resolved once at startup via the AWS SDK (after any `--from-state` intrinsic substitution above). Three accepted shapes: | `valueFrom` | API | | --- | --- | | `arn:aws:secretsmanager:::secret:` | `SecretsManagerClient.GetSecretValue` | | `arn:aws:secretsmanager:::secret::::` | `GetSecretValue`, then JSON.parse + extract `json-key` | | `arn:aws:ssm:::parameter/` | `SSMClient.GetParameter({ WithDecryption: true })` | Resolution failures (NotFound / AccessDenied / network error / invalid ARN) hard-fail with the offending container + secret name. The user fixes their AWS creds / IAM policy and re-runs. (Mirrors the `cdkd local invoke --from-state` philosophy: explicit failure beats silently-empty.) ### Container start ordering — `DependsOn` | Condition | What cdkd waits for | | --- | --- | | `START` | Dependency's `docker run` has returned. | | `COMPLETE` | Dependency's container has exited (any code). | | `SUCCESS` | Dependency's container has exited with exit code 0. | | `HEALTHY` | Dependency's `HEALTHCHECK` reports `healthy` (polled every 1s, capped at 5 min). | Cyclic dependencies → hard-error at discovery with the offending cycle named. Topological sort decides the start order; siblings with no dependsOn relation start in template order. ### Volumes | `Volumes[]` shape | Local realization | | --- | --- | | `Host: { SourcePath: '/some/path' }` | `docker run -v /some/path:` bind mount (caller's responsibility that the host path exists; a missing path emits a warn) | | `Host` (no `SourcePath`) | Docker anonymous volume — empty per-task scratch | | `DockerVolumeConfiguration: { Scope: 'task' \| 'shared', Driver, DriverOpts }` | `docker volume create --driver --opt ...` per task; per-task scope is torn down at exit | | `EFSVolumeConfiguration` | **Hard-error**. Bind-mount a local directory at the same `containerPath` instead. | | `FSxWindowsFileServerVolumeConfiguration` | **Hard-error**. | ### Lifecycle + teardown 1. The first `essential: true` container (defaults to `containers[0]` when no container declares `essential: false`) drives the task. 2. When the essential container exits, cdkd `docker stop`s every other container with a 10s grace then `docker rm -f`. 3. The metadata sidecar is `docker rm -f`'d and the docker network is removed. 4. cdkd exits with the essential container's exit code. `^C` triggers the same teardown. Double-`^C` exits 130 immediately (skipping container cleanup — same pattern as `cdkd local start-api`). `--detach` skips steps 1, 2, and 4. The sidecar and user containers stay running for the caller to manage. cdkd prints the network name on exit so you can `docker ps --filter network=` to inspect. `--keep-running` skips step 2 only. The network + sidecar are still torn down. Use to `docker exec` into a stopped container post-mortem. ### `local run-task` exit codes - `0` — essential container exited 0. - N (non-zero) — essential container exited N (cdkd propagates the code). - Various cdkd-side error codes (Docker missing, target not found, network creation failed, secret resolution failed, ...) follow the global handler's defaults (typically 1). ### `local run-task` Phase 1 scope (out of scope, deferred) | Out of scope | Why | | --- | --- | | `AWS::ECS::Service` / `DesiredCount` / `LaunchType` | Use `cdkd local start-service` instead | | ALB / NLB target group registration / listener rules | Deferred follow-up — needs an HTTP proxy emulator | | Service Connect / Cloud Map | Implemented for `cdkd local start-service` via `--add-host` DNS overlay ([#460](https://github.com/go-to-k/cdkd/issues/460)). `cdkd local run-task` is single-task by design; cross-service discovery is meaningful only with multiple long-running services, so it stays out of scope here. | | Auto Scaling / Deployment Strategy | Not meaningful locally | | Fargate vs EC2 launch-type differences (PID namespace, `awsvpc`-only, ephemeral storage cap) | Local Docker can't enforce these | | EFS / FSx volumes | Need real AWS NFS / SMB; hard-error with a routing hint | | ECS Exec | Use `docker exec` directly | | CloudWatch Logs auto-shipping (`logConfiguration.LogDriver: 'awslogs'`) | stdout/stderr already streamed; skip the driver | | X-Ray sidecar's AWS-API mocking | Run the daemon explicitly if you need it | | AWS App Mesh / Envoy fidelity | Not meaningful locally | | awsvpc / ENI complete fidelity | Map to docker bridge with a warn | ## `local start-service` (run an ECS Service locally) `cdkd local start-service ` is the long-running counterpart of `cdkd local run-task`. It locates an `AWS::ECS::Service` in the synthesized template, chains into the existing `run-task` machinery once per `DesiredCount` replica (clamped by `--max-tasks`, default 3), and keeps every replica running until `^C`. Failed replicas restart per `--restart-policy on-failure | always | none` with exponential backoff (1s → 30s capped) so a crash-looping container does not hammer docker. Each replica gets its own per-task docker network on a UNIQUE `169.254..0/24` subnet (170, 171, 172, ...; see [src/local/ecs-network.ts](../src/local/ecs-network.ts) `buildEndpointSubnet`) so concurrent replicas don't collide on a single /24 — the same metadata-endpoint sidecar starts at `169.254..2` per replica and every container's `ECS_CONTAINER_METADATA_URI_V4` is rewritten to point at its own replica's sidecar. > **Host-port publishing and multi-replica services.** A > **single-replica** service publishes its container `PortMappings` to > the host (`-p ::`) so you can > `curl localhost:` from the host. A **multi-replica** service > (effective replica count > 1 after the `--max-tasks` clamp) does NOT > publish host ports: N replicas all map the same container port, so a > fixed host-port publish would make the 2nd+ replica fail to boot with > `Bind for 127.0.0.1: failed: port is already allocated`. This > matches production — real ECS Service Connect / `awsvpc` tasks have > per-task ENIs and never share a host port. Peers still reach a > multi-replica service by container IP / network alias on the shared > docker network; to hit a specific replica from the host, `docker exec` > into it or read its IP from `docker inspect`. ### `local start-service` target resolution Same grammar as `local run-task`: - `Stack/Service/...` (display path) or `Stack:LogicalId` (logical id). - Single-stack apps may omit the stack prefix. - The target MUST resolve to an `AWS::ECS::Service`; passing a bare TaskDefinition surfaces a clear "use cdkd local run-task" hint. The Service's `TaskDefinition` property MUST be `{Ref: ''}` referencing a same-stack `AWS::ECS::TaskDefinition` (the standard CDK shape). Cross-stack TaskDefinitions and `Fn::ImportValue` shapes are rejected with a clear error. ### `local start-service` options | Flag | Default | Behavior | | --- | --- | --- | | `--cluster ` | `cdkd-local` | Cluster name surfaced to `ECS_CONTAINER_METADATA_URI_V4` and used as the docker network prefix. Each replica's network appends `-svc--r` so per-replica networks are easy to identify in `docker ps`. | | `--max-tasks ` | `3` | Hard cap on local replica count regardless of template `DesiredCount`. Local dev machines should not run an unbounded number of containers; raise this for production-shape workloads only when warranted. | | `--restart-policy

` | `on-failure` | Restart-on-exit behavior. `on-failure` restarts only on non-zero exit; `always` restarts on every exit (mirrors ECS Service deployment semantics more closely); `none` shuts the affected replica down and runs the service degraded. | | `--env-vars ` | — | SAM-shape JSON env-var overrides; same format as `run-task`. | | `--container-host ` | `127.0.0.1` | Host IP to bind published container ports to. Must be a numeric IP. | | `--assume-task-role [arn]` | unset | Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so every replica's containers run with the deployed task role. Same three-form grammar as `run-task`. | | `--ecr-role-arn ` | — | Role ARN to assume before ECR `docker pull` for cross-account / centralized registries. Same shape as `run-task`. | | `--platform

` | inferred | Force `--platform linux/amd64` or `linux/arm64`. | | `--no-pull` | off | Skip `docker pull` on every container image and the metadata sidecar. | | `--from-state` | off | Read cdkd S3 state and substitute intrinsic-valued env / secret / role-ARN / volume entries against the deployed cdkd state. Same shape as `cdkd local run-task --from-state`. | | `--from-cfn-stack [cfn-stack-name]` | off | Read a deployed CloudFormation stack via `DescribeStackResources` and substitute `Ref` / `Fn::ImportValue` in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the cdkd stack name (per target when multiple `` are supplied). **Mutually exclusive with `--from-state`**. `Fn::GetAtt` is warn-and-dropped in v1, except a same-stack ECR repository's `Arn` / `RepositoryUri` in a container image URI (synthesized from the recovered physical name + pseudo parameters). Same shape as `cdkd local run-task --from-cfn-stack`. | | `--stack-region ` | — | Region of the state record to read. Used with `--from-state` when the same stack name has state in multiple regions, and with `--from-cfn-stack` as the CFn client region. | | `--host-port ` | host port == container port | Publish a container port on a specific host port (e.g. `80=8080`); repeatable. Use this on macOS to map a privileged container port (< 1024) to a non-privileged host port and avoid the Docker Desktop admin-password prompt. **Single-replica services only** — multi-replica services do not publish host ports. | | `--watch` | off | Hot reload: re-synth + per-replica reload when the CDK source changes (`cdk.json watch.include` / `watch.exclude` honored; `cdk.out` / `node_modules` / `.git` always excluded). A per-firing classifier picks the per-replica primitive: source-only edits on interpreted-language handlers (Node / Python / Ruby / shell) take a bind-mount **FAST PATH** (`docker cp` the new source into each replica + `docker restart`; no `docker build`, sub-second). Dockerfile / dependency manifest / compiled-language source / ambiguous edits fall through to the rebuild rolling primitive — boot a shadow under a bumped generation suffix, wait for its container port to accept a TCP connection, atomically swap Service Connect / Cloud Map registrations, then retire the old container. Either path rolls one replica at a time, so peer services see zero connection refusals across the reload even on multi-replica services. Off by default; existing replica(s) keep serving when synth fails mid-reload. (cdk-local 0.69.0 / [#214](https://github.com/go-to-k/cdk-local/issues/214) Phase 4.) Source-only TypeScript edits classify as a **rebuild** (not soft-reload) so precompiled handler setups are not left stale (cdk-local 0.77 / [cdk-local#236](https://github.com/go-to-k/cdk-local/pull/236)). | | `--image-override ` | — | Pin or locally build a replica's container image instead of using the deployed registry tag. `` is a service / container selector; `` is an image reference, a build directory, or a `Dockerfile` path (`~` is tilde-expanded). Repeatable. Compose with the per-service `--image-build-arg` / `--image-build-secret` / `--image-target` variants for local builds. On `--watch`, a covered override re-builds when its source changes. (cdk-local 0.77 / [cdk-local#241](https://github.com/go-to-k/cdk-local/pull/241), [cdk-local#244](https://github.com/go-to-k/cdk-local/pull/244).) | | `--shadow-ready-timeout ` | `60000` | Per-invocation override of the shadow-replica TCP-ready probe budget used by the rebuild rolling primitive (and the initial boot). Raise it for slow-starting containers. Also settable via `CDKD_SHADOW_READY_TIMEOUT_MS`. (cdk-local 0.77 / [cdk-local#266](https://github.com/go-to-k/cdk-local/pull/266).) | Each replica's container stdout / stderr is streamed live to the host terminal while the service runs (cdk-local 0.77 / [cdk-local#231](https://github.com/go-to-k/cdk-local/pull/231)). ### `local start-service` lifecycle `^C` (SIGINT) and SIGTERM trigger a graceful shutdown across every replica in parallel — each replica's docker containers + per-replica network + metadata sidecar are torn down via the same `cleanupEcsRun` path `run-task` uses. Double-`^C` bypasses cleanup and exits 130 immediately so users have an escape hatch when docker hangs. ### `local start-service` scope (deferred follow-ups) | Deferred | Tracked in / Why | | --- | --- | | Local load-balancer emulator (listener + round-robin + target-group health check) | Follow-up PR — needs an HTTP/TCP proxy emulator. Today's start-service does NOT register replicas to a local listener; reach a single-replica service via its published container ports, or any replica via its docker network IP / alias (multi-replica services skip the host-port publish — see the host-port note above). | | Envoy sidecar (L7 routing / retries / circuit breaking / mTLS) | Deferred follow-up — Cloud Map DNS overlay (closed via [#460](https://github.com/go-to-k/cdkd/issues/460)) covers ~80% of debugging use cases; the missing 20% requires the AWS-published Envoy image (~120MB / task). DNS-only mode is the default; an opt-in `--envoy` flag will ship with the sidecar. | | Rolling deployment strategy (`DeploymentConfiguration.MaximumPercent` etc.) | Follow-up — meaningful only with the LB emulator. | | `HealthCheckGracePeriodSeconds` runtime semantics | Field is parsed and surfaced on `ResolvedEcsService` but not yet acted on. Becomes load-bearing when the LB emulator ships (today's restart policy fires on essential-container exit code, not health-check failure). | ### `awsvpc` network mode ECS Services on Fargate require `awsvpc`. cdkd maps `awsvpc` to a per-task docker bridge network with a startup warn; security groups are NOT enforced locally and per-task ENIs are not emulated. Full rationale at [design/461-awsvpc-decision.md](design/461-awsvpc-decision.md). ## `local start-alb` (run an Application Load Balancer locally) `cdkd local start-alb ` (Issue [#86](https://github.com/go-to-k/cdkd/issues/86)) is the long-running local Application Load Balancer front-door. It names one or more `AWS::ElasticLoadBalancingV2::LoadBalancer` resources from the synthesized template, discovers the ECS / Lambda targets behind each listener's `forward` action, boots every backing ECS service via the same engine `local start-service` uses (replicas, restart-on-exit, Service Connect / Cloud Map), and stands up a per-listener local `node:http(s)` server that round-robins inbound requests across the running replicas and applies the listener rules (path / host / header / method / query-string / source-IP) against the backing targets. The symmetric counterpart of `local start-api` for ALB-fronted workloads. The command is a thin wrapper around the shared ECS service emulator engine (`runEcsServiceEmulator`, shimmed from `cdk-local/internal`); the per-listener front-door owns the routing + auth-guard logic, the underlying engine owns the container boot + Cloud Map plumbing. ### `local start-alb` target resolution - `Stack/Alb/...` (display path) or `Stack:LogicalId` (logical id). - Single-stack apps may omit the stack prefix. - The target MUST resolve to an `AWS::ElasticLoadBalancingV2::LoadBalancer`; passing a Listener or TargetGroup surfaces a clear error naming the available ALBs in the stack. - Variadic: multiple ALB targets in one invocation share a single shared docker network + a single Cloud Map registry so ECS services registered to different ALBs still discover each other. ### `local start-alb` options | Flag | Default | Behavior | | --- | --- | --- | | `--lb-port ` | host port == listener port | Remap the local front-door host port for a specific listener port. Repeatable (`--lb-port 80=8080 --lb-port 443=8443`). Use this on macOS to remap a privileged listener port (< 1024) to a non-privileged host port. | | `--tls` | off | Terminate TLS locally for cloud-HTTPS listeners. Default: a cloud-HTTPS listener is served over plain HTTP locally (`X-Forwarded-Proto: https` is preserved so the upstream app still sees the deployed listener protocol). Implied by `--tls-cert` / `--tls-key`. Use this when local-dev cookies need `Secure` / `SameSite=None`, when the upstream app inspects TLS metadata, or for mTLS / SNI testing — otherwise plain HTTP is friendlier (no self-signed cert warnings in curl / browser). | | `--tls-cert ` | — | PEM-encoded server certificate for HTTPS front-door listeners. Implies `--tls`. Must be set together with `--tls-key`. Pass `--tls` alone (without `--tls-cert` / `--tls-key`) to auto-generate a self-signed cert cached under `$XDG_CACHE_HOME/cdk-local/alb-https/`. The deployed Listener Certificates are NOT fetched (ACM private keys are not retrievable). | | `--tls-key ` | — | PEM-encoded server private key matching `--tls-cert`. Implies `--tls`. Must be set together. | | `--no-verify-auth` | off | Disable local enforcement of `authenticate-cognito` / `authenticate-oidc` actions. Every request is served as if the auth check passed. | | `--bearer-token ` | — | Default Bearer JWT injected as `Authorization: Bearer ` when the inbound request has none. Verified against the same JWKS / OIDC discovery URL the deployed ALB would (signature + iss + aud + exp). Cookie pass-through (`AWSELBAuthSessionCookie-*`) also works. | | `--cluster ` | `cdkd-local` | Cluster name surfaced to `ECS_CONTAINER_METADATA_URI_V4` and used as the docker network prefix. Same shape as `local start-service`. | | `--max-tasks ` | `3` | Per-service hard cap on local replica count regardless of template `DesiredCount`. Same shape as `local start-service`. | | `--restart-policy

` | `on-failure` | Restart-on-exit behavior for backing ECS containers. Same three-state grammar as `local start-service`. | | `--env-vars ` | — | SAM-shape JSON env-var overrides for backing containers; same format as `local run-task` / `local start-service`. | | `--container-host ` | `127.0.0.1` | Host IP to bind published container + front-door ports to. Must be a numeric IP. | | `--assume-task-role [arn]` | unset | Assume each backing service's TaskRoleArn (or the supplied ARN). Same three-form grammar as `local start-service`. | | `--ecr-role-arn ` | — | Role ARN to assume before ECR `docker pull`. Same shape as `local start-service`. | | `--platform

` | inferred | Force `--platform linux/amd64` or `linux/arm64`. | | `--no-pull` | off | Skip `docker pull` on every container image and the metadata sidecar. | | `--from-state` | off | Read cdkd's S3 state for the target stack and substitute `Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::ImportValue` / `Fn::GetStackOutput` intrinsics in the resolved backing services' container images, environment variables, secrets, role ARNs, and volumes. Mutually exclusive with `--from-cfn-stack`. Same shape as `local start-service --from-state`. | | `--state-bucket ` | auto | S3 bucket containing cdkd state. Falls back to `CDKD_STATE_BUCKET` env or `cdk.json context.cdkd.stateBucket`, then the default `cdkd-state-{accountId}`. Only used with `--from-state`. | | `--state-prefix ` | `cdkd` | S3 key prefix for state files. Only used with `--from-state`. | | `--from-cfn-stack [cfn-stack-name]` | off | Read a deployed CloudFormation stack via `DescribeStackResources` and substitute `Ref` / `Fn::ImportValue` intrinsics. For CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Mutually exclusive with `--from-state`. Same shape as `local start-service --from-cfn-stack`. | | `--stack-region ` | — | Region of the state record to read. Used with `--from-state` when the same stack name has state in multiple regions, and with `--from-cfn-stack` as the CFn client region. | | `--watch` | off | Hot reload: re-synth + per-replica reload of every ECS service behind the ALB when the CDK source changes (`cdk.json watch.include` / `watch.exclude` honored; `cdk.out` / `node_modules` / `.git` always excluded). A per-firing classifier picks the per-replica primitive: source-only edits on interpreted-language handlers (Node / Python / Ruby / shell) take a bind-mount **FAST PATH** (`docker cp` + `docker restart`; no `docker build`, sub-second; the front-door pool entry is unchanged since the IP/port are preserved). Dockerfile / dependency manifest / compiled-language source / ambiguous edits fall through to the rebuild rolling primitive — boot a shadow, wait for TCP-ready, atomically register it in the front-door pool, drop the old entry. Either path rolls one replica at a time, so a continuous external request stream against the listener port sees zero connection refusals across the reload. The host front-door (TLS, JWKS cache, Lambda-target containers, listener sockets) stays up across the reload. Lambda target groups behind the ALB are a no-op on reload (the warm RIE container keeps its boot-time image). Off by default; existing replica(s) keep serving when synth fails mid-reload. (cdk-local 0.69.0 / [#214](https://github.com/go-to-k/cdk-local/issues/214) Phase 4.) | | `--image-override ` | — | Pin or locally build a backing service's container image instead of using the deployed registry tag. Same grammar + per-service `--image-build-arg` / `--image-build-secret` / `--image-target` variants as `local start-service`. (cdk-local 0.77 / [cdk-local#241](https://github.com/go-to-k/cdk-local/pull/241), [cdk-local#244](https://github.com/go-to-k/cdk-local/pull/244).) | | `--shadow-ready-timeout ` | `60000` | Per-invocation override of the shadow-replica TCP-ready probe budget. Same shape as `local start-service`. (cdk-local 0.77 / [cdk-local#266](https://github.com/go-to-k/cdk-local/pull/266).) | When no listener rule matches an inbound request, the local front-door's 404 now explains which listener fields (path / host / header / method) were evaluated, instead of a bare 404 (cdk-local 0.77 / [cdk-local#229](https://github.com/go-to-k/cdk-local/pull/229)). ### `local start-alb` listener / action support The local front-door reads the synthesized template and emulates these listener / action shapes: - **Listener protocols:** HTTP and HTTPS. A cloud-HTTPS listener is served over plain HTTP locally by default — `X-Forwarded-Proto: https` is preserved so the upstream app still sees the deployed listener protocol. Pass `--tls` to terminate TLS locally (a self-signed cert is auto-generated and cached under `$XDG_CACHE_HOME/cdk-local/alb-https/`), or `--tls-cert` / `--tls-key` to supply your own cert (each flag implies `--tls`). Non-HTTP/HTTPS listeners (TCP / UDP / TLS / NLB) are skipped with a warn. - **Rule conditions:** all six ALB fields — `path-pattern`, `host-header`, `http-header`, `http-request-method`, `query-string`, `source-ip`. - **Default + rule actions:** `forward` (single target group, weighted forward across multiple target groups), `redirect`, `fixed-response`. `authenticate-cognito` + `authenticate-oidc` enforce a local Bearer-JWT check (or `AWSELBAuthSessionCookie` pass-through) against the same JWKS / OIDC discovery URL the deployed ALB would. - **Target groups:** ECS (`AWS::ECS::Service.LoadBalancers[]` binding the TG to the container + port) and Lambda (TG `Targets[].Id` = `{Fn::GetAtt: [, "Arn"]}`). Unsupported listener / action / target shapes are skipped with a per-line warn at boot; the front-door still serves what it can. ### `local start-alb` lifecycle `^C` (SIGINT) and SIGTERM tear down the front-door servers first, then every backing service's replicas + sidecar + shared network in parallel. Double-`^C` bypasses cleanup and exits 130 immediately so users have an escape hatch when docker hangs. The front-door servers always rebind the requested host port on restart — there is no in-process state across `^C`. ## `local start-cloudfront` (run a CloudFront distribution locally) `cdkd local start-cloudfront [target]` serves a CloudFront distribution's **viewer-request -> S3 / Lambda Function URL origin -> viewer-response** pipeline locally so a routing-function change is verifiable in seconds instead of a deploy round-trip. Models cdk-local's `cdkl start-cloudfront`, inherited into cdkd's command tree as a thin pass-through to `cdk-local`'s command factory. A CloudFront-Functions + S3-origin-only distribution is pure-local (no Docker); a distribution with a Lambda Function URL origin runs that origin's backing Lambda via the RIE container (Docker required). It binds a Function URL origin's backing Lambda + a deployed-S3 origin's bucket name to deployed state via cdkd's S3-backed `--from-state` (after a prior `cdkd deploy`) OR cdk-local's inherited `--from-cfn-stack` / `--stack-region` / `--assume-role` (CloudFormation-deployed stacks). The two state sources are mutually exclusive. As of cdk-local 0.128.0 (cdk-local#426) the start-cloudfront factory accepts the `extraStateProviders` seam, so cdkd threads its `--from-state` factory in and layers `--from-state` / `--state-bucket` / `--state-prefix` on top — the same wiring as `start-agentcore` / `start-alb` / `start-service` (issue #766; `start-cloudfront` was `--from-state`-exempt before the seam landed). ### `local start-cloudfront` target resolution Names one `AWS::CloudFront::Distribution` by its CDK display path (`MyStack/MyDistribution`). Omit the target in a TTY for an interactive picker over every distribution in the synthesized app. A single distribution is served per invocation. ### `local start-cloudfront` what runs locally - **CloudFront Functions** (`cloudfront-js-1.0` / `2.0`) — the inline rewrite JS associated as `viewer-request` / `viewer-response` runs in-process in a `node:vm` sandbox (async 2.0 handlers awaited). A viewer-request function that returns a `statusCode` short-circuits with a generated response (redirect / fixed body); otherwise the rewritten request continues to the origin, then the viewer-response function runs over the origin response. - **S3 origin content** — resolved out of the cloud assembly: the origin's bucket -> its `BucketDeployment` custom resource -> `SourceObjectKeys` -> the staged asset directory (or, under `--from-cfn-stack`, the deployed bucket served from real S3 on demand). Served with `DefaultRootObject` (root only — sub-paths are NOT auto-indexed, matching CloudFront) and `CustomErrorResponses` (the SPA fallback). - **Lambda Function URL origins** — the origin's backing Lambda is run locally via the RIE container (the same path as `local invoke`), so a distribution that fronts a Function URL is served end-to-end. - **Routing** — path patterns route across the `DefaultCacheBehavior` + ordered `CacheBehaviors[]` (CloudFront `*` / `?` glob matching). ### `local start-cloudfront` options - `--port ` — listener port (default `0` = collision-bumped). - `--host ` — bind IP (default `127.0.0.1`). - `--tls` / `--tls-cert

` / `--tls-key

` — terminate real HTTPS (user-supplied PEM pair or an auto-generated self-signed cert). - `--origin =

` — point an origin at a local directory when `BucketDeployment` resolution can't (content uploaded out of band, non-CDK bucket). Repeatable. - `--kvs-file =` — supply a CloudFront KeyValueStore's contents from a local JSON file (the AWS-free alternative to `--from-cfn-stack`, which reads the deployed store on demand). `` is a KeyValueStore handle — its `AWS::CloudFront::KeyValueStore` resource logical id, its construct path (`MyStack/RoutesKvs`), or its bare construct id (`RoutesKvs`) — so you no longer have to synth + grep for the hash-suffixed logical id. An unrecognized key (or an ambiguous bare id) fails fast with an error listing the distribution's KeyValueStore candidates. Repeatable. - `--cache-origin` — keep fetched deployed-S3 origin objects in memory (only meaningful under `--from-cfn-stack`). Setting it without `--from-cfn-stack` is a no-op and now logs a boot-time WARN saying so (a local BucketDeployment / `--origin =` origin serves from disk and is never cached). - `--no-pull` — skip `docker pull` for a Lambda Function URL origin's base image (no-op for a Function-URL-free distribution). - `--from-state` / `--state-bucket ` / `--state-prefix ` — bind a Function URL origin's backing Lambda + a deployed-S3 origin's bucket name to cdkd's S3 state (after a prior `cdkd deploy`). Mutually exclusive with `--from-cfn-stack`. `--state-prefix` defaults to `cdkd`. - `--from-cfn-stack [name]` / `--stack-region ` / `--assume-role [arn]` — the CloudFormation-deployed-stack counterpart of `--from-state` (for apps deployed via the upstream CDK CLI). - `--watch` — re-synth + atomically swap the in-memory routing model under the live socket on every CDK source edit. ### `local start-cloudfront` scope S3 origins + Lambda Function URL origins. A custom (non-S3, non-Function-URL) origin, a `LambdaFunctionAssociations` Lambda@Edge association, and the 2.0 `cf.fetch` origin API are warn-and-skip (custom / unresolved origins return 502). ## `local invoke-agentcore` (run Bedrock AgentCore Runtime locally) `cdkd local invoke-agentcore ` runs one Bedrock AgentCore Runtime container locally and invokes it once over the AgentCore protocol declared by the target. Supports the container artifact (`fromContainerAsset` / `fromEcr`) and the `CodeConfiguration` managed-runtime artifact (`fromCodeAsset`, built from source) on the HTTP, MCP, A2A, and AGUI protocols, plus a bidirectional `--ws` mode for streaming. Models cdk-local's `cdkl invoke-agentcore`, ported into cdkd's command tree as a shim over `cdk-local/internal`. > **`CodeConfiguration` builds run the bundle as-is (no dependency install).** The `fromCodeAsset` / `fromS3` source build matches the AWS managed runtime: it does **not** `pip install` (`requirements.txt` / `pyproject.toml`) or `npm install` your dependencies — the deployed runtime resolves deps vendored into the bundle at deploy time (e.g. arm64 wheels via `uv pip install --target`). So a bundle that declares a dependency manifest **without vendored deps** now fails locally with `ModuleNotFoundError` the same way it fails deployed (instead of passing locally only because of a local install), and cdkd emits a warning with the vendoring recipe. Vendor your deps into the bundle — which a successful deploy already requires. Container artifacts (`fromContainerAsset` / `fromEcr`) are unaffected. Inherited from cdk-local (follows [cdk-local#455](https://github.com/go-to-k/cdk-local/issues/455) / cdk-local#456); applies to `start-agentcore` too (same `buildAgentCoreCodeImage` build). ### Target resolution Same shape as `cdkd local invoke`: accepts a CDK display path (`MyStack/MyAgent`) or stack-qualified logical ID (`MyStack:MyAgentRuntime1234`). Single-stack apps may omit the stack prefix. When the target is omitted in an interactive terminal, an interactive picker prompts from the discovered list (no TTY -> command's required-arg error). ### Supported protocols | `Protocol` (CFn) | What runs | Container ports | cdkd dispatch | | --- | --- | --- | --- | | `HTTP` (default) | `POST /invocations` (single response or SSE stream) + `GET /ping` | 8080 | `POST /invocations` with `--event` body. SSE stream prints incrementally; non-stream prints `tail -1`. | | `MCP` | Model Context Protocol streamable HTTP | 8000 | Session handshake (`initialize` -> `notifications/initialized`) + one JSON-RPC request: defaults to `tools/list` when `--event` is omitted; otherwise `--event` is the JSON-RPC request body. | | `A2A` | Agent-to-Agent JSON-RPC at `POST /` | 9000 | Defaults to `agent/getAgentCard` when `--event` is omitted; otherwise `--event` is the JSON-RPC request body. | | `AGUI` | AGUI JSON-RPC streaming | 8800 | Streams the agent's response frames. | | `--ws` (HTTP only) | Bidirectional `/ws` WebSocket on the HTTP container | 8080 | First frame = `--event` body. In a TTY, auto-enters a REPL — each typed stdin line is a follow-up frame until Ctrl-D / close; piped / non-TTY stdin stays one-shot (only the `--event` frame is sent). | ### Inbound JWT auth (`customJwtAuthorizer`) When the runtime declares an inbound `customJwtAuthorizer`, `--jwt ` verifies the supplied Bearer JWT against the runtime's OIDC discovery URL before the container starts, then forwards it on `/invocations` as `Authorization: Bearer `. Verification covers `iss` / `aud` / `exp` / signature + allowedScopes + customClaims. Without `--jwt`, the local invoke proceeds without authorization (mirrors `cdkl`). ### State-source flags Same shape as `cdkd local invoke`. Use `--from-state` to substitute Ref / Fn::GetAtt / Fn::Sub / Fn::ImportValue in env vars from cdkd's S3 state for the target stack (after a prior `cdkd deploy`). Use `--from-cfn-stack [name]` to read a deployed CFn stack via `DescribeStackResources` (for CDK apps deployed via the upstream CDK CLI). Mutually exclusive. ### Credentials + role-assumption Same shape as `cdkd local invoke`. The container receives the developer's AWS credentials by default (so the agent's outbound AWS calls reach real AWS as the developer); `--assume-role ` (or bare `--assume-role` to auto-resolve from state) assumes the runtime's deployed `RoleArn` first so the agent runs with the narrow function role. `--ecr-role-arn ` is the cross-account ECR image-pull escape hatch. ### Options - `--event ` / `--event-stdin` — request body / stdin source. - `--env-vars ` — SAM-shape env-var overrides. - `--platform ` — defaults to `linux/arm64` (AgentCore's required arch). - `--container-host ` — host to bind the container ports to (default `127.0.0.1`). - `--session-id ` — value of the AgentCore session-id header (auto-generated when omitted). - `--jwt ` — verified + forwarded when the runtime declares `customJwtAuthorizer`. - `--timeout ` — per-request timeout (default 120000 / 120s). - `--ws` — bidirectional `/ws` WebSocket mode (HTTP protocol only). Auto-detects a TTY: an interactive terminal enters a multi-turn REPL (each stdin line is sent as a follow-up frame until Ctrl-D / agent close), while piped / redirected / CI stdin stays a wire-faithful one-shot (force one-shot in a TTY with `--ws ` — role-assumption flags. - `--no-pull` / `--no-build` — pull / build skip. ### Hot reload (`--watch`) When `--watch` is set, cdkd watches the CDK app **source tree** (the synth working directory, where `cdk.json` lives), honoring `cdk.json`'s `watch.include` / `watch.exclude` and excluding `cdk.out` / `node_modules` / `.git`. On a source edit it re-synths and reloads the agent container, mirroring `cdk watch` (follows [cdk-local#270](https://github.com/go-to-k/cdk-local/pull/270)). A per-firing classifier picks the reload primitive: - **Soft-reload FAST PATH** — an interpreted-language source edit inside a `CodeConfiguration` (`fromCodeAsset`) source tree (no Dockerfile / dependency-manifest / compiled-source change, asset hash unchanged in a way that matters) takes a `docker cp` of the freshly-synthed source into the running container's WORKDIR + `docker restart`. No `docker build`, no container swap — the container ID + host port are preserved. - **Full rebuild** — a Dockerfile / compiled-source / asset-hash-changed / ambiguous edit (or a `fromS3` / non-CDK-asset runtime, or any classifier-context failure) tears down the container (SIGTERM + `docker rm -f`), re-resolves the image (re-running the same image-build pipeline the cold boot runs), and starts a fresh container. `--watch` applies to BOTH the `--ws` session path AND the default one-shot `POST /invocations` (the reload re-runs the single shot). On `--ws`, the active socket is closed cleanly on each reload firing so the next session reconnects to the rebuilt / soft-reloaded container. For **MCP / A2A** runtimes `--watch` is a no-op WARN and the single shot proceeds (those protocols run once and exit with no reconnect surface). Reloads are chain-serialized (no two reloads run in parallel); a reload-callback failure exits the watch loop cleanly (the previous container may already be torn down, so it does not block on a stale port). `^C` (SIGINT) tears down the container and exits. ### Implementation notes cdkd consumes cdk-local's AgentCore implementation verbatim via shim files (`src/local/agentcore-*.ts`). The actual runtime resolver, code-image builder, S3 bundle downloader, protocol clients (HTTP / MCP / A2A / WebSocket), and SigV4 signer all live in cdk-local (`@cdk-local/internal`); cdkd's command file ports the CLI surface + state-source integration only, mirroring the established pattern from `cdkd local invoke` / `start-api` / `run-task` / `start-service`. ## `local start-agentcore` (serve a Bedrock AgentCore Runtime locally) `cdkd local start-agentcore [target]` is the long-running **serve** counterpart of the single-shot `local invoke-agentcore`. It boots the AgentCore Runtime container **once** (same image / env / credential resolution as `invoke-agentcore`) and keeps it **warm**, serving the runtime's native protocol contract so a client can hit it repeatedly: - **HTTP / AGUI** runtimes serve `POST /invocations` + `GET /ping` (proxied to the warm container, with the session-id / boot-resolved `Authorization` injected and the request/response — including an SSE stream — piped through) **and** the bidirectional `/ws` WebSocket endpoint behind a host bridge that injects the AgentCore session-id (and, under a `customJwtAuthorizer`, the `Authorization` header) on the container upgrade — so a **header-less client** (e.g. a browser `WebSocket`, which cannot set custom upgrade headers) can hold an interactive multi-frame session. Both share the **same host port**; the ready banner prints both an `HTTP contract served on http://...` line and the `Server listening on ws://...` line. - **MCP** runtimes serve `POST /mcp`; **A2A** runtimes serve `POST /` (these have no `/ws` bridge, and print a `Server listening on http://...` ready line plus a ` contract served on http://...` line). Runs until `^C`. Models cdk-local's `cdkl start-agentcore` ([cdk-local#420](https://github.com/go-to-k/cdk-local/pull/420); the warm HTTP serve + all four protocols + per-request inbound JWT + `--sigv4` + `--watch` follow [cdk-local#454](https://github.com/go-to-k/cdk-local/issues/454) slices 1/2/4a/4b), inherited into cdkd's command tree as a thin pass-through to cdk-local's command factory. Requires Docker. ### `local start-agentcore` target resolution Same shape as `local invoke-agentcore`: a CDK display path (`MyStack/MyAgent`) or stack-qualified logical ID. Single-stack apps may omit the stack prefix. Omit the target in a TTY for an interactive picker over the discovered AgentCore Runtimes. ### `local start-agentcore` state-source flags `start-agentcore` is one of the factory pass-throughs that bind deployed state via the `extraStateProviders` seam (issue #766): cdk-local's start-agentcore factory accepts it, so cdkd threads its S3-backed `--from-state` factory in and layers the cdkd-specific `--from-state` / `--state-bucket` / `--state-prefix` flags on top of cdk-local's inherited `--from-cfn-stack` / `--stack-region`. Use `--from-state` (after a prior `cdkd deploy`) or `--from-cfn-stack [name]` (for a stack deployed via the upstream CDK CLI) to substitute `Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::ImportValue` / `Fn::GetStackOutput` intrinsics in the runtime container image + environment variables. Mutually exclusive. (`start-alb`, `start-service`, and — as of cdk-local 0.128.0 — `start-cloudfront` thread `--from-state` the same way.) ### `local start-agentcore` options - `--port ` — serve bind port the client connects to (default `0` = OS-assigned). The HTTP contract and the `/ws` bridge share this one port; the ready banner prints the chosen `http://:` + `ws://:/ws`. - `--host ` — serve bind host (default `127.0.0.1`). - `--session-id ` — pin one AgentCore session-id for every forwarded request / `/ws` connection (default: a fresh UUID each, so each is its own session). - `--bearer-token ` — Bearer JWT presented under a `customJwtAuthorizer` (verified against the runtime's OIDC discovery URL before the container starts, then forwarded on every request and the container `/ws` upgrade). Now the **default-when-missing** fallback — the inbound JWT gate is per request, not boot-time (see below). - `--no-verify-auth` — skip the inbound JWT verification (a `--bearer-token`, if given, is still forwarded). - `--sigv4` — sign each forwarded request with AWS SigV4 (service `bedrock-agentcore`) when the runtime declares **no** `customJwtAuthorizer`, so the warm container sees the same `Authorization` / `X-Amz-*` headers the cloud receives. Mutually exclusive with `--bearer-token`; ignored (with a warning) when a `customJwtAuthorizer` is declared. - `--watch` — re-synth + reload the warm container in place on a CDK source change, keeping the host serve up (only the container rotates; a per-firing classifier picks rebuild vs soft-reload, the same machinery as `invoke-agentcore --ws --watch`). Off by default. - `--env-vars ` — SAM-shape env-var overrides. - `--platform ` — defaults to `linux/arm64` (AgentCore's required arch). - `--container-host ` — host to bind the container ports to. - `--timeout ` — per-request timeout. - `--from-state` / `--from-cfn-stack [name]` / `--state-bucket` / `--state-prefix` / `--stack-region` — state-source flags (above). - `--assume-role [arn]` / `--ecr-role-arn ` — role-assumption + cross-account ECR image-pull flags. - `--no-pull` / `--no-build` — pull / build skip. ### `local start-agentcore` inbound auth (`customJwtAuthorizer`) When the runtime declares an inbound `customJwtAuthorizer`, the warm serve verifies each contract request's `Authorization` **per request** (matching the cloud): `401` when the header is missing, `403` when the token is invalid, forwarded on a pass. `GET /ping` is unauthenticated. The container boots without requiring a token up front; `--bearer-token` is the default token used when a request arrives without its own `Authorization`. For a SigV4-protected runtime (no `customJwtAuthorizer`) use `--sigv4` instead to sign each forwarded request. ### `local start-agentcore` lifecycle The container is started once and kept warm; HTTP / AGUI contract requests are proxied to it and each `/ws` client opens its own container upgrade with the session-id (and optional `Authorization`) injected. `^C` (SIGTERM / SIGINT) tears the container down and exits — no `cdkd-local-agentcore-*` container is left behind. The studio `agentcore-ws` serve kind (cdk-local) spawns this command, but cdkd does not embed cdk-local's `studio` command, so that surface is not exposed by the cdkd CLI.