How to back up and restore Cloudflare DNS records
A deleted Cloudflare DNS record cannot be undone, and Cloudflare support cannot restore the DNS of a deleted domain, so keep your own copy. Export the zone from its DNS Records page (Import and Export, then Export), or call GET /zones/{zone_id}/dns_records/export with a read-only API token, and save the JSON record list next to it. To restore, compare the backup with the live zone and put back only what is missing or wrong: import only adds records.
Why DNS needs its own backup
Server backups and snapshots do not contain DNS. The records live at Cloudflare, so when one disappears the server is fine and nobody can reach it. Cloudflare's docs say deleting records "can cause downtime and cannot be reverted", and support "is unable to restore DNS or settings for deleted domains". Records get lost in ordinary ways:
- Someone deletes or edits the wrong record in the dashboard.
- A Terraform apply deletes every record whose resource left the code, or a script with an Edit token changes hundreds of records in seconds.
- The whole zone is deleted: by a user, by Cloudflare when the nameservers stop pointing to it, or when a new domain stays pending for 28 days.
A missing A or CNAME takes a site offline, a missing MX stops incoming mail, and missing SPF, DKIM or DMARC TXT records get your outgoing mail rejected. Verification TXT records for other services are the easiest to forget: nothing complains until that service checks again.
Export from the dashboard
- Open the zone in the Cloudflare dashboard and go to its DNS Records page.
- Select Import and Export.
- Select Export. The browser downloads a BIND zone file: plain text, one record per line.
Each line holds name, TTL, class, type and content. What BIND has no field for, Cloudflare writes as a comment after ;. From Cloudflare's documentation:
a.cloudflaredocs.com. 1 IN CNAME example.com. ; cf_tags=test:1,cf-flatten-cname
b.cloudflaredocs.com. 1 IN CNAME example.com. ; cf_tags=cf-proxied:false
c.cloudflaredocs.com. 1 IN CNAME example.com. ; cf_tags=tag-without-value,cf-proxied:trueExport by hand before any large change. For a backup that does not depend on remembering, use the API.
What the export keeps, and what it leaves out
| Item | In the zone file? | Notes |
|---|---|---|
| Name, TTL, type and content of every record | Yes | Names are fully qualified and end in a dot. |
| Proxy status (orange cloud) | Yes | As the tag cf-proxied:true or cf-proxied:false. |
| Per-record CNAME flattening | Yes | As the tag cf-flatten-cname (paid zones). |
| Record comments and tags | Yes | After the ;, with tags after cf_tags=. Tags need a Pro plan or higher; Free zones get comments of up to 100 characters. |
| Record IDs, created and modified dates | No | Only in the JSON from the list endpoint. |
| Zone settings: SSL/TLS mode, caching, rules, WAF, DNSSEC | No | The export is DNS records only. Write these settings down separately. |
A TTL of 1 means Auto. That is how the API stores it, and Cloudflare's own export examples use it.
Create a read-only API token
The API needs a zone ID and a token. The zone ID is on the domain's Overview page, in the API section.
- Go to My Profile > API Tokens, select Create Token, and build a custom token.
- Choose the permission Zone, DNS, Read (
DNS Readin Cloudflare's reference). It can read DNS records and gets an error for anything else. - Limit it to the zones you back up. Optionally allow only your server's IP under Client IP Address Filtering.
- Create it and copy the secret. Cloudflare shows it once.
Restores need DNS Write (Edit in the dashboard). Make that a second token and keep it off the backup server, so a leaked backup token can only read. Store the read token as a header line in a file only root can read:
sudo install -d -m 700 /etc/cloudflaresudo install -m 600 /dev/null /etc/cloudflare/dns-read.headerread -rsp 'Cloudflare token: ' T && printf 'Authorization: Bearer %s\n' "$T" | sudo tee /etc/cloudflare/dns-read.header > /dev/null; unset Tinstall -m 600 /dev/null creates the file with its final permissions before the token goes in. read -rsp prompts without echoing, so the token stays out of your shell history, and printf is a shell builtin, so it never shows in ps. curl's -H @file then sends each line of the file as a header, which keeps the token out of scripts too. Check that a user token works:
sudo curl -sS -H @/etc/cloudflare/dns-read.header https://api.cloudflare.com/client/v4/user/tokens/verifyExport with the API
Set the zone ID in your shell, then download the zone file:
ZONE_ID=your-zone-idsudo curl -sS --fail-with-body -H @/etc/cloudflare/dns-read.header -o example.com.zone "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/export"-sShides the progress meter but still prints errors.--fail-with-bodymakes curl exit with code 22 when the API answers with an HTTP error, so a script notices a bad token. It needs curl 7.76 or newer.-owrites the response to a file.
The list endpoint returns more than the zone file: every record as JSON, with its id, proxied, comment, tags and modified_on. You need the IDs to fix a changed record later.
sudo curl -sS --fail-with-body -H @/etc/cloudflare/dns-read.header "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?per_page=5000" | jq '.result | sort_by(.type, .name, .content)' > example.com.jsonThe list is paged: per_page sets the page size, and result_info.total_pages in each response says how many pages there are. One page of 5,000 covers most zones; the script below reads every page, however many there are.
Automate it with cron
This script saves both files for one zone, refuses to keep an empty backup, and deletes files older than 30 days. It needs curl and jq (sudo apt install curl jq on Debian and Ubuntu).
#!/usr/bin/env bash
# Back up one Cloudflare zone: the BIND export and every record as JSON.
# Usage: cf-dns-backup.sh <zone-id> <zone-name>
set -euo pipefail
ZONE_ID="${1:?zone ID}"
ZONE="${2:?zone name}"
OUT_DIR="/var/backups/cloudflare-dns"
AUTH="/etc/cloudflare/dns-read.header"
API="https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records"
STAMP="$(date +%F)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
mkdir -p "$OUT_DIR"
# 1. The zone file, the same as the dashboard's Export button.
curl -sS --fail-with-body -H @"$AUTH" -o "$TMP/zone.txt" "$API/export"
# 2. Every record as JSON, one page at a time.
page=1
while :; do
curl -sS --fail-with-body -H @"$AUTH" -o "$TMP/page-$page.json" "$API?per_page=500&page=$page"
pages="$(jq -er '.result_info.total_pages' "$TMP/page-$page.json")"
[ "$page" -ge "$pages" ] && break
page=$((page + 1))
done
jq -s '[.[].result[]] | sort_by(.type, .name, .content)' "$TMP"/page-*.json > "$TMP/records.json"
# 3. Refuse to keep an empty backup, then move both files into place.
[ "$(jq length "$TMP/records.json")" -gt 0 ]
mv "$TMP/zone.txt" "$OUT_DIR/$ZONE-$STAMP.zone"
mv "$TMP/records.json" "$OUT_DIR/$ZONE-$STAMP.json"
# 4. Delete backups older than 30 days.
find "$OUT_DIR" -name "$ZONE-*" -type f -mtime +30 -deletesudo chmod 700 /usr/local/bin/cf-dns-backup.sh15 3 * * * root /usr/local/bin/cf-dns-backup.sh your-zone-id example.com >> /var/log/cf-dns-backup.log 2>&1set -euo pipefailstops at the first failed command, so a rejected token never leaves a file that looks like a backup.jq -efails iftotal_pagesis missing, instead of looping forever.- Files are named by date, so a run never overwrites an earlier day, and an emptied zone stops at the empty check.
- For several zones, add one cron line per zone. One token can cover them all.
We ran the script against a local stand-in for the API with a page size of 2: it fetched all three pages and merged five records into one sorted file. With a wrong token it exited with code 22 and saved nothing.
Copy the backups off the server too, for example with rclone. The day you need them may be the day the server is unreachable.
Before restoring: compare with the live zone
Do not delete everything and import the backup: every record gets a new ID, which breaks Terraform state and scripts that update records by ID, and names stop resolving in between. Importing over a damaged zone is no better. Import only adds, so if www now points to a wrong IP, the import adds the right IP as a second A record and Cloudflare answers with both.
Find the differences first. Fetch the live records:
sudo curl -sS --fail-with-body -H @/etc/cloudflare/dns-read.header "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?per_page=5000" | jq '.result | sort_by(.type, .name, .content)' > live.jsonThen reduce each file to one line per record. Save this filter:
.[] | [.type, .name, .content, (.priority // "" | tostring), (.ttl | tostring), (.proxied | tostring)] | @tsvjq -r -f records.jq example.com-2026-10-02.json | sort > backup.tsvjq -r -f records.jq live.json | sort > live.tsvdiff backup.tsv live.tsv1,2c1
< A example.com 203.0.113.10 1 true
< A mail.example.com 203.0.113.25 300 false
---
> A example.com 198.51.100.7 1 trueLines starting with < are in the backup but not live; lines with > are live but not in the backup. In this sample zone, mail.example.com was deleted and the root A record was changed from 203.0.113.10 to 198.51.100.7. The filter includes priority because the API keeps an MX record's priority in its own field.
Put back a deleted record
Copy the record's line from the backup zone file into a new file. Its ; comment carries the proxy status, comment and tags along:
grep '^mail\.example\.com\.' example.com-2026-10-02.zone > restore.zoneRead restore.zone and delete any line you do not want back. Then import it with the Edit token, saved the same way in dns-edit.header on the machine you restore from:
curl -sS --fail-with-body -H @dns-edit.header --form "[email protected]" "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/import" | jq '.result'The result has recs_added and total_records_parsed. If the first is lower, some lines were not added; the response's errors and messages say why. The dashboard does the same through Import and Export > Import DNS records.
- A file can be at most 256 KiB, and import allows three requests per minute per user.
- A
cf-proxiedtag decides proxy status. Lines without one follow the API'sproxiedform field, or the dashboard's Proxy imported DNS records box. CNAME,DNAME,MX,NS,PTRandSRVcontent must be a fully qualified name ending in a dot, as exported lines are.
Fix a record that was changed
Import cannot change an existing record. Update it in place instead: take its ID from the live list and its values from the backup.
RECORD_ID=$(jq -r '.[] | select(.type == "A" and .name == "example.com") | .id' live.json)jq -c '.[] | select(.type == "A" and .name == "example.com") | {type, name, content, ttl, proxied}' example.com-2026-10-02.json > fix.jsoncurl -sS --fail-with-body -X PATCH -H @dns-edit.header -H "Content-Type: application/json" --data @fix.json "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID"PATCH updates the record in place, so its ID stays the same. The API reference marks name, ttl and type as required, hence fix.json carries them; for MX, add priority. If a name has several A records, select returns several: pick the one you mean.
For many changes at once, the batch endpoint POST /zones/{zone_id}/dns_records/batch takes deletes, patches, puts and posts in one database transaction. If any one fails, none is applied. It accepts 200 records per call on Free zones and 3,500 on paid plans.
Common errors
| Problem | Fix |
|---|---|
| curl exits with code 22 | Cloudflare refused the request. Check the token with the verify call above, that it covers this zone, and that it has DNS Read for backups or DNS Write for imports and edits. |
$INCLUDE directive not allowed | The import file uses $INCLUDE. Paste the included records into the file instead. |
NS records with that host already exist. (Code:81056) | That name is delegated to a separate subdomain zone with NS records, so this zone cannot also hold A, AAAA or CNAME records for it. |
A CNAME will not save on a name that has an A or AAAA record | A name with a CNAME can have no address records. Delete the one you no longer want first. |
| The zone file is too large to import | The limit is 256 KiB. Split it, and leave 20 seconds between imports. |
Frequently asked questions
- Does Cloudflare back up my DNS records?
- Not in a way you can restore from. Deleted records cannot be reverted, and Cloudflare support cannot restore the DNS or settings of a deleted domain. Keep your own export.
- Does the Cloudflare DNS export include proxy status?
- Yes. Proxied records get a cf-proxied:true tag and DNS-only records cf-proxied:false. On import, that tag decides the proxy status.
- Will importing a zone file overwrite my existing records?
- No. Import only adds records, so a changed record stays changed and can end up with a second record beside it. Compare first, import what is missing, and fix changed records by ID.
- What API token permission do I need to export DNS records?
- DNS Read (Zone, DNS, Read in the dashboard). Importing or editing records needs DNS Write.
How this was checked
Commands, limits and prices were checked against these official pages, on October 3, 2026:
- Cloudflare DNS docs: Import and export records
- Cloudflare API: Export DNS Records
- Cloudflare API: Import DNS Records
- Cloudflare API: List DNS Records
- Cloudflare API: Update DNS Record
- Cloudflare DNS docs: Batch record changes
- Cloudflare DNS docs: Record attributes
- Cloudflare DNS docs: Domain deleted from Cloudflare
- Cloudflare docs: Create API token
- Cloudflare docs: API token permissions
- curl(1) manual (Ubuntu 24.04)