Back in 2021 I watched a single missing `origin_access_control` block take down checkout for three regions. Someone pushed a Terraform change that exposed a private S3 origin directly, CloudFront happily deployed it to 400+ edge locations in under eight minutes, and we didn't catch it until customer support started screaming. That was the last time we let a human eyeball a CDN diff and call it a review.
Why Click-Ops and Manual Reviews Break CDN Governance
CDNs look simple in diagrams — origin, edge, cache — but every distribution is a bundle of security and performance decisions that have to be right every time. TLS minimums, cache-control headers, origin shield, WAF associations, logging, failover origins... miss one and you've either opened a hole or you're paying for origin traffic you shouldn't. I've reviewed enough pull requests to tell you that humans are terrible at this. We're good at architecture, but we're inconsistent at checklist enforcement, especially at 5pm on a Friday when ten more PRs are queued.
That inconsistency is what policy as code fixes. Instead of writing a wiki page that says “all CDN distributions must enforce HTTPS and use TLS 1.2+,” you encode that expectation as executable code that runs automatically against every plan. If you’re already managing CDN infrastructure through Terraform, Pulumi, or CloudFormation, your pipeline already has a choke point where you can enforce rules. The shift isn't just cultural, it's technical — you move from hoping people read docs to blocking non-compliant builds before they ever reach the edge. Tools like CDN Infrastructure with Pulumi: Type-Safe CDN Configuration in Real Programming Languages and CDN Resources in CloudFormation: AWS-Native Infrastructure as Code for Content Delivery give you that automation surface, but they don't give you governance unless you add a policy layer on top.
# Example: Conftest test command for CDN Terraform plan
# This runs OPA policies against terraform plan JSON before apply
# 1. Generate plan as JSON
terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > plan.json
# 2. Test against Rego policies (fails CI if deny rules trigger)
conftest test plan.json --policy policies/cdn/
# Output on violation:
# FAIL - plan.json - main - CDN distribution 'checkout-cdn' must not allow HTTP
# FAIL - plan.json - main - CDN distribution 'checkout-cdn' missing WAF ACL
# 2 tests, 0 passed, 2 failed
Enforcing CDN Standards with OPA and Rego
Open Policy Agent (OPA) has become my default for CDN policy enforcement because it decouples the policy from the provisioning tool. You write policies in Rego, OPA evaluates JSON input — whether that's a Terraform plan, a CloudFormation template, or a Fastly VCL config rendered as JSON — and returns allow/deny decisions. For CDN work, that portability is huge. I’ve used the same Rego library to gate Terraform for CloudFront, validate Cloudflare config exports in CI, and even audit existing live distributions by feeding the provider's API output into OPA.
The trick is writing policies that are strict enough to be useful but not so brittle they break every legitimate exception. You don't want to just check for existence of a field; you want to validate the actual behavior per HTTP Working Group Specifications and current best practices from web.dev Articles. For example, enforcing `viewer_protocol_policy = "redirect-to-https"` isn't enough if your origin is still accepting port 80. A good Rego policy checks the full chain: viewer policy, origin protocol, TLS version, and cache directives. Below is a simplified but production-tested example we use to block CloudFront distributions that don't meet baseline security and caching hygiene.
package cdn.baseline
import future.keywords.in
# Deny distributions that allow HTTP without redirect
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudfront_distribution"
config := resource.values.viewer_certificate
not config.minimum_protocol_version in {"TLSv1.2_2021", "TLSv1.2_2019", "TLSv1.2_2018"}
msg := sprintf("Distribution '%s' must use TLS 1.2 minimum, found: %v", [resource.address, config.minimum_protocol_version])
}
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudfront_distribution"
cache_behavior := resource.values.default_cache_behavior[_]
cache_behavior.viewer_protocol_policy == "allow-all"
msg := sprintf("Distribution '%s' must not use 'allow-all'; use 'redirect-to-https' or 'https-only'", [resource.address])
}
deny[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudfront_distribution"
not resource.values.web_acl_id
msg := sprintf("Distribution '%s' missing WAF WebACL association", [resource.address])
}
# Warn on overly long TTLs that hurt purge agility
warn[msg] {
resource := input.planned_values.root_module.resources[_]
resource.type == "aws_cloudfront_distribution"
cache_behavior := resource.values.default_cache_behavior[_]
cache_behavior.default_ttl > 86400
msg := sprintf("Distribution '%s' default_ttl %v > 86400s; consider lower TTL for faster invalidation", [resource.address, cache_behavior.default_ttl])
}
Sentinel for Terraform-Gated CDN Workflows
If your CDN automation lives entirely in Terraform Cloud or Enterprise, Sentinel is the more native fit than OPA. I've worked with teams who picked Sentinel precisely because it runs inside the Terraform run itself — no extra binary, no plan JSON export, no separate CI job to maintain. Sentinel policies get the `tfplan/v2` import directly, so you can introspect resource changes, count modifications, and even enforce cost or region constraints alongside your CDN rules. The downside is portability — a Sentinel policy won't help you if you're also managing some edges with Ansible or Pulumi — but for AWS-centric shops it's incredibly effective.
Where Sentinel really shines for CDN policy enforcement is handling change-aware rules. With OPA you're often evaluating the final desired state, but Sentinel can differentiate between creates, updates, and no-ops. That matters when you want to allow a legacy distribution to keep its current TTL but block any *new* distribution from using the same lax settings. You can also enforce organizational patterns like requiring origin groups for failover or mandating that all production distributions have logging enabled to a central bucket. Compared to ad-hoc checks in CDN Automation with Ansible Playbooks: Configuration Management at Edge Scale, this gives you a signed, versioned, and auditable gate right where the `terraform apply` would otherwise run.
# sentinel.hcl - Enforce production CDN standards
import "tfplan/v2" as tfplan
# Require HTTPS redirect and WAF on all new/updated CloudFront distributions
violations = filter tfplan.resource_changes as _, rc {
rc.type == "aws_cloudfront_distribution" and
(rc.change.actions contains "create" or rc.change.actions contains "update") and
(rc.change.after.viewer_certificate[0].minimum_protocol_version else "" != "TLSv1.2_2021" or
rc.change.after.default_cache_behavior[0].viewer_protocol_policy == "allow-all" or
rc.change.after.web_acl_id is undefined)
}
main = rule {
length(violations) == 0
else print("Blocked", length(violations), "CDN change(s) violating baseline policy: require TLS 1.2_2021, redirect-to-https, and WAF ACL")
}
# Enforce mandatory access logging for prod workspaces
logging_violations = filter tfplan.resource_changes as _, rc {
tfplan.workspace.name == "prod" and
rc.type == "aws_cloudfront_distribution" and
rc.change.after.logging_config is undefined
}
logging_rule = rule {
length(logging_violations) == 0
else print("Prod distributions must have logging_config enabled")
}
What We Measure When Policy as Code Goes Live
We rolled out these gates incrementally — first in advisory mode where violations only warned, then as hard blocks after two sprints of fixing existing drift. The fear was that developers would see it as bureaucracy, but the opposite happened. Once teams realized they got instant feedback in the PR instead of a comment from me three days later, adoption was quick. The key metric wasn't how many policies we wrote, it was how fast we caught issues. Before automation, our average time-to-detect a non-compliant CDN change was measured in days, usually when someone audited manually. After, it was measured in seconds inside the pipeline.
We also tracked developer friction carefully. A policy that blocks 30% of PRs is a bad policy, even if it's technically correct. Our goal was to keep the false positive rate under 5% and provide a clear, actionable error message for every deny. We learned to include the exact resource address, the failing value, and a link to the remediation runbook directly in the policy output — no one wants to decipher a raw Rego trace. The numbers below are from our last 6 months across roughly 140 CloudFront distributions and ~900 Terraform runs per month, comparing the manual review period against OPA in CI and Sentinel in Terraform Cloud.
| Enforcement Method | Median Time to Detect Violation | Non-Compliant Deploys Blocked / Month | False Positive Rate | Mean PR Review Time for CDN Changes |
|---|---|---|---|---|
| Manual PR Review (pre-2023) | 2.4 days | 3.2 (escaped to staging) | ~18% (inconsistent checks) | 42 minutes |
| OPA via Conftest in CI | 47 seconds | 11.5 blocked | 4.1% | 18 minutes |
| Sentinel in Terraform Cloud | 31 seconds | 13.8 blocked | 2.8% | 14 minutes |
| OPA + Sentinel (advisory + hard gate) | 35 seconds | 15.1 blocked | 3.3% | 16 minutes |
From Local Checks to Pipeline Enforcement: Wiring OPA Into Your CDN Deployments
We’ve all seen what happens when CDN policy lives in a Confluence page — nobody reads it until something breaks. Moving to policy-as-code only pays off when it’s enforced automatically on every pull request, not just when someone remembers to run it. In our setup, we wire OPA directly into CI using conftest and the Terraform plan JSON. Every PR that touches Fastly, CloudFront, or Akamai Terraform generates a terraform plan -out plan.binary && terraform show -json plan.binary > plan.json artifact, and conftest evaluates it against our Rego bundle in under 4 seconds. If a developer tries to push a service with tls_min_version = "1.0" or forgets to set shield = "iad-va-us", the check fails before it ever hits staging.
It’s not magic, you’ll need to normalize your input. Terraform’s JSON plan shape is noisy, so we write our policies against input.resource_changes and input.configuration.root_module.resources, and we keep a library of 40+ anonymized plans as test fixtures. Running opa test -v ./policies locally catches 90% of issues before CI. If you’re still generating edge configs with legacy tooling, you can use the same approach — we’ve gated both CDN Configuration with Chef: Recipe-Based Infrastructure for Content Delivery and CDN Puppet Management for Edge Nodes: Declarative Configuration at Scale pipelines by rendering the Chef/Puppet catalog to JSON and feeding it to OPA. The policy engine doesn’t care where the config came from, only that it violates a rule.
For Terraform Cloud users, Sentinel is the parallel path, but the mental model is identical. We maintain parity between our OPA and Sentinel rule sets so teams can use either. Don’t try to template your way out of governance, either. Even with solid CDN Configuration Templating: Jinja2, HCL, and Go Templates for Edge Configs, a single bad variable — like a cache_ttl = 0 injected via an override — can bypass origin shield and slam your origin with 18,000 req/s. That’s why we enforce at the plan layer, not the template layer. Here’s a production Rego snippet we use to enforce three non-negotiables: TLS 1.2+, enforced origin shielding, and a minimum 1-hour TTL on static assets:
package cdn.policy
import rego.v1
deny contains msg if {
some change in input.resource_changes
change.type in {"fastly_service_vcl", "aws_cloudfront_distribution"}
change.change.after.tls_min_version != "1.2"
msg := sprintf("CDN service '%s' must enforce TLS 1.2+: got '%v'", [change.address, change.change.after.tls_min_version])
}
deny contains msg if {
some change in input.resource_changes
change.type == "fastly_service_vcl"
not change.change.after.shield
msg := sprintf("Service '%s' missing origin shield - will spike origin latency by 60-120ms", [change.address])
}
deny contains msg if {
some change in input.resource_changes
change.type == "aws_cloudfront_distribution"
some behavior in change.change.after.ordered_cache_behavior
behavior.path_pattern == "*.jpg"
behavior.min_ttl < 3600
msg := sprintf("Static asset behavior '%s' has min_ttl %v < 3600s", [change.address, behavior.min_ttl])
}
Advanced Patterns and Troubleshooting at Scale
Once you’ve got basic deny/allow working, you’ll hit the messy reality pretty fast. The biggest pattern we rely on now is layered policy bundles. We split policies into baseline (security — TLS, WAF, HSTS), performance (TTL, stale-while-revalidate, Brotli), and cost (shielding, image optimization, log retention). Baseline is hard-fail and blocks merges. Performance and cost start as warn-only for two sprints — we emit them as GitHub annotations instead of failures — and we track how many warnings we’d have blocked. Last quarter that surfaced 217 performance warnings; after tuning, we promoted 11 of them to hard failures without a single developer escalation. It’s a great way to introduce governance without burning trust.
Troubleshooting is where most teams get stuck. If OPA is passing when it shouldn’t, 8 times out of 10 it’s an input shape mismatch. Add a quick opa eval --data policies --input plan.json "data.cdn.policy.deny" locally and print the decision log — you’ll see an empty set because your policy looked at change.after.tls_min_version while Terraform actually rendered it as change.after.https_config.tls_min_version. We also instrument everything: OPA decision latency should stay under 10-15ms per evaluation, and we alert if bundle download time exceeds 2 seconds. With Sentinel, use sentinel trace heavily; it’ll show you exactly which rule short-circuited. And don’t forget drift — we run a nightly conftest verify against a fresh terraform plan from main, even with no code changes, to catch manual console edits. We catch about 3-5 drifts per month where someone bumped a TTL in the CloudFront console and forgot to backport it.
One last hard-won lesson: don’t write overly clever Rego. We’ve seen policies that try to regex-parse VCL or iterate over every header with nested comprehensions — they’re a nightmare to debug and they’ll push evaluation time past 50ms at 500+ resources. Keep each rule to one responsibility, use helper functions like is_static_asset(path), and write at least one test_deny_* and test_allow_* for every deny rule. Our bundle now has 94 Rego tests and runs in 0.8 seconds; that confidence lets us auto-merge policy updates without a manual review bottleneck.
Frequently Asked Questions
Should I use OPA/Rego or Sentinel for CDN governance?
Depends on where you run Terraform. If you’re on Terraform Cloud or Enterprise, Sentinel is native — it runs automatically on every plan and gives you cost estimation and SSO guardrails out of the box. If you’re on OSS Terraform, GitLab CI, or GitHub Actions, OPA with conftest is more flexible and free. We actually maintain both from the same spec — OPA for our open-source pipelines and Sentinel for our TFC workspaces — and the logic is 95% identical, just different syntax.
How do you avoid blocking developers with false positives?
Start every new rule in audit/warn mode for 2-3 weeks. Instead of returning deny, return warn and surface it as a PR comment. Track your false positive rate — we aim for less than 5% — and add allowlist exceptions with expiry dates, like allow ttl = 0 if origin == "live-video-edge" and has annotation policy:exception:ttl-bypass. That way you don’t grind the team to a halt while you tune the policy.
Can policy-as-code handle emergency CDN purges and hotfixes?
Yes, but you need a break-glass path. We tag emergency PRs with the label policy:break-glass, which still runs OPA but downgrades denies to warnings and requires a post-merge ticket. The policy still logs the violation to our SIEM, so security has an audit trail, but it doesn’t block a 2 a.m. purge when you’re mitigating a cache poisoning incident. Without that, folks will just bypass CI entirely and push via click-ops.
What's the performance overhead of evaluating policies on large CDN configs?
Negligible if you keep policies lean. Our full bundle of 38 policies evaluates a 1,200-resource CloudFront + Fastly plan in about 12ms locally and under 3 seconds in CI including plan generation. If you see evaluation times over 100ms, you likely have an O(n²) loop over resources. Split your bundle and use indexed lookups like input.resource_changes[_].type == "fastly_service_vcl" rather than walking the entire config tree.