Securing Bitbucket-to-Jenkins Access with an Auto-Syncing AWS Prefix List

Securing Bitbucket-to-Jenkins Access with an Auto-Syncing AWS Prefix List

The flow: a push to Bitbucket fires a webhook call from Bitbucket Cloud into self-hosted infrastructure — Jenkins, in this case — to kick off a build. Jenkins isn’t exposed directly — it sits behind a load balancer, and that load balancer is what actually has to accept the inbound call from Bitbucket’s servers on port 443. The fastest way to unblock that is opening port 443 on its security group to 0.0.0.0/0, and it’s an easy shortcut to leave in place indefinitely — it works, so nothing forces anyone to revisit it.

That’s not a security posture — it’s a disaster waiting to happen. The fix isn’t a hardcoded list of Atlassian’s IP ranges either. Those ranges rotate on no fixed schedule, and a hardcoded list just trades an open security group for a maintenance job nobody remembers — until a build starts failing and the hunt for why begins. The better option is a security group rule that points at a single AWS managed prefix list, kept in sync automatically.

The Managed Prefix List

An AWS managed prefix list is a named, reusable set of CIDR blocks that a security group rule can reference directly. Once the rule points at the list, the list’s contents can change without ever touching the security group again:

resource "aws_ec2_managed_prefix_list" "bitbucket" {
  name           = "<prefix_list_name>"
  address_family = "IPv4"
  max_entries    = <max_entries> # 55 is a reasonable starting point — see "Sizing the Prefix List" below

  lifecycle {
    ignore_changes = [entry]
  }
}

⚠️ The ignore_changes = [entry] block is required, not optional, if a Lambda or other process manages entries outside of Terraform. Without it, the entries populated by the sync process look like drift on the next terraform apply, and Terraform reverts them to whatever’s in the .tf file — which is nothing. That silently empties the list and breaks every subsequent build until someone notices. Terraform should own the list’s shell (name, size); the entries are runtime state owned by whatever keeps them updated.

Sizing the Prefix List

max_entries isn’t just a cap on the list — it’s what counts against AWS’s default quota of 60 rules per security group (L-0EA8095F) once the list is referenced by a security group rule. That quota is consumed by max_entries itself, not by how many entries are actually populated — a list configured with max_entries = 55 reserves 55 rule slots even if only 30 entries are currently in it.

For that reason, give the prefix list its own dedicated security group rather than folding its rule into one that also holds unrelated rules, like admin access. That way max_entries is the only thing competing for that group’s 60-rule ceiling, instead of splitting that budget with unrelated rules that eat into the headroom the list needs to grow.

Setting max_entries to Atlassian’s current CIDR count plus a small buffer — enough headroom for the list to grow without immediately needing a quota increase, but not so much that it wastes the security group’s rule budget — is the right balance. As of writing, Atlassian publishes 49 CIDR ranges, so a max_entries of 55 leaves a modest buffer without reserving rule slots the list doesn’t need. Sizing it too tight means a future expansion of Atlassian’s IP ranges silently fails to sync when the Lambda runs; sizing it too generously means requesting a quota increase for no real reason.

Creating the Sync Lambda

The sync Lambda’s job is simple: read the prefix list’s current entries, compare them to what Atlassian now publishes, and call ec2:ModifyManagedPrefixList with the difference. The function itself and its execution role are ordinary boilerplate — the role’s trust policy is the standard Lambda assume-role document, represented here as a placeholder rather than spelled out in full:

resource "aws_iam_role" "bitbucket_ip_sync" {
  assume_role_policy = "<lambda_trust_policy_json>"
}

resource "aws_lambda_function" "bitbucket_ip_sync" {
  function_name = "<function_name>"
  role          = aws_iam_role.bitbucket_ip_sync.arn
  handler       = "<handler>"
  runtime       = "<runtime>"
  filename      = "<deployment_package.zip>"
}

What isn’t boilerplate is scoping the role down to least privilege:

data "aws_iam_policy_document" "bitbucket_ip_sync_lambda_policy" {
  statement {
    sid    = "PrefixListReadWrite"
    effect = "Allow"
    actions = [
      "ec2:GetManagedPrefixListEntries",
      "ec2:ModifyManagedPrefixList",
    ]
    resources = [aws_ec2_managed_prefix_list.bitbucket.arn]
  }

  # DescribeManagedPrefixLists does not support resource-level scoping.
  statement {
    sid       = "PrefixListDescribe"
    effect    = "Allow"
    actions   = ["ec2:DescribeManagedPrefixLists"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "bitbucket_ip_sync" {
  role   = aws_iam_role.bitbucket_ip_sync.id
  policy = data.aws_iam_policy_document.bitbucket_ip_sync_lambda_policy.json
}

Syncing via SNS Instead of Polling

The straightforward way to keep the list current is a scheduled job that polls ip-ranges.atlassian.com on an interval and diffs it against the prefix list. That works, but it’s wasteful — most polling cycles find nothing has changed, since Atlassian’s ranges shift infrequently, so the job burns compute, racks up API calls, and adds cost for no real gain. There is also a window of time — up to the length of the polling interval — during which the list can become stale and lead to a blocked webhook.

Atlassian publishes a public SNS topic that fires the moment their IP ranges change, documented here:

arn:aws:sns:us-east-1:745490931007:atlassian-public-ip-changes

Subscribing the sync Lambda created above directly to that topic makes the sync event-driven instead of scheduled — the update happens within seconds of Atlassian actually changing something, not when polling takes place:

resource "aws_sns_topic_subscription" "bitbucket_ip_sync" {
  topic_arn = "arn:aws:sns:us-east-1:745490931007:atlassian-public-ip-changes"
  protocol  = "lambda"
  endpoint  = aws_lambda_function.bitbucket_ip_sync.arn

  depends_on = [aws_lambda_permission.bitbucket_ip_sync_sns] # grants SNS permission to invoke the function
}

The SNS topic only fires on future changes — it doesn’t replay history, so a freshly created prefix list starts out empty and stays that way until Atlassian’s next change event. Seed it once at deploy time by invoking the Lambda manually right after the initial terraform apply, or wiring a one-time invocation into the deployment pipeline, so the list reflects Atlassian’s current ranges immediately instead of waiting on the first notification.

Alerting on Sync Failures

SNS invokes the Lambda asynchronously, and a failed invocation that exhausts Lambda’s built-in retries doesn’t surface anywhere by default — the event is simply dropped. Pointing the function’s dead-letter config at an SQS queue catches that failed event instead of letting it disappear, giving something concrete to alert on.

That alert is worth having because this Lambda has no legitimate reason to fail in normal operation — it’s a small, narrowly scoped function doing the same read-compare-write on every invocation. So the moment a message lands in that queue, it means the sync is broken and the prefix list may already be going stale, which is exactly the kind of failure that otherwise stays invisible until a build mysteriously starts failing. A CloudWatch alarm on the queue’s message count, wired to notify immediately rather than waiting for a batch of failures to build up, turns that silent failure mode into an active page.

When that alarm fires, the DLQ message itself is the fastest way in — Lambda automatically attaches the request ID, error code, and error message as SQS message attributes on the failed event, so the reason for the failure is often visible without digging any further. If that’s not enough, the Lambda’s CloudWatch Logs for that request ID carry the full stack trace. In this specific setup, a few causes are worth checking first: max_entries has been exhausted by Atlassian publishing more ranges than the list can hold, the Lambda’s IAM permissions have drifted out from under it, or a concurrent edit to the prefix list — a manual console change, say — caused a version conflict on the next ec2:ModifyManagedPrefixList call.

Closing Thoughts

The end state is a security group rule that never changes and a prefix list that keeps itself current. Atlassian can rotate their IP ranges as often as they want without anyone noticing, remembering, or intervening — the Lambda picks the change up from the SNS topic and updates the list within seconds. Terraform stays in control of the list’s shape; the contents are properly maintained by the automation that owns them.

This same pattern extends cleanly to any other external service that both publishes its IP ranges and offers a change-notification mechanism — the prefix list, Lambda, and IAM scoping shown here don’t need to change, just the source of the IP data and the topic being subscribed to.

← All Posts

Related Posts

Importing Existing Infrastructure into Terraform Most infrastructure doesn't start life in Terraform — it starts as a console click, a legacy script, or a manual fix under pressure. Here's how to bring it under management without recreating it. AWS Account – First Steps A practical checklist for getting a new AWS account secured and ready for real workloads. AWS Tips for Cost, Security, and Efficiency Practical strategies to reduce AWS spending, strengthen security, and streamline operations.

Contact

Tell me what you're building and what you need help with — ping me anytime!