Skip to content

Serverless App Application Construct

The infrastructure.backend_app module hosts the domain construct (BackendApp) that owns every backend resource — the KMS key, DynamoDB idempotency table, SSM greeting parameter, AppConfig application/environment/ profile, the Lambda function, the API Gateway REST API, the CloudWatch log groups, the monitoring facade, and the AppInsights cleanup custom resource.

The thin BackendStack wrapper composes this construct and attaches stack-level cdk-nag suppressions; everything else lives here.

API reference

infrastructure.backend_app

BackendApp construct — the domain-level application.

Encapsulates all resources that make up the backend serverless application: KMS key, DynamoDB idempotency table, SSM greeting parameter, AppConfig feature flags, Lambda function, API Gateway, Application Insights monitoring, dashboard, Logs Insights saved queries, and per-resource cdk-nag suppressions.

Following the CDK best practice "model with constructs, deploy with stacks": the Stack only composes this construct, applies stack-wide Aspects, and wires outputs. Any deployment shape (multiple copies in one stack, multi-tenant, dev-next-to-prod) can be achieved by instantiating this construct multiple times without subclassing the Stack.

BackendApp(scope, construct_id, *, idempotency_table, is_production_env=True, appconfig_monitor=False)

Bases: Construct

Domain-level backend application.

Exposes the top-level resources as public attributes so the enclosing Stack can reference them for CfnOutputs and cross-stack wiring.

Build the application construct.

Parameters:

Name Type Description Default
scope Construct

The CDK construct scope.

required
construct_id str

The scoped construct ID.

required
idempotency_table ITableV2

The Powertools idempotency table, created in the separate :class:DataStack and passed in cross-stack. This construct wires it into the Lambda (the IDEMPOTENCY_TABLE_NAME env var and a scoped read/write grant) but does not own its lifecycle — see that stack for the stateful-resource separation rationale.

required
is_production_env bool

When True (the default, and what the default prod deployment environment passes), alarm notifications are routed to a CMK-encrypted SNS topic. Non-production environments — ephemeral per-developer/per-branch stacks in particular — still get the full dashboard and alarm set, but without the SNS topic so short-lived stacks never page anyone.

True
appconfig_monitor bool

Opt-in production switch for AppConfig feature-flag rollouts. When True, the flag deployment uses a gradual strategy (LINEAR 25%/step over 10 min, 5-min bake) and the environment carries a CloudWatch alarm monitor that auto-rolls-back a bad flag config (_attach_appconfig_rollback_monitor). Defaults to False — all-at-once, no monitor — because a monitored CFN-managed deployment cannot create a cold stack (the monitor alarm starts INSUFFICIENT_DATA, which AppConfig treats as a rollback signal). Enable it only AFTER a first all-at-once deploy has produced metric data; see that method and README "Deployment safety".

False
Source code in infrastructure/backend_app.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
def __init__(
    self,
    scope: Construct,
    construct_id: str,
    *,
    idempotency_table: dynamodb.ITableV2,
    is_production_env: bool = True,
    appconfig_monitor: bool = False,
) -> None:
    """Build the application construct.

    Args:
        scope: The CDK construct scope.
        construct_id: The scoped construct ID.
        idempotency_table: The Powertools idempotency table, created in the
            separate :class:`DataStack` and passed in cross-stack.
            This construct wires it into the Lambda (the
            ``IDEMPOTENCY_TABLE_NAME`` env var and a scoped read/write
            grant) but does not own its lifecycle — see that stack for the
            stateful-resource separation rationale.
        is_production_env: When True (the default, and what the default
            ``prod`` deployment environment passes), alarm notifications
            are routed to a CMK-encrypted SNS topic. Non-production
            environments — ephemeral per-developer/per-branch stacks in
            particular — still get the full dashboard and alarm set, but
            without the SNS topic so short-lived stacks never page anyone.
        appconfig_monitor: Opt-in production switch for AppConfig feature-flag
            rollouts. When True, the flag deployment uses a gradual strategy
            (LINEAR 25%/step over 10 min, 5-min bake) and the environment
            carries a CloudWatch alarm monitor that auto-rolls-back a bad flag
            config (``_attach_appconfig_rollback_monitor``). Defaults to False
            — all-at-once, no monitor — because a monitored CFN-managed
            deployment **cannot create a cold stack** (the monitor alarm starts
            ``INSUFFICIENT_DATA``, which AppConfig treats as a rollback signal).
            Enable it only AFTER a first all-at-once deploy has produced metric
            data; see that method and README "Deployment safety".
    """
    super().__init__(scope, construct_id)

    stack = Stack.of(self)

    # Stateful data layer lives in its own stack (DataStack); the
    # table is passed in cross-stack. This construct's own CMK below covers
    # the compute-side encryption (Lambda env vars, log groups, AppConfig,
    # SNS) — the table is encrypted by the data stack's separate key.
    self.idempotency_table = idempotency_table

    # Compute-side KMS key, shared across this stack's CloudWatch log groups,
    # Lambda env vars, AppConfig hosted configuration content, and the SNS
    # alarm topic. The DynamoDB table has its own key in the data stack
    # (see DataStack) — keys are not shared across the stack
    # boundary, so each carries a tighter, least-privilege key policy.
    # CloudWatch Logs requires the Logs service principal to be granted access
    # so it can encrypt data on behalf of the service.
    # Note: SSM StringParameter cannot use CMK — CloudFormation does not support
    # creating SecureString parameters. AppConfig support arrived later (via
    # the kms_key_identifier property on CfnConfigurationProfile), wired below.
    self.encryption_key = kms.Key(
        self,
        "EncryptionKey",
        description=f"KMS key for {stack.stack_name} log groups, Lambda env, AppConfig, and SNS",
        enable_key_rotation=True,
        # 90 days is a common compliance-aligned cadence (PCI/HIPAA forks
        # default to 90). Rotation is fully managed by AWS — key ID/ARN
        # and policies stay constant, prior versions are retained for
        # transparent decryption, no dependent redeploys required.
        rotation_period=Duration.days(90),
        removal_policy=RemovalPolicy.DESTROY,
    )
    # Confused-deputy guard: scope the Logs service principal grant to
    # log-group ARNs in this account+region. See ``grant_logs_service_to_key``
    # in ``nag_utils.py`` — three CMKs in this project share the statement.
    grant_logs_service_to_key(
        self.encryption_key,
        region=stack.region,
        account=stack.account,
        partition=stack.partition,
    )
    # Deliberately NO grant for GuardDuty (or DevOps Guru / Application
    # Insights / Resource Explorer): those services' service-linked roles
    # probe kms:Decrypt against this CMK when introspecting the Lambda's
    # CMK-encrypted env vars, and are denied — by design. A previous
    # service-principal grant (Principal: guardduty.amazonaws.com +
    # SourceAccount/SourceArn conditions) was removed after live CloudTrail
    # evidence (2026-07-02, GuardDuty enabled in-account) showed it can
    # never match: the call is made by the assumed
    # AWSServiceRoleForAmazonGuardDuty role, and a role-session principal
    # is not matched by a service-principal key statement — the grant was
    # dead policy. The SLR's AWS-managed policy carries no kms:Decrypt
    # either, and GuardDuty's documented Lambda coverage (function
    # metadata, tags, network activity) does not require reading env-var
    # plaintext, so nothing is lost. A fork that wants these services to
    # read CMK-encrypted config must add a key-policy statement naming the
    # specific service-linked role ARN — possible only in accounts where
    # that SLR already exists (KMS validates key-policy principals), which
    # is why this template doesn't ship it.

    # SSM parameter for Powertools Parameters.
    # parameter_name omitted so CDK auto-generates. Lambda reads the value
    # through the GREETING_PARAM_NAME env var, so the name doesn't need to
    # be human-memorable.
    self.greeting_param = ssm.StringParameter(
        self,
        "GreetingParameter",
        string_value="hello world",
    )

    # AppConfig for Powertools Feature Flags
    self.app_config_app = appconfig.CfnApplication(
        self,
        "FeatureFlagsApp",
        name=f"{stack.stack_name}-features",
    )

    # deletion_protection_check=BYPASS — same teardown rationale as the
    # configuration profile below: the account-level deletion-protection
    # window would otherwise fail `cdk destroy` for any environment a
    # Lambda polled recently.
    app_config_env = appconfig.CfnEnvironment(
        self,
        "FeatureFlagsEnv",
        application_id=self.app_config_app.ref,
        name=f"{stack.stack_name}-env",
        deletion_protection_check="BYPASS",
    )

    # kms_key_identifier CMK-encrypts the hosted configuration content at
    # rest in AppConfig. This compute-side CMK already covers the Lambda's
    # log groups and env vars; pinning AppConfig to the same key keeps the
    # compute-side auditable encryption surface inside one ARN. (The Lambda
    # also gets an explicit kms:Decrypt grant on this key below for the
    # GetLatestConfiguration read path — see the grant near the role policy.)
    #
    # Type is FREEFORM on purpose, not AWS.AppConfig.FeatureFlags: the
    # native flags type stores the authoring format but its data plane
    # serves the flattened {"<flag>":{"enabled":bool}} form, which
    # Powertools FeatureFlags rejects with SchemaValidationError ("feature
    # 'default' boolean key must be present"). Powertools consumes its OWN
    # schema ({"<flag>":{"default":bool,"rules":{...}}}) from a freeform
    # profile — see the Powertools feature-flags docs. Found on a live
    # deployment; the handler's fallback path masks it at synth/test time.
    # Name is "-flags" (not the application's "-features") deliberately:
    # changing a profile's Type forces CFN replacement, and replacement is
    # create-before-delete — a replacement that keeps the same pinned name
    # collides with the not-yet-deleted old profile ("Resource already
    # exists outside the stack"). AppConfig L1s require a name (no CDK
    # auto-generation), so any future property change that replaces this
    # profile must change the name in the same commit.
    #
    # deletion_protection_check=BYPASS: AppConfig deletion protection (an
    # account-level setting) refuses to delete environments and hosted
    # configurations that a client polled within the protection window.
    # The Lambda polls this profile continuously, so with the account
    # default enabled every `cdk destroy` of a recently used stack would
    # fail mid-teardown — the same class of dangling-teardown problem
    # `make destroy-clean` exists to solve. BYPASS keeps teardown
    # deterministic; production forks that want the guardrail can flip
    # these to ACCOUNT_DEFAULT and accept manual deletes on destroy.
    app_config_profile = appconfig.CfnConfigurationProfile(
        self,
        "FeatureFlagsProfile",
        application_id=self.app_config_app.ref,
        name=f"{stack.stack_name}-flags",
        location_uri="hosted",
        type="AWS.Freeform",
        kms_key_identifier=self.encryption_key.key_arn,
        deletion_protection_check="BYPASS",
    )

    # Initial feature flags configuration, in the Powertools feature-flags
    # schema. "rules" (conditional enablement, e.g. by source_ip /
    # user_agent from the evaluation context) can be authored here later.
    #
    # The flag content lives in feature_flags.json next to this module —
    # one file read by both this construct (at synth) and the unit test
    # that validates it against the Powertools feature-flags schema
    # (tests/unit/test_feature_flags_schema.py, which runs in the venv
    # where Powertools is importable). json.loads is the synth-side guard:
    # it can't check the flag schema (Powertools isn't installable next to
    # CDK — see the attrs conflict in pyproject.toml) but it does fail the
    # build on malformed JSON instead of shipping it to AppConfig.
    flags_content = (Path(__file__).parent / "feature_flags.json").read_text()
    json.loads(flags_content)
    flags_version = appconfig.CfnHostedConfigurationVersion(
        self,
        "FeatureFlagsVersion",
        application_id=self.app_config_app.ref,
        configuration_profile_id=app_config_profile.ref,
        content_type="application/json",
        content=flags_content,
    )

    # A hosted configuration version is inert until DEPLOYED: the AppConfig
    # data plane (GetLatestConfiguration, which Powertools FeatureFlags
    # calls) only serves configuration that has been deployed to the
    # environment. Without this Deployment the enhanced_greeting flag could
    # never evaluate true and every fetch would take the handler's
    # error-fallback path. CFN re-runs the deployment whenever the hosted
    # version changes (ConfigurationVersion references flags_version.ref).
    #
    # Deployment strategy: all-at-once by default; gradual when the opt-in
    # appconfig_monitor switch is set (it pairs with the environment monitor
    # wired below — the bake window is what gives the monitor time to act).
    #
    # Why all-at-once is the default: a *gradual* strategy with a CloudWatch
    # alarm **monitor** for automatic rollback is the production-grade pattern
    # for protecting ongoing flag changes — but it cannot be wired into this
    # CFN-managed deployment when it runs during *initial* stack creation.
    # AppConfig rolls back when a monitored alarm is in ALARM **or
    # INSUFFICIENT_DATA**, and on a cold stack the rollback metric
    # (FeatureFlagEvaluationFailure, emitted only by the running Lambda) has
    # no data, so the fresh alarm sits in INSUFFICIENT_DATA and AppConfig
    # aborts the initial deploy (verified on a live deploy). The metric is
    # always emitted for observability; gradual + alarm rollback is opt-in via
    # appconfig_monitor and must be turned on only AFTER a first all-at-once
    # deploy — see _attach_appconfig_rollback_monitor and README
    # "Deployment safety" / TODO "AppConfig".
    if appconfig_monitor:
        flags_deployment_strategy = appconfig.CfnDeploymentStrategy(
            self,
            "FeatureFlagsDeployStrategy",
            name=f"{stack.stack_name}-gradual",
            deployment_duration_in_minutes=10,
            growth_factor=25,
            growth_type="LINEAR",
            final_bake_time_in_minutes=5,
            replicate_to="NONE",
        )
    else:
        flags_deployment_strategy = appconfig.CfnDeploymentStrategy(
            self,
            "FeatureFlagsDeployStrategy",
            name=f"{stack.stack_name}-all-at-once",
            deployment_duration_in_minutes=0,
            growth_factor=100,
            growth_type="LINEAR",
            final_bake_time_in_minutes=0,
            replicate_to="NONE",
        )
    appconfig.CfnDeployment(
        self,
        "FeatureFlagsDeployment",
        application_id=self.app_config_app.ref,
        environment_id=app_config_env.ref,
        configuration_profile_id=app_config_profile.ref,
        configuration_version=flags_version.ref,
        deployment_strategy_id=flags_deployment_strategy.ref,
        # Same CMK as the hosted content — keeps the deployment data inside
        # the one auditable key, matching the profile's kms_key_identifier.
        kms_key_identifier=self.encryption_key.key_arn,
    )

    # Explicit Lambda log group with 1-week retention (implicit group has no retention).
    # log_group_name omitted — CDK auto-generates a unique name and wires it into the
    # Lambda function via the log_group property below.
    lambda_log_group = logs.LogGroup(
        self,
        "FunctionLogGroup",
        encryption_key=self.encryption_key,
        # 90 days for operational app logs — enough debugging history and
        # satisfies a "3 months immediately available" clause without paying
        # CloudWatch storage for a long tail. Audit-relevant logs (CloudTrail,
        # access logs) go to S3 for cheaper long-term retention instead — see
        # README "Audit stack and log retention".
        retention=logs.RetentionDays.THREE_MONTHS,
        removal_policy=RemovalPolicy.DESTROY,
    )

    # Lambda function with automatic dependency bundling.
    # environment_encryption pins the env-var encryption to our CMK so the
    # security boundary stays inside one key — without it Lambda falls back
    # to an AWS-managed key.
    self.function = PythonFunction(
        self,
        "ApiFunction",
        runtime=_lambda.Runtime.PYTHON_3_14,
        entry="lambda",
        index="app.py",
        handler="lambda_handler",
        architecture=_lambda.Architecture.ARM_64,
        memory_size=256,
        timeout=Duration.seconds(10),
        # Async-retry posture made explicit: this function is only invoked
        # synchronously (API Gateway), so Lambda's default of two automatic
        # async retries is dead config — pinning it to 0 documents that no
        # async path exists, and any future async event source must revisit
        # this together with an on_failure destination (see the LambdaDLQ
        # suppressions below).
        retry_attempts=0,
        # Reserved concurrency caps how much of the account's concurrency pool
        # this one function can consume, so a runaway loop or a traffic spike
        # on /greeting can't starve every other Lambda in the account. 100 is
        # a deliberately modest ceiling for a reference workload — size it to
        # real peak traffic in a fork (and note that a reserved value also
        # guarantees that headroom is always available to this function).
        # First-deploy precondition: Lambda rejects any reservation that
        # would leave the account's UNRESERVED pool below 100, so this line
        # needs an applied account concurrency quota of at least 200. The
        # documented default is 1000, but fresh accounts often start with a
        # much lower applied quota — check `aws lambda get-account-settings`
        # and request an increase (or lower this value) before the first
        # deploy in a new account, or the stack fails on this resource.
        # Retires the NIST.800.53.R5-LambdaConcurrency /
        # HIPAA.Security-LambdaConcurrency suppressions below.
        reserved_concurrent_executions=100,
        tracing=_lambda.Tracing.ACTIVE,
        log_group=lambda_log_group,
        logging_format=_lambda.LoggingFormat.JSON,
        # Platform/system log records (START, REPORT, platform.report, …)
        # are filtered at INFO. That is the service default today, but the
        # posture is pinned in code — same "visible in code, not implicit
        # in the runtime default" rationale as recursive_loop below. Note
        # the SlowInvocations saved query reads platform.report records,
        # so this must never be raised above INFO without updating it.
        system_log_level_v2=_lambda.SystemLogLevel.INFO,
        environment_encryption=self.encryption_key,
        environment={
            "POWERTOOLS_SERVICE_NAME": "serverless-app",
            "POWERTOOLS_METRICS_NAMESPACE": "ServerlessApp",
            "POWERTOOLS_LOG_LEVEL": "INFO",
            "IDEMPOTENCY_TABLE_NAME": self.idempotency_table.table_name,
            "GREETING_PARAM_NAME": self.greeting_param.parameter_name,
            # Sourcing AppConfig identifiers from the CFN constructs (instead
            # of re-formatting f"{stack.stack_name}-...") keeps the Lambda's
            # reads in lockstep with the IAM grant below: any future rename
            # of the AppConfig resources flows through .name automatically.
            "APPCONFIG_APP_NAME": self.app_config_app.name,
            "APPCONFIG_ENV_NAME": app_config_env.name,
            "APPCONFIG_PROFILE_NAME": app_config_profile.name,
            # In-memory TTL for fetched feature flags (seconds). The handler
            # defaults to 300 anyway; set explicitly so the caching posture
            # is visible and tunable here rather than buried in code.
            "APPCONFIG_MAX_AGE_SECONDS": "300",
        },
    )

    # Recursive-loop detection. Default is Terminate, but the L2 PythonFunction
    # construct doesn't surface this property — set it explicitly on the
    # underlying CfnFunction so the posture is visible in code rather than
    # implicit in the runtime default. A runtime isinstance check (instead of a
    # bare cast) makes a future CDK change to the L2->L1 default_child
    # relationship fail loudly at synth rather than silently dropping the
    # Terminate posture — mirroring the provider-lookup guard in the frontend stack.
    cfn_function = self.function.node.default_child
    if not isinstance(cfn_function, _lambda.CfnFunction):
        raise TypeError(f"Expected ApiFunction default_child to be CfnFunction, got {type(cfn_function)}")
    cfn_function.recursive_loop = "Terminate"

    # Lambda alias for CodeDeploy traffic-shifting deployments. The API
    # integration targets this alias rather than $LATEST, so a code change
    # rolls out through CodeDeploy (canary in prod, all-at-once in dev) with
    # automatic rollback on the alias error alarm — see
    # _attach_canary_deployment. current_version publishes a new Lambda
    # version whenever the function's code or config changes; CodeDeploy
    # shifts the alias onto it. (Reserved concurrency stays on the function;
    # it is shared across versions, so the alias needs none of its own.)
    self.alias = _lambda.Alias(
        self,
        "LiveAlias",
        alias_name="live",
        version=self.function.current_version,
    )

    # Grant permissions
    self.idempotency_table.grant_read_write_data(self.function)
    self.greeting_param.grant_read(self.function)

    # AppConfig least-privilege: both calls authorize against the
    # application/environment/configuration ARN. The session token in the
    # GetLatestConfiguration request body is opaque request data, not the
    # IAM resource — IAM still evaluates the call against this profile ARN.
    appconfig_profile_arn = (
        f"arn:{stack.partition}:appconfig:{stack.region}:{stack.account}:"
        f"application/{self.app_config_app.ref}/"
        f"environment/{app_config_env.ref}/"
        f"configuration/{app_config_profile.ref}"
    )
    self.function.add_to_role_policy(
        statement=iam.PolicyStatement(
            actions=["appconfig:StartConfigurationSession", "appconfig:GetLatestConfiguration"],
            resources=[appconfig_profile_arn],
        )
    )
    # AppConfig CMK-decrypt grant. The hosted configuration is encrypted at
    # rest with this stack's CMK (kms_key_identifier on the profile +
    # deployment above), and AppConfig evaluates kms:Decrypt against the
    # *caller's* role on GetLatestConfiguration — so the Lambda needs decrypt
    # on this key. This permission used to ride the DynamoDB grant back when
    # the table shared this CMK; now that the table has its own key in
    # DataStack, the AppConfig decrypt path needs an explicit,
    # scoped grant here (read-only path, so kms:Decrypt only).
    self.encryption_key.grant_decrypt(self.function)

    # Explicit API Gateway access log group with 1-week retention.
    # log_group_name omitted — CDK auto-generates and passes it into the
    # RestApi via LogGroupLogDestination below.
    api_log_group = logs.LogGroup(
        self,
        "ApiAccessLogs",
        encryption_key=self.encryption_key,
        # 90 days — operational retention (see FunctionLogGroup).
        retention=logs.RetentionDays.THREE_MONTHS,
        removal_policy=RemovalPolicy.DESTROY,
    )

    # API Gateway REST API
    # cloud_watch_role=True (default) creates an implicit IAM role scoped to
    # allow API Gateway to write execution logs to CloudWatch — this is a
    # region-level account setting managed by CDK automatically.
    self.api = apigw.RestApi(
        self,
        "RestApi",
        cloud_watch_role=True,
        cloud_watch_role_removal_policy=RemovalPolicy.DESTROY,
        deploy_options=apigw.StageOptions(
            stage_name="Prod",
            tracing_enabled=True,
            # Stage-level throttling caps steady-state and burst request rates
            # across the whole stage, bounding both abuse and runaway cost.
            # These are method-level defaults applied to every route; per-method
            # overrides or a usage plan can layer tighter limits later. Values
            # are deliberately modest for a reference workload — raise them in a
            # fork sized to real traffic. This retires the
            # Serverless-APIGWDefaultThrottling suppression in BackendStack.
            throttling_rate_limit=100,
            throttling_burst_limit=200,
            access_log_destination=apigw.LogGroupLogDestination(api_log_group),
            access_log_format=apigw.AccessLogFormat.custom(
                # Built from typed AccessLogField references — json_with_standard_fields
                # only supports 10 fixed fields; custom() is the CDK API for extended formats.
                #
                # errorMessage stays the quoted RAW $context.error.message on
                # purpose. $context.error.messageString looks like the
                # JSON-safe variant but is unusable here: absent access-log
                # variables render as a bare dash, so the unquoted form
                # corrupts every success line ("errorMessage":-) and the
                # quoted form double-quotes every error line. The residual
                # exposure of the raw form — a message containing a double
                # quote breaks JSON parsing for that one line — is accepted.
                "{"
                + ",".join(
                    [
                        f'"requestId":"{apigw.AccessLogField.context_request_id()}"',
                        f'"accountId":"{apigw.AccessLogField.context_owner_account_id()}"',
                        f'"apiId":"{apigw.AccessLogField.context_api_id()}"',
                        f'"stage":"{apigw.AccessLogField.context_stage()}"',
                        f'"resourcePath":"{apigw.AccessLogField.context_resource_path()}"',
                        f'"httpMethod":"{apigw.AccessLogField.context_http_method()}"',
                        f'"protocol":"{apigw.AccessLogField.context_protocol()}"',
                        f'"status":"{apigw.AccessLogField.context_status()}"',
                        f'"responseType":"{apigw.AccessLogField.context_error_response_type()}"',
                        f'"errorMessage":"{apigw.AccessLogField.context_error_message()}"',
                        f'"requestTime":"{apigw.AccessLogField.context_request_time()}"',
                        # Feeds the SlowestRequests saved query in
                        # _create_insights_queries — total request latency in ms.
                        f'"responseLatency":"{apigw.AccessLogField.context_response_latency()}"',
                        f'"ip":"{apigw.AccessLogField.context_identity_source_ip()}"',
                        f'"caller":"{apigw.AccessLogField.context_identity_caller()}"',
                        f'"user":"{apigw.AccessLogField.context_identity_user()}"',
                        f'"responseLength":"{apigw.AccessLogField.context_response_length()}"',
                        f'"xrayTraceId":"{apigw.AccessLogField.context_xray_trace_id()}"',
                    ]
                )
                + "}"
            ),
            logging_level=apigw.MethodLoggingLevel.INFO,
            data_trace_enabled=False,
        ),
    )

    greeting_resource = self.api.root.add_resource("greeting")
    # Integrate with the alias (not $LATEST) so CodeDeploy traffic shifting
    # is what actually moves production traffic onto a new version.
    greeting_resource.add_method("GET", apigw.LambdaIntegration(self.alias))
    greeting_resource.add_cors_preflight(
        allow_origins=apigw.Cors.ALL_ORIGINS,
        allow_methods=["GET", "OPTIONS"],
        # X-Amzn-Trace-Id is required for CloudWatch RUM to propagate the
        # client-side X-Ray trace header into the API Gateway → Lambda
        # segments so the browser and backend appear on the same trace.
        # Idempotency-Key must be allowed by the preflight or browsers will
        # block the actual request — the Lambda requires it (returns 400
        # without it) so the preflight has to permit it explicitly.
        allow_headers=[*apigw.Cors.DEFAULT_HEADERS, "X-Amzn-Trace-Id", "Idempotency-Key"],
    )

    # Explicit execution log group — API Gateway creates this outside CloudFormation
    # when logging_level is enabled. Pre-creating it here transfers ownership to CFN
    # so it is deleted on cdk destroy. Name format is fixed by the API Gateway service.
    execution_log_group = logs.LogGroup(
        self,
        "ApiExecutionLogs",
        log_group_name=f"API-Gateway-Execution-Logs_{self.api.rest_api_id}/Prod",
        encryption_key=self.encryption_key,
        # 90 days — operational retention (see FunctionLogGroup).
        retention=logs.RetentionDays.THREE_MONTHS,
        removal_policy=RemovalPolicy.DESTROY,
    )
    # Order the stage after the group: if the stage goes live first and a
    # request arrives mid-deploy, API Gateway auto-creates the group
    # (unencrypted, no retention) and this LogGroup CREATE then fails with
    # "already exists". No cycle: the group depends only on the RestApi
    # (via rest_api_id in its name), not on the stage.
    self.api.deployment_stage.node.add_dependency(execution_log_group)

    # Regional WAF on API Gateway — closes the CloudFront-bypass window on the
    # public execute-api URL. See _attach_regional_waf for the full rationale.
    self._attach_regional_waf()

    # CodeDeploy traffic-shifting deployment for the Lambda alias, with
    # automatic rollback if the alias error alarm fires during the shift.
    self._attach_canary_deployment(is_production_env)

    # AppConfig deployment monitor (opt-in): a CloudWatch alarm AppConfig
    # watches during a flag rollout, auto-rolling-back the config if it fires.
    # Off by default — it cannot create a cold stack (see the method docstring).
    if appconfig_monitor:
        self._attach_appconfig_rollback_monitor(app_config_env)

    self._create_insights_queries(lambda_log_group, api_log_group)

    # Application Insights
    resource_group = rg.CfnGroup(
        self,
        "ApplicationResourceGroup",
        name=f"ApplicationInsights-{stack.stack_name}",
        resource_query=rg.CfnGroup.ResourceQueryProperty(
            type="CLOUDFORMATION_STACK_1_0",
        ),
    )

    app_insights = appinsights.CfnApplication(
        self,
        "ApplicationInsightsMonitoring",
        resource_group_name=resource_group.name,
        auto_configuration_enabled=True,
    )
    app_insights.add_dependency(resource_group)

    # CMK-encrypted log group for the AwsCustomResource provider Lambda.
    # Passing log_group= here (instead of log_retention=) avoids the legacy
    # LogRetention singleton path and lets us own every log group with our
    # CMK — no dangling AWS-managed-key log group left after cdk destroy.
    custom_resource_log_group = logs.LogGroup(
        self,
        "AwsCustomResourceLogGroup",
        encryption_key=self.encryption_key,
        retention=logs.RetentionDays.ONE_WEEK,
        removal_policy=RemovalPolicy.DESTROY,
    )

    # Custom resource to delete the Application Insights auto-created CloudWatch
    # dashboard on stack destroy. Application Insights creates a dashboard named
    # "ApplicationInsights-{resource-group-name}" outside of CloudFormation, so
    # CDK cannot own it directly. Because the resource group's own name already
    # starts with "ApplicationInsights-", the real dashboard name carries the
    # DOUBLED prefix — deleting resource_group.name verbatim silently deletes
    # nothing (verified against a live teardown). This Lambda-backed custom
    # resource calls DeleteDashboards at destroy time so no dashboard is left
    # behind after cdk destroy. Policy is scoped to the exact dashboard ARN —
    # CloudWatch dashboards have a known global ARN format.
    app_insights_dashboard_name = f"ApplicationInsights-{resource_group.name}"
    app_insights_dashboard_arn = (
        f"arn:{stack.partition}:cloudwatch::{stack.account}:dashboard/{app_insights_dashboard_name}"
    )
    app_insights_dashboard_cleanup = cr.AwsCustomResource(
        self,
        "AppInsightsDashboardCleanup",
        on_delete=cr.AwsSdkCall(
            service="CloudWatch",
            action="deleteDashboards",
            parameters={"DashboardNames": [app_insights_dashboard_name]},
            physical_resource_id=cr.PhysicalResourceId.of(app_insights_dashboard_name),
        ),
        policy=cr.AwsCustomResourcePolicy.from_sdk_calls(
            resources=[app_insights_dashboard_arn],
        ),
        install_latest_aws_sdk=False,
        log_group=custom_resource_log_group,
    )
    # Must run after Application Insights has had a chance to create the dashboard
    app_insights_dashboard_cleanup.node.add_dependency(app_insights)

    # Monitoring dashboard, alarms, and (in production) SNS alarm routing.
    self.alarm_topic = self._build_monitoring(lambda_log_group, is_production_env)

    # Expose API URL for consumption by the enclosing stack and cross-stack refs
    self.api_url = self.api.url

    self._add_resource_suppressions(app_insights_dashboard_cleanup)