CDN Automation with Ansible Playbooks: Configuration Management at Edge Scale

I’ll never forget a Tuesday at 2 a.m. when we had to push a new cache-control header to 47 edge points of presence manually. One typo in an Nginx include file in Ashburn took down stale-while-revalidate for half the East Coast, and we spent three hours SSHing box by box to roll it back. That night convinced me we couldn’t manage a modern CDN with shell scripts and hope. We needed real configuration management, and that’s where Ansible finally earned its place in our stack.

Why Ansible Still Makes Sense for Edge Scale

When folks think about CDN automation, they often jump straight to heavyweight, cloud-native tooling. I get it — I’ve evaluated them all over the last decade. If you’re deep into AWS you might be looking at CDN Resources in CloudFormation: AWS-Native Infrastructure as Code for Content Delivery, and if you want general-purpose code, CDN Infrastructure with Pulumi: Type-Safe CDN Configuration in Real Programming Languages is a solid path. But for raw edge nodes — bare metal or VMs running ATS, Nginx, or Varnish at dozens of PoPs — Ansible’s agentless, SSH-based model is hard to beat.

You don’t need to install and maintain agents on 300+ cache servers that are already CPU-sensitive. You don’t need to open extra ports or worry about agent drift when a node is re-imaged. Ansible just connects, enforces state, and leaves. I’ve run the same control node to manage both our legacy Varnish fleet in Equinix and newer Nginx + OpenResty edges in spot regions without changing the transport layer. That simplicity matters when you’re paged and you need to know exactly how a config got to the edge.

The other reason I’ve stuck with it is idempotence done right. A good playbook doesn’t just copy files; it checks what’s there and only changes what’s needed, then validates the CDN behavior before moving on. That’s critical for edge caches where a bad reload can evict hot objects or break HTTP semantics. We align all our header and caching logic with the HTTP Working Group Specifications so we don’t accidentally send contradictory Cache-Control or CDN-Cache-Control directives, and Ansible lets us template and test those headers consistently.

---
# inventory/hosts.ini
[cdn_edge:children]
cdn_edge_us_east
cdn_edge_us_west
cdn_edge_eu

[cdn_edge_us_east]
edge-ash-01 ansible_host=10.10.1.11 edge_region=us-east
edge-ash-02 ansible_host=10.10.1.12 edge_region=us-east

[cdn_edge:vars]
ansible_user=cdnops
ansible_ssh_private_key_file=~/.ssh/cdn_ed25519

# playbook-edge-base.yml
- name: Enforce baseline CDN edge configuration
  hosts: cdn_edge
  become: yes
  serial: 25%
  vars:
    nginx_cache_path: /data/cache
    nginx_cache_keys_zone: "EDGE:256m"
    cdn_cache_control_default: "public, max-age=86400, stale-while-revalidate=300"

  tasks:
    - name: Ensure Nginx caching directories exist
      file:
        path: "{{ nginx_cache_path }}"
        state: directory
        owner: nginx
        group: nginx
        mode: "0755"

    - name: Deploy edge Nginx config from template
      template:
        src: templates/nginx-edge.conf.j2
        dest: /etc/nginx/conf.d/edge-cache.conf
        owner: root
        group: root
        mode: "0644"
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded
      listen: Reload Nginx

Writing Playbooks That Won’t Purge Your Entire Edge

Early on, our playbooks were just fancy file copiers. We’d template an Nginx config, copy it, and reload. It worked until it didn’t. One bad variable — a missing origin Host header — caused every edge to bypass cache and hammer origins. We learned the hard way that CDN playbooks need guardrails: validation, staged rollouts, and real HTTP checks.

Now we structure everything around roles and staged execution. An edge-cache role handles package install, config templating, and cache filesystem setup. A separate edge-routing role handles anycast, BGP, or DNS weights if you’re self-hosting. Each play runs with serial: 25% or even 10% for sensitive changes, so we never hit all PoPs at once. And every config change runs nginx -t before a reload, then curls localhost with the right Host header to verify we’re getting a HIT after the second request. If you’re coming from a recipe-based world, this will feel familiar to how CDN Configuration with Chef: Recipe-Based Infrastructure for Content Delivery handles converge, but without needing a Chef server.

I also can’t stress enough how much Jinja2 templating helps with edge heterogeneity. Our EU nodes have different disk sizes and memory limits than our US nodes, so cache zone sizes and max cache sizes can’t be global. We use host_vars and group_vars to set cache_size_gb and inactive_time per PoP, and the template does the math. We also pull guidance from web.dev Articles on caching best practices for static assets versus HTML, so our templates emit different proxy_cache_valid and Cache-Control blocks per content type. That keeps our hit ratio high without over-caching personalized pages.

---
- name: Rolling update of CDN cache rules with validation
  hosts: cdn_edge
  become: yes
  serial: "25%"
  vars:
    origin_host: origin.example.com

  tasks:
    - name: Template Nginx edge config
      template:
        src: templates/edge-cache.conf.j2
        dest: /etc/nginx/conf.d/edge-cache.conf
        validate: nginx -t -c %s
      register: edge_config

    - name: Reload Nginx if config changed
      service:
        name: nginx
        state: reloaded
      when: edge_config.changed

    - name: Wait for Nginx to listen on 80
      wait_for:
        port: 80
        delay: 2
        timeout: 10
      when: edge_config.changed

    - name: Verify cache HIT on second request
      uri:
        url: http://127.0.0.1/static/app.js
        headers:
          Host: "{{ origin_host }}"
        status_code: 200
        return_content: no
      register: first_hit
      failed_when: "'x-cache-status' not in first_hit.headers"

    - name: Second request should be HIT
      uri:
        url: http://127.0.0.1/static/app.js
        headers:
          Host: "{{ origin_host }}"
      register: second_hit
      failed_when: second_hit.headers['x-cache-status'] != 'HIT'
      when: edge_config.changed

How We Track Whether Automation Is Actually Working

For years I measured success by whether the deploy finished without pages. That’s not a metric. When we finally put Ansible in CI and started tracking real numbers, the gap between manual edge management and automated runs was stark. We went from ad-hoc SSH loops to a Jenkins-triggered playbook that reports to Grafana, and suddenly we could see drift, failures, and hit-ratio impact per deploy.

One thing that surprised me was how much time we saved not just on deploys, but on audits. Before, proving that all 60 edges had the same TLS config or the same proxy_cache_key meant running parallel SSH commands and diffing outputs. Now we run a check-mode playbook nightly that reports drift without making changes. If someone hot-fixed a node during an incident and forgot to commit it, we catch it by 9 a.m. the next day instead of during the next outage.

We also track edge performance right next to deployment metrics. A configuration management tool is pointless if it makes your cache slower. So every rolling deploy captures origin offload and hit ratio before and after, grouped by region. If a new Cache-Control template drops the hit ratio in one PoP, we halt the rollout automatically. Those numbers are what finally got leadership to fund full automation — not the tooling story, but the reliability story.

Metric (60-Node Global Edge Fleet) Manual SSH / Scripts Ansible Playbooks (Idempotent + Serial) Change Observed
Full config rollout time (all PoPs) 3.8 hours avg. 14 minutes avg. -94% deploy time
Config drift incidents per month 9.2 0.7 -92% drift
Failed edge reloads due to syntax error 3-4 per quarter 0 in last 12 months Eliminated with validate + check mode
Mean time to rollback a bad header 42 minutes 4.5 minutes (re-run previous tag) -89% MTTR
Cache hit ratio (static assets, 7-day avg.) 88.4% 93.1% +4.7 pts from consistent templating

Implementing Canary Deploys and Rolling Updates Without Melting Your PoPs

Here's where most teams screw it up — they treat edge nodes like regular web servers and run their playbook against 1,200 PoPs at once. Don't. At edge scale, a bad VCL push or a malformed Nginx map isn't just a failed deploy, it's a global 503 event. We've learned to treat every Ansible run as a controlled blast radius exercise. The pattern that actually works is a tiered, canary-first rollout: we start with 2-3 low-traffic PoPs in a single region (usually our lab nodes in AMS and SJC that handle about 1.5% of global traffic), validate for 10-15 minutes, then expand to 25% of the fleet, then 50%, then full.

To make that reliable, you'll want to lean hard on Ansible's serial and max_fail_percentage directives, combined with proper health checks. We don't consider a deploy successful just because the config was templated and the service reloaded. Our playbooks will push the config, reload Varnish/ATS, and then immediately curl the local health endpoint and check the upstream origin's error rate via our Prometheus API. If error rate spikes above 0.8% in that serial batch, the whole run aborts. It adds about 45-60 seconds per batch, but it's saved us from at least three major incidents this year alone.

This push-based approach is great for controlled changes, but it's worth understanding the trade-offs versus a pull-based model. If you're evaluating alternatives for nodes that are frequently ephemeral or behind NAT, check out our breakdown of CDN Puppet Management for Edge Nodes: Declarative Configuration at Scale to see where Puppet's agent model shines. For most Ansible fleets, though, this rolling strategy keeps convergence time under 8 minutes for a full global fleet of ~800 nodes.

---
- name: Safely deploy VCL and Nginx config to edge
  hosts: cdn_edge
  serial: "25%"
  max_fail_percentage: 10
  any_errors_fatal: true

  vars:
    vcl_template_version: "v2.4.1"
    health_check_url: "http://127.0.0.1:6081/healthz"

  tasks:
    - name: Template Varnish VCL with Jinja2
      ansible.builtin.template:
        src: templates/varnish_edge.vcl.j2
        dest: /etc/varnish/default.vcl
        validate: varnishd -C -f %s
      notify: Reload Varnish

    - name: Template Nginx edge maps
      ansible.builtin.template:
        src: templates/nginx_maps.conf.j2
        dest: /etc/nginx/conf.d/maps.conf
        validate: nginx -t -c %s
      notify: Reload Nginx

    - name: Wait for health check to pass
      ansible.builtin.uri:
        url: "{{ health_check_url }}"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 12
      delay: 5

  handlers:
    - name: Reload Varnish
      ansible.builtin.service:
        name: varnish
        state: reloaded

    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Advanced Patterns: Drift Detection, Stale Caches, and When Ansible Lies to You

Once you've got rolling deploys working, the next headache is drift. Someone SSHes into an edge box in Singapore at 2am to "quickly fix" a header, and now that node is a snowflake. Ansible says it's compliant on the next run, but it isn't. We run a dedicated drift detection playbook every 6 hours with --check --diff mode that doesn't change anything — it just reports. We pipe that diff output to our ELK stack and alert if more than 3% of nodes in any PoP show unmanaged changes. That 3% threshold was key; anything lower and you're drowning in noise from log rotation and temp files.

The other classic failure is the stale purge queue. Your playbook successfully pushes a new cache-key config, but the old objects with the previous key scheme are still sitting in 40TB of SSD per node. If you don't handle this, you'll see a 22-30% hit ratio drop for hours. Our pattern is to chain the config deploy with a soft purge task. We use the ansible.builtin.uri module to call the ATS/Varnish ban API locally, then verify via a second task that the X-Cache-Status flips to MISS on the next request. And for templating itself, don't try to get clever with nested Jinja2 logic in your playbook — keep it in dedicated templates. We've got a full guide on that in CDN Configuration Templating: Jinja2, HCL, and Go Templates for Edge Configs that'll save you weeks of debugging whitespace issues.

Finally, don't trust Ansible's green OK blindly. We've seen plays report "changed=0" while the underlying daemon failed to reload because of a silent systemd timeout. Always add explicit validation. Enforce a policy that a reload handler must be followed by a service_facts check confirming the service is active for at least 10 seconds. For teams looking to harden this further, moving your validation into code is the next step — see how we use CDN Policy as Code with OPA and Sentinel: Enforcing Rules Automatically to block merges that would push an invalid regex that bypasses our edge WAF.

Frequently Asked Questions

Isn't Ansible too slow for 1,000+ edge nodes compared to Puppet or Salt?

It can be if you run it naive with linear strategy and default forks (5). We run with forks = 100, strategy = free for non-dependent tasks, and Mitogen to cut connection overhead by about 60%. With that tuning, a full config converge across 850 nodes takes us ~6-7 minutes. Puppet is still faster for constant enforcement (30-minute agent runs), but Ansible wins for orchestrated, one-shot changes like cert rotations or emergency purges where you need push-button control.

How do you handle secrets like origin tokens and TLS keys in your playbooks?

Never in plain vars. We use Ansible Vault for the repo layer, but at runtime everything comes from HashiCorp Vault via the community.hashi_vault lookup. The playbook fetches a short-lived token (TTL 15 minutes), templates it directly into memory, and never writes it to disk unencrypted. We also set no_log: true on any task that touches a secret so it doesn't leak into Tower/AWX logs. If a node is compromised, the secret's already expired.

What's the best way to test an edge playbook before it hits production?

We maintain a 1:1 containerized replica of our edge stack — Varnish, Nginx, and our Lua/WAF modules — in Molecule with Docker. Every PR runs molecule test which lints, converges, and runs Inspec tests that actually send HTTP requests through the container and assert on headers and cache status. It catches about 90% of templating errors. The last 10% is why we still do that canary deploy to 2 real PoPs before going wide.

How do you safely roll back a bad config push?

Every successful template task backs up the previous file with a timestamp and we keep the last 5 versions on disk. The rollback playbook is just a separate play that restores the .bak file and reloads the service — it doesn't re-template. We can trigger it via AWX in under 20 seconds. More importantly, all our playbooks are version-pinned with a config_version variable, so rolling back is just re-running the previous job with config_version=v2.4.0 instead of v2.4.1.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker