Skip to content
Expert Cloud & AI
Menu

A configuration-driven AWS WAF platform across account boundaries

How a small YAML file drives shared private ALBs, CloudFront and cross-account Route 53 through Azure DevOps, under a central WAF policy.

Chris Baran

· 10 min read

On this page
  1. Separate the traffic path from the control plane
  2. Make YAML the engineer-facing interface
  3. Share the ALB and give each application a route
  4. Size the shared pools around traffic and standing cost
  5. Distinguish request quotas from ALB capacity units
  6. Keep DNS ownership in its account
  7. Turn the config into an Azure DevOps deployment
  8. Apply the security baseline from the central account
  9. Automate provisioning and keep migration cutover explicit
  10. Assess the workload before adopting the pattern

Publishing an application often involves separate tasks for DNS, certificates, load-balancer routing and security policy. Each task has a different owner, and the application cannot go live until the parts agree.

For an oil and gas company replacing Imperva, we connected those tasks through infrastructure as code and Azure DevOps. Engineers describe a site in a small YAML configuration. The pipeline generates its infrastructure parameters and deploys the application entry path, while dedicated accounts retain ownership of DNS and central security policy.

The core pattern is per-application CloudFront distributions, shared private Application Load Balancers, cross-account Route 53 access and centrally managed AWS WAF rules. The shared load balancer is where application routing and WAF inspection meet.

The snippets below use fictional domains and documentation-only addresses. They are shortened teaching examples of the implementation patterns; complete stacks also need networking, IAM, logging and custom-resource lifecycle handling.

Separate the traffic path from the control plane

A viewer connects to CloudFront. A VPC origin carries the request to an internal ALB in the relevant DMZ account. AWS WAF inspects requests at that ALB, and the listener’s host rule selects an application target group. The target group forwards to backend destinations over the private network.

DNS and policy have different paths. Route 53 in the DNS account points the application hostname at its CloudFront distribution. AWS Firewall Manager in the security account applies the WAF baseline to the in-scope DMZ load balancers.

100%
Separate DNS and security accounts manage Route 53 records and Firewall Manager policy. In each DMZ environment, CloudFront uses a VPC origin to reach a shared private ALB with an attached WAF, which routes by hostname to application backends. Dashed lines indicate control operations and the blue line indicates requests.

Account ownership and the request path. Repeat the DMZ pattern for the required environments and regions.

Account ownership and the request path. Repeat the DMZ pattern for the required environments and regions.
Account responsibilityResources and authority
Production or nonproduction DMZCloudFront distributions, private ALBs, certificates and application routes
DNSHosted zone and the role used for delegated certificate validation and record changes
Central securityFirewall Manager policy, shared WAF rule groups and policy deployment

The accounts coordinate through delegated permissions and service policy. Application onboarding does not require moving the hosted zone into the DMZ or giving every application its own security-policy implementation.

CloudFront VPC origins support private-subnet ALBs. In this pattern, the ALB has no public listener address; its security groups and backend network paths still need to permit only the intended connections.

Make YAML the engineer-facing interface

The site declaration expresses the few choices an application engineer needs to make:

waf-sites.yaml — illustrative site entry
Sites:
  - FrontendDomain: portal.example.com
    BackendIPs:
      - "192.0.2.10:443"
      - "192.0.2.11:443"
    HealthCheckPath: /health
    BackendHostname: origin.example.com
    HealthCheckMatcherCodes: "200"

The addresses above are placeholders. A deployment uses backend destinations reachable through its approved private network paths.

FrontendDomain supplies the public hostname for the certificate, CloudFront alias and ALB host rule. BackendIPs supplies the target registrations and ports. HealthCheckPath tells the load balancer what to test. The optional BackendHostname supports applications that expect a different Host header behind the public hostname.

The parameter generator expands that entry into three connected outputs:

  1. Domain lists for regional ALB certificates and the CloudFront viewer certificate.
  2. Target-group configuration for the regional shared ALB.
  3. A parameter file for each domain’s CloudFront distribution.

That is the interface’s main advantage. Engineers work with application intent instead of hand-editing several infrastructure templates and remembering how their parameters depend on one another.

The generator also splits the target-group JSON across several CloudFormation parameters to stay within parameter-size limits. That implementation detail stays behind the small YAML interface.

Share the ALB and give each application a route

The regional foundation creates an internal ALB in private subnets. Its HTTPS listener presents the regional certificate and returns a fixed 404 when no application rule matches.

CloudFormation — the private shared entry point
ApplicationLoadBalancer:
  Type: AWS::ElasticLoadBalancingV2::LoadBalancer
  Properties:
    Type: application
    Scheme: internal
    Subnets:
      - !Ref PrivateSubnetA
      - !Ref PrivateSubnetB
    SecurityGroups:
      - !Ref AlbSecurityGroup
 
HttpsListener:
  Type: AWS::ElasticLoadBalancingV2::Listener
  Properties:
    LoadBalancerArn: !Ref ApplicationLoadBalancer
    Port: 443
    Protocol: HTTPS
    Certificates:
      - CertificateArn: !Ref RegionalCertificateArn
    DefaultActions:
      - Type: fixed-response
        FixedResponseConfig:
          StatusCode: "404"
          ContentType: text/plain
          MessageBody: Not Found

The fixed response keeps an unknown hostname from falling through to a default application. Each declared site receives a target group and a listener rule matching its public hostname.

A Lambda-backed CloudFormation custom resource reconciles the variable-length site list. On updates it manages target groups, registered addresses and listener rules, including optional host-header rewrites and removal of sites. The reusable template therefore supports a changing application inventory without adding another hand-authored resource block for every site.

The forwarding rule itself has a small, recognisable shape:

The host-routing operation — illustrative SDK fragment
elbv2.create_rule(
    ListenerArn=listener_arn,
    Priority=priority,
    Conditions=[{
        "Field": "host-header",
        "HostHeaderConfig": {"Values": [site["FrontendDomain"]]},
    }],
    Actions=[{
        "Type": "forward",
        "TargetGroupArn": target_group_arn,
    }],
)

The surrounding reconciler supplies the target group, chooses priorities and handles existing resources. Custom resources need dependable Create, Update and Delete behaviour, successful responses to CloudFormation, and careful handling of retries and partial failures.

Sharing the ALB also creates a shared change boundary. Capacity, rule changes and custom-resource updates can affect more than one application. Account and environment separation, scoped change review and application testing remain important.

Size the shared pools around traffic and standing cost

Many origins do not necessarily mean a high request rate. Across May–August 2026, the production CloudFront distributions received approximately 115 million viewer requests, averaging 10.8 requests per second combined. Fourteen of the sixteen production distributions averaged less than one request per second over the window.

The distribution traffic grouped by its regional origin pool was:

Origin poolAverage requests/secondBusiest hourly average requests/second
Production · Sydney3.78390.58
Production · Oregon7.04314.56
Nonproduction · Sydney0.1515.16
Nonproduction · Oregon0.073.66

These are CloudFront viewer-request counts, including automated traffic, divided by the duration of the measurement window. Each pool’s busiest hour can occur at a different time; the production-wide busiest hourly average was 390.69 requests/second. Hourly averages smooth short bursts and are not instantaneous peak measurements.

At the ALB layer, the two production load balancers averaged 6.09 and 12.09 target-selected requests/second, with busiest hourly averages of 209.60 and 345.16 respectively. ALB RequestCount measures requests for which a target was selected; it excludes requests rejected before target selection. It is a different measurement from CloudFront viewer requests, so use each for the layer being sized.

An ALB’s standing charge applies regardless of how quiet its applications are. As of October 2026, the platform’s 21 application distributions shared four ALBs: one per production/nonproduction and Sydney/Oregon combination. Keeping the same account and region boundaries, the comparison with a dedicated ALB per application is:

Application poolApplicationsDedicated ALBs · monthly baseShared ALBs · monthly base
Production · 10 Sydney, 6 Oregon1616 ALBs · US$282.512 ALBs · US$34.82
Nonproduction · 4 Sydney, 1 Oregon55 ALBs · US$90.012 ALBs · US$34.82
Total2121 ALBs · US$372.524 ALBs · US$69.64

The model uses 730 hours/month and on-demand ALB rates of US$0.0252/hour in Sydney and US$0.0225/hour in Oregon, checked on 8 October 2026 in AWS’s pricing catalogue. It covers the ALB hourly charge only, excluding LCUs, WAF, CloudFront, data transfer, logging and tax. The difference is about US$303/month, before changes in usage charges, illustrating how shared origins help control the platform’s ongoing AWS cost.

100%
The same 21 application distributions use either 21 dedicated ALBs at US$372.52 per month or four shared ALBs at US$69.64 per month in hourly charges. Sharing lowers this fixed component by 81%, before LCUs and other service costs.

The hourly-charge floor grows with load-balancer count, even when most application origins are quiet. Prices and application inventory: October 2026.

The hourly-charge floor grows with load-balancer count, even when most application origins are quiet. Prices and application inventory: October 2026.

Distinguish request quotas from ALB capacity units

An ALB does not have a universal requests-per-second limit represented by one LCU. AWS charges for the highest of four usage dimensions: new connections, active connections, bytes processed and listener-rule evaluations. For conventional TLS certificates and IP targets, one LCU represents 25 new connections/second, 3,000 active connections/minute, 1 GB/hour, or 1,000 rule evaluations/second. Those are billing dimensions; the load balancer scales beyond one LCU. AWS’s ALB pricing explains the certificate and target-type variations.

Request count alone therefore cannot predict the LCU bill. Large downloads can make bytes dominant, persistent sessions change connection demand, and a longer rule list can increase evaluation cost. The first ten rule evaluations per request are free. For example, at 100 requests/second with twenty rules evaluated per request, the ten chargeable evaluations produce 1,000 evaluations/second—one LCU in that dimension. It is not a 100-request/second throughput ceiling.

There are also service and configuration quotas to plan around:

BoundaryPublished defaultWhat it means for sharing
Regional AWS WAF web ACL100,000 requests/second per web ACLSize the aggregate traffic inspected by that ACL.
CloudFront pay-as-you-go distribution250,000 requests/second per distributionAn edge quota for each distribution, not a guaranteed ALB or backend rate.
ALB routing inventory100 non-default rules and 100 target groups per ALBEach application route consumes configuration capacity even when idle.
CloudFront VPC origins25 per account; up to 50 distributions associated with one VPC originCheck origin inventory as the per-domain deployment grows.

These figures come from the AWS WAF quotas, CloudFront quotas and ALB quotas. Check the applied account quotas before expanding a pool. Quotas are planning boundaries, not an end-to-end performance guarantee.

The observed load makes sharing a sensible starting point for these applications. Validate short bursts with finer-grained metrics and load tests, monitor latency, rejected connections, errors and consumed LCUs, and split out workloads when their traffic or operating requirements call for an independent pool. Host routing separates destinations; it does not give each application an independent load balancer or failure boundary.

Keep DNS ownership in its account

The DMZ stacks need two DNS operations: ACM validation records and the application record pointing at CloudFront. The hosted zone remains in the DNS account.

A role in that account grants record-management access to the nominated zone. Its trust policy limits eligible callers to the approved DMZ accounts; caller-side identity policies must also allow assumption of that role.

DNS role — account-bounded trust
AssumeRolePolicyDocument:
  Version: "2012-10-17"
  Statement:
    - Effect: Allow
      Principal:
        AWS: "*"
      Action: sts:AssumeRole
      Condition:
        StringEquals:
          aws:PrincipalAccount: !Ref ApprovedDmzAccountId

The wildcard principal is bounded by the account condition. This expresses account-level delegation; limiting trust to individual runtime-role ARNs is a further refinement when the operating model requires it.

This shortened permissions statement shows the destination boundary:

DNS role — zone-scoped record-management permissions
Statement:
  - Effect: Allow
    Action:
      - route53:ChangeResourceRecordSets
      - route53:ListResourceRecordSets
    Resource: !Sub "arn:${AWS::Partition}:route53:::hostedzone/${HostedZoneId}"
  - Effect: Allow
    Action:
      - route53:GetChange
    Resource: !Sub "arn:${AWS::Partition}:route53:::change/*"

The custom-resource function runs under a DMZ runtime role that can assume the nominated DNS role. It exchanges that authority for temporary credentials, then calls Route 53 in the DNS account.

Assume the DNS role before managing a record
session = boto3.client("sts").assume_role(
    RoleArn=dns_role_arn,
    RoleSessionName="ApplicationDnsDeployment",
)
credentials = session["Credentials"]
route53 = boto3.client(
    "route53",
    aws_access_key_id=credentials["AccessKeyId"],
    aws_secret_access_key=credentials["SecretAccessKey"],
    aws_session_token=credentials["SessionToken"],
)

That connects automation to delegated authority without putting permanent DNS-account credentials in the site file.

The permission boundary shown here is the hosted zone. If several teams share a zone, narrower role trust and record-name or record-type conditions can reduce the authority further. Route 53’s IAM conditions support those more specific controls.

Turn the config into an Azure DevOps deployment

The Azure DevOps pipeline ties together the dependencies that make the site declaration useful. Deployment stages run only from main after successful validation.

Within the configured production and nonproduction accounts, the pipeline discovers region configuration files and generates parameter artifacts. Regional jobs deploy the ALB certificate and private ALB configuration. A separate job deploys the CloudFront viewer certificate in us-east-1, as ACM certificates used by CloudFront require.

The CloudFront stage waits for both. It reads the ALB ARN and DNS name from the regional stack outputs and the viewer-certificate ARN from the certificate stack, fills those values into each site’s generated parameters, and deploys a distribution per domain.

100%
An Azure DevOps validation stage leads to DNS-role deployment and generated parameter artifacts. Regional certificate and private ALB deployment runs alongside the CloudFront viewer-certificate deployment. Both feed per-domain CloudFront deployments and cleanup. The central WAF policy is deployed through a separate security pipeline.

The pipeline makes the infrastructure dependencies executable. Central WAF policy has a separate security deployment path.

The pipeline makes the infrastructure dependencies executable. Central WAF policy has a separate security deployment path.

Reusable stage templates express this ordering. The following excerpt shows the dependency handoff; the referenced templates contain the deployment jobs and their account-specific service connections.

Azure DevOps — reusable stages and explicit dependencies
stages:
  - stage: Validate
    jobs:
      - job: Templates
        steps:
          - script: |
              python -m pip install cfn-lint
              cfn-lint WAF/templates/*.yaml
 
  - stage: GenerateParameters
    dependsOn: Validate
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: Parameters
        steps:
          - script: |
              python -m pip install pyyaml
              for config in WAF/configs/*/*/waf-sites.yaml; do
                python WAF/scripts/generate-params.py "$config"
              done
 
  - template: pipeline-templates/deploy-regional-infra-stage.yaml
    parameters:
      stageSuffix: Prod
      dependsOn: [DeployCrossAccountRole, GenerateParameters]
      # Account, environment and other template inputs omitted here.
 
  - template: pipeline-templates/deploy-cloudfront-cert-stage.yaml
    parameters:
      stageSuffix: Prod
      dependsOn: [DeployCrossAccountRole, GenerateParameters]
 
  - template: pipeline-templates/deploy-cloudfront-stage.yaml
    parameters:
      stageSuffix: Prod
      dependsOn: [DeployRegionalInfra_Prod, DeployCloudfrontCert_Prod]

The full pipeline includes the DNS-role stage, publishes generated parameters as artifacts, and supplies the remaining template inputs. Per-domain matrix jobs avoid maintaining a handwritten deployment job for every site. Production and nonproduction use the same stage templates with their own account configuration.

After distribution deployment, the pipeline performs invalidation and certificate cleanup. A further cleanup path removes orphaned site stacks when applications leave the configuration.

For Azure Repos Git, connect PR validation through the repository’s build-validation branch policy. Microsoft’s trigger documentation makes that distinction explicit: a YAML PR trigger alone does not configure Azure Repos validation.

The result is a connected delivery path: the config generates the parameters; the infrastructure creates the outputs; the next stage consumes them. Engineers no longer have to transfer those values between console tasks.

Apply the security baseline from the central account

The security account deploys Firewall Manager policy through a separate Azure DevOps pipeline. That policy selects the relevant DMZ accounts and regional resource types, with automatic remediation enabled.

The WAF inspection point in this architecture is the ALB. CloudFront is not the attachment point for this central policy. Firewall Manager policies distribute the configured rule groups to in-scope resources across accounts.

The baseline combines managed protections with custom rules. Application compatibility is handled through deliberate rule tuning rather than asking each application engineer to assemble a new baseline. Legitimate authentication, uploads and machine-to-machine requests still need to work.

Rule order and exception scope matter in a shared web ACL. A permissive rule that terminates evaluation early can change what later protections see. Review the complete evaluation path when adjusting an application exception, then test both the required request and requests the policy should reject.

The rollout used COUNT mode before blocking, with central WAF logs supporting tuning and investigation. A blocked request is an enforcement result; its business meaning still depends on the request and the rule that matched.

CloudFront caching is disabled for these applications. Requests continue to the origin for application handling and ALB WAF inspection. The architecture therefore provides a global entry point and private origin connectivity without assuming that cached responses remove backend or inspection work.

Automate provisioning and keep migration cutover explicit

For a new domain, the DNS custom resource can create its CNAME as part of deployment. For an existing domain pointing at another provider, it detects the current value and leaves it in place. Stack outputs show the existing and required targets.

That allows the new infrastructure to be deployed and tested before live traffic moves. The migration tool then saves the old CNAME, checks that it has not changed unexpectedly, asks for confirmation and cuts over one domain. A rollback operation restores the saved value.

This distinction is essential to the end-to-end design. Routine provisioning is automated; redirecting an existing production service has an explicit operator decision. Once the site is on the platform, subsequent infrastructure changes continue through the same configuration and deployment path.

Assess the workload before adopting the pattern

This design fits applications that can share a regional entry and security foundation while retaining separate routes and backend ownership. Its benefits depend on workable private network paths, application-compatible rule tuning and a team able to operate shared infrastructure.

An AWS Workload Foundations Assessment maps those dependencies and control boundaries for one workload, then defines a target pattern and delivery plan.

For the commercial outcome, read the case study on retiring the Imperva contract.