Ansible - Discover and Backup PowerDNS

3 minute read

Refreshed June 2026: the original did this with two playbooks, a shell wrapper, and a chain of curl, jq, grep, replace, and lineinfile to turn JSON into a YAML list by hand. None of that is needed now. The ansible.builtin.uri module calls the PowerDNS API and hands back parsed JSON directly, so the whole thing collapses to one playbook with no text munging. The API path also gained an /api/v1 prefix since 2015. Playbook syntax-checked on ansible-core 2.19.

Background

I wanted Ansible to back up the zones and records from a PowerDNS server through its HTTP API. The 2015 version of this post worked, but it was held together with shell commands: curl to hit the API, jq to format it, then grep and three replace passes and a lineinfile to carve a usable zone list out of the JSON. It also needed two separate playbooks because there was no clean way to pass the discovered zones from one step to the next without writing an intermediate file.

The uri module removes all of that. It calls the API, parses the JSON response into a variable, and you loop over it.

The Playbook

One playbook. Discover the zones, pull each zone’s records, write each one to disk.

---
- name: Discover and back up PowerDNS zones and records
  hosts: localhost
  connection: local
  gather_facts: false
  vars:
    pdns_api_key: changeme
    pdns_api_url: http://127.0.0.1:8081/api/v1
    zones_dir: pdns_zone_backups
  tasks:
    - name: Ensure the backup directory exists
      ansible.builtin.file:
        path: "{{ zones_dir }}"
        state: directory
        mode: "0750"

    - name: Discover all zones
      ansible.builtin.uri:
        url: "{{ pdns_api_url }}/servers/localhost/zones"
        method: GET
        headers:
          X-API-Key: "{{ pdns_api_key }}"
      register: pdns_zones

    - name: Pull the full record set for each zone
      ansible.builtin.uri:
        url: "{{ pdns_api_url }}/servers/localhost/zones/{{ item.id }}"
        method: GET
        headers:
          X-API-Key: "{{ pdns_api_key }}"
      register: pdns_zone_detail
      loop: "{{ pdns_zones.json }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Write each zone backup to disk
      ansible.builtin.copy:
        content: "{{ item.json | to_nice_json }}\n"
        dest: "{{ zones_dir }}/{{ item.json.name }}.json"
      loop: "{{ pdns_zone_detail.results }}"
      loop_control:
        label: "{{ item.json.name }}"

A few notes on why this is so much shorter than the original:

  • uri returns parsed JSON. pdns_zones.json is already a list of zone dictionaries. No curl, no jq, no parsing the text yourself.
  • The zone list passes straight into a loop. loop: "{{ pdns_zones.json }}" uses the discovery result directly, so there is no intermediate pdns_zones_query_names.yml file and no second playbook.
  • Reference each zone by id. The API gives every zone an id, which is the correct value to use in the per-zone URL (it handles the trailing dot and any escaping for you).
  • Back up as .json. The original saved JSON content into files named .yml, which was misleading. These are JSON, so they get a .json extension.

What The Zones API Returns

The discovery call returns a JSON array of zone objects. Trimmed to a couple of entries:

[
  {
    "id": "vagrant.local.",
    "url": "/api/v1/servers/localhost/zones/vagrant.local.",
    "name": "vagrant.local",
    "kind": "Native",
    "dnssec": false,
    "serial": 2015101001
  },
  {
    "id": "1.168.192.in-addr.arpa.",
    "url": "/api/v1/servers/localhost/zones/1.168.192.in-addr.arpa.",
    "name": "1.168.192.in-addr.arpa",
    "kind": "Native",
    "dnssec": false,
    "serial": 2015101001
  }
]

The per-zone call returns the full record set. For example, pdns_zone_backups/vagrant.local.json:

{
  "id": "vagrant.local.",
  "name": "vagrant.local",
  "kind": "Native",
  "dnssec": false,
  "serial": 2015101001,
  "records": [
    {
      "content": "192.168.70.241",
      "disabled": false,
      "ttl": 3600,
      "type": "A",
      "name": "dns.vagrant.local"
    },
    {
      "content": "node-1.vagrant.local hostmaster.vagrant.local 2015101001 10800 3600 604800 3600",
      "disabled": false,
      "ttl": 3600,
      "type": "SOA",
      "name": "vagrant.local"
    }
  ]
}

Automating It

These tasks are a natural fit for a scheduled run: a cron job, a CI pipeline, or a systemd timer. Point the dest at a directory under version control and commit after each run, and you have a dated history of every zone change. Because the playbook is idempotent on the directory and overwrites the backups in place, a git diff after each run shows exactly what changed in DNS.

Conclusion

Same goal as a decade ago, a fraction of the moving parts. Let uri talk to the API and parse the JSON, loop over the result, and write it out. No shell pipeline to babysit.

Enjoy!

Comments