VPS Snaps

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.

10 min readUpdated Checked against official documentation

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

  1. Open the zone in the Cloudflare dashboard and go to its DNS Records page.
  2. Select Import and Export.
  3. 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:

Zone file (Cloudflare's example)
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:true

Export 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

ItemIn the zone file?Notes
Name, TTL, type and content of every recordYesNames are fully qualified and end in a dot.
Proxy status (orange cloud)YesAs the tag cf-proxied:true or cf-proxied:false.
Per-record CNAME flatteningYesAs the tag cf-flatten-cname (paid zones).
Record comments and tagsYesAfter 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 datesNoOnly in the JSON from the list endpoint.
Zone settings: SSL/TLS mode, caching, rules, WAF, DNSSECNoThe 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.

  1. Go to My Profile > API Tokens, select Create Token, and build a custom token.
  2. Choose the permission Zone, DNS, Read (DNS Read in Cloudflare's reference). It can read DNS records and gets an error for anything else.
  3. Limit it to the zones you back up. Optionally allow only your server's IP under Client IP Address Filtering.
  4. 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:

Terminal
sudo install -d -m 700 /etc/cloudflare
Terminal
sudo install -m 600 /dev/null /etc/cloudflare/dns-read.header
Terminal
read -rsp 'Cloudflare token: ' T && printf 'Authorization: Bearer %s\n' "$T" | sudo tee /etc/cloudflare/dns-read.header > /dev/null; unset T

install -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:

Terminal
sudo curl -sS -H @/etc/cloudflare/dns-read.header https://api.cloudflare.com/client/v4/user/tokens/verify

Export with the API

Set the zone ID in your shell, then download the zone file:

Terminal
ZONE_ID=your-zone-id
Terminal
sudo 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"
  • -sS hides the progress meter but still prints errors.
  • --fail-with-body makes 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.
  • -o writes 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.

Terminal
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.json

The 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/local/bin/cf-dns-backup.sh
#!/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 -delete
Terminal
sudo chmod 700 /usr/local/bin/cf-dns-backup.sh
/etc/cron.d/cloudflare-dns-backup
15 3 * * * root /usr/local/bin/cf-dns-backup.sh your-zone-id example.com >> /var/log/cf-dns-backup.log 2>&1
  • set -euo pipefail stops at the first failed command, so a rejected token never leaves a file that looks like a backup.
  • jq -e fails if total_pages is 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:

Terminal
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.json

Then reduce each file to one line per record. Save this filter:

records.jq
.[] | [.type, .name, .content, (.priority // "" | tostring), (.ttl | tostring), (.proxied | tostring)] | @tsv
Terminal
jq -r -f records.jq example.com-2026-10-02.json | sort > backup.tsv
Terminal
jq -r -f records.jq live.json | sort > live.tsv
Terminal
diff backup.tsv live.tsv
Output
1,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	true

Lines 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:

Terminal
grep '^mail\.example\.com\.' example.com-2026-10-02.zone > restore.zone

Read 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:

Terminal
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-proxied tag decides proxy status. Lines without one follow the API's proxied form field, or the dashboard's Proxy imported DNS records box.
  • CNAME, DNAME, MX, NS, PTR and SRV content 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.

Terminal
RECORD_ID=$(jq -r '.[] | select(.type == "A" and .name == "example.com") | .id' live.json)
Terminal
jq -c '.[] | select(.type == "A" and .name == "example.com") | {type, name, content, ttl, proxied}' example.com-2026-10-02.json > fix.json
Terminal
curl -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

ProblemFix
curl exits with code 22Cloudflare 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 allowedThe 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 recordA name with a CNAME can have no address records. Delete the one you no longer want first.
The zone file is too large to importThe 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: