export

How a one-line CDK Aspect took down every Lambda in our dev environment

2026-10-05 | 10 min read
Armand Rego
At Weird Sheep Labs, we lean heavily on containerised Lambda functions. On a recent client project, a seemingly harmless CDK Aspect (all it did was turn on image scanning) took down every Lambda in the backend dev environment overnight. The culprit turned out to be a CloudFormation behaviour that anyone running Docker-image Lambdas on CDK can trip over, and one that neither deploy output nor drift detection will warn you about. Here's what happened and how to avoid it.

The setup

The backend follows an event-driven, service-based architecture, with each service containing one or more Lambda functions. Each of those functions pulls its container image from an ECR repository at cold start. The stacks are synthesised with CDK's AppStagingSynthesizer, which creates its own staging stack to hold ECR repositories and other deployment assets.

We wanted scan-on-push enabled on every ECR repository, including the ones the synthesiser creates. The synthesiser doesn't expose a scanning option, and because it owns those repository definitions we can't set the property on them directly. So we wrote a CDK Aspect that operates on the L1 CfnRepository resource; that way the setting lands in CloudFormation regardless of how the repository was constructed.

1
@jsii.implements(IAspect)
2
class EcrScanOnPushAspect:
3
  def visit(self, node: constructs.IConstruct) -> None:
4
    if not isinstance(node, ecr.CfnRepository):
5
      return
6

7
    node.image_scanning_configuration = (
8
      ecr.CfnRepository.ImageScanningConfigurationProperty(
9
        scan_on_push=True,
10
      )
11
    )

Add the Aspect at the app level and every repository gets ScanOnPush: true:

1
Aspects.of(app).add(EcrScanOnPushAspect())

About as innocuous as infrastructure changes get. One property on a resource with no other pending changes. It deployed without error, and with no evident regressions. Then, several hours later, CloudWatch alarms started hammering the Slack channel.

Two services controlling one property

When you create a Docker-image Lambda function, Lambda sets a resource-based policy on the backing ECR repository called LambdaECRImageRetrievalPolicy. It does this by calling ecr:SetRepositoryPolicy directly, outside CloudFormation. It's documented behaviour, but not something you might think to dig into under normal circumstances.

1
{
2
  "Sid": "LambdaECRImageRetrievalPolicy",
3
  "Effect": "Allow",
4
  "Principal": {"Service": "lambda.amazonaws.com"},
5
  "Action": ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer"],
6
  "Condition": {
7
    "StringLike": {
8
      "aws:sourceArn": "arn:aws:lambda:<region>:<account-id>:function:*"
9
    }
10
  }
11
}

This same policy can be set via the declarative CloudFormation property RepositoryPolicyText. If your template updates an AWS::ECR::Repository and doesn't declare a policy, CloudFormation takes that to mean the repository should have no policy. It deletes whatever is there, including the one set directly by the Lambda service's ecr:SetRepositoryPolicy API call.

The end result of all this is that any CloudFormation update to an ECR repository backing a Dockerised Lambda will delete the policy if the template doesn't declare it. The trigger is simply "a CloudFormation update to the repo."

In our case the Aspect was that trigger. It added ImageScanningConfiguration to repositories that had never been updated since creation, and that first update made CloudFormation reconcile the whole resource, dropping the undeclared RepositoryPolicyText on all of them at once. CloudTrail shows exactly what happened:

1
{
2
  "eventTime": "2026-09-20T20:35:44Z",
3
  "eventSource": "ecr.amazonaws.com",
4
  "eventName": "DeleteRepositoryPolicy",
5
  "sourceIPAddress": "cloudformation.amazonaws.com",
6
  "userAgent": "cloudformation.amazonaws.com",
7
  "requestParameters": {
8
    "repositoryName": "c3podev/apiapifunction"
9
  },
10
  "responseElements": {
11
    "policyText": {
12
      "Statement": [{
13
        "Sid": "LambdaECRImageRetrievalPolicy",
14
        "Effect": "Allow",
15
        "Principal": {"Service": "lambda.amazonaws.com"},
16
        "Action": [
17
          "ecr:BatchGetImage",
18
          "ecr:GetDownloadUrlForLayer",
19
          "ecr:SetRepositoryPolicy",
20
          "ecr:DeleteRepositoryPolicy",
21
          "ecr:GetRepositoryPolicy"
22
        ]
23
      }]
24
    }
25
  }
26
}

Every repository in the staging stack got the same DeleteRepositoryPolicy call at the same timestamp, all invoked by cloudformation.amazonaws.com.

Without the requisite permissions, our Lambda functions were no longer able to pull their images from ECR. The delay between deploy and the alarms going off was simply due to the fact that there were enough warm execution environments able to handle the incoming requests. It was only once these warm environments were eventually killed, and Lambda tried to spin up new ones, that the ImageAccessDenied errors started flowing in.

Getting back up

We were able to recover quickly, in a two-step manner.

First, we restored the policy on each affected repository with manual ecr:SetRepositoryPolicy API calls, allowing the Lambda functions to pull the Docker images once again.

But restoring the policy alone wasn't enough. Lambda caches the ImageAccessDenied result; a function that's already failed won't retry the ECR pull just because the policy is back. You have to force each function to re-pull. Any configuration change does this: an UpdateFunctionConfiguration call makes Lambda fetch the image again. We pushed a trivial environment-variable change across all functions, Lambda re-pulled from the now-readable repositories, and the functions came back to `Active`.

The fix

After bringing the system back up, we deployed the actual fix to prevent the issue reoccurring i.e stopping CloudFormation from treating the absence of a declared policy as an instruction to delete one, by explicitly defining the policy on the Aspect itself:

1
@jsii.implements(IAspect)
2
class EcrScanOnPushAspect:
3
  def visit(self, node: constructs.IConstruct) -> None:
4
    if not isinstance(node, ecr.CfnRepository):
5
      return
6

7
    node.image_scanning_configuration = (
8
      ecr.CfnRepository.ImageScanningConfigurationProperty(
9
        scan_on_push=True,
10
      )
11
    )
12

13
    stack = Stack.of(node)
14
    node.add_property_override(
15
      "RepositoryPolicyText",
16
      {
17
        "Version": "2012-10-17",
18
        "Statement": [
19
          {
20
            "Sid": "LambdaECRImageRetrievalPolicy",
21
            "Effect": "Allow",
22
            "Principal": {"Service": "lambda.amazonaws.com"},
23
            "Action": [
24
              "ecr:BatchGetImage",
25
              "ecr:GetDownloadUrlForLayer",
26
              "ecr:SetRepositoryPolicy",
27
              "ecr:DeleteRepositoryPolicy",
28
              "ecr:GetRepositoryPolicy",
29
            ],
30
            "Condition": {
31
              "StringLike": {
32
                "aws:sourceArn": f"arn:aws:lambda:{stack.region}:{stack.account}:function:*"
33
              }
34
            },
35
          }
36
        ],
37
      },
38
    )

The Aspect now owns both properties: the scan-on-push setting we wanted and the policy Lambda would otherwise set directly. There's no undeclared property left for CloudFormation to delete.

We used an Aspect because the AppStagingSynthesizer owns our repository definitions. If you declare your repositories directly, just add the policy where you define them. During our investigation we found that CDK team learned this with the bootstrap stack years ago and fixed it by putting the policy in the template. AppStagingSynthesizer, still alpha, never got the same treatment:

  • cloudformation-coverage-roadmap#2493 - the underlying bug: CloudFormation deletes undeclared RepositoryPolicyText on update, and drift detection ignores it. Open since April 2026.
  • aws-cdk#18473 - the same deletion hit CDK's bootstrap stack. Fixed in bootstrap v11 (March 2022) by declaring the policy in the template.
  • aws-cdk#27007 - AppStagingSynthesizer creates ECR repositories without that declared policy. Open since September 2023.

Takeaways

CloudFormation will delete a property it doesn't manage and never tell you. Lambda's ECR policy is set via direct service API call; it never appears in a template, isn't tracked in stack state, and isn't caught by drift detection. Any update to the repository can silently remove it.

Anything that touches CfnRepository: an Aspect, a direct property change etc. needs to account for LambdaECRImageRetrievalPolicy. If you're using AppStagingSynthesizer with Dockerised Lambdas, you're exposed until the CDK team fixes #27007.

© Weird Sheep Labs Ltd 2026
Weird Sheep Labs Ltd is a company registered in England & Wales (Company No. 15160367)
167-169 Great Portland Street, 5th Floor, London, England, W1W 5PF