Skip to content
Open console

Manage a DNS Zone as a File in Version Control

Clicking records into a console works until the day you need to know what changed, or rebuild a zone somewhere else. This tutorial keeps every record for a domain in one plain text file, applies it with a short script and the zcp CLI, and puts that file in version control beside the rest of your infrastructure.

By the end you have:

  • A records file that describes the whole zone
  • A script that applies it, and can show you what it would do first
  • A repeatable way to rebuild the zone from the file

Plan for about 20 minutes.

You need:

  • The zcp CLI installed and authenticated. See CLI installation.
  • A project and a domain you control.
  • Replace example.ca with a domain you control. Use your actual ingress addresses and nameservers.
  • A git repository to keep the file in.
  • jq, which the script uses to read the zone slug out of the CLI’s JSON output. Install it with brew install jq on macOS, or apt install jq on Debian and Ubuntu.

Read Known limitations before you start. A name and type hold one value today, which shapes what a file like this can express.

Use one line per record, with the content last so it can contain spaces:

NAME TYPE TTL PRIO CONTENT

Four rules make the file readable and easy to parse:

  • @ means the zone apex
  • - means the type takes no priority
  • Lines starting with # are comments
  • CONTENT is the final field, so it may contain spaces

Create dns/zones/example.ca.records. Group records by purpose and align the columns, so a reviewer can see the shape of the zone without parsing it:

# example.ca - zone records
#
# Columns are whitespace separated and CONTENT is the final field, so it may
# contain spaces. "@" is the zone apex and "-" means the type takes no priority.
#
# TXT values keep their double quotes, because the API rejects an unquoted value.
# NAME TYPE TTL PRIO CONTENT
# Website -----------------------------------------------------------------
@ A 300 - 198.51.100.10
www CNAME 300 - example.ca.
# YOW shared ingress -------------------------------------------------------
yow-edge A 300 - 192.0.2.10
status CNAME 300 - yow-edge.example.ca.
objects CNAME 300 - yow-edge.example.ca.
# YUL shared ingress -------------------------------------------------------
yul-edge A 300 - 198.51.100.20
api CNAME 300 - yul-edge.example.ca.
app CNAME 300 - yul-edge.example.ca.
# Mail routing ------------------------------------------------------------
@ MX 300 10 mail.example.ca.
# Mail authentication -----------------------------------------------------
@ TXT 300 - "v=spf1 include:mailprovider.ca -all"
_dmarc TXT 300 - "v=DMARC1; p=quarantine; rua=mailto:security@example.ca"

Keep the file next to your infrastructure code, not in a personal folder. Its value is that everyone can see it and changes arrive through review.

Use one A record as a regional ingress target only when its services share the same ingress and move together. Point each service at that target with one direct CNAME. Updating the A record then changes the destination for every alias in that regional group. Do not use CNAME chains or loops.

The example.ca file groups the yow-edge and yul-edge targets with the services they own.

A CNAME owner cannot hold other record data. Keep each regional target as a direct A record. Keep the zone apex as an A record because the apex also carries the SOA and NS records and may carry MX or TXT records. MX and NS targets must point directly to address records, not CNAMEs. See RFC 1034 section 3.6.2 and RFC 2181 sections 10.1 and 10.3.

A CNAME changes DNS lookup only. It does not create an HTTP redirect. Clients still send the original service name in TLS SNI and the HTTP Host header, so routing and certificates must cover every service name. A shared target also does not add automatic high availability or bandwidth.

TTL controls how long resolvers can cache each answer. Use a short TTL such as 300 while you migrate, then raise it when the records are stable. A shorter TTL does not invalidate existing caches. DNS cache behavior is defined in RFC 1034 section 2.3.

Create dns/apply-zone.sh. It creates the zone if it is missing, then walks the file line by line:

#!/usr/bin/env bash
# Apply a records file to a DNS zone with the zcp CLI.
#
# Usage: dns/apply-zone.sh <zone-name> <records-file> <project-slug> [--dry-run]
set -euo pipefail
ZONE="${1:?zone name required}"
FILE="${2:?records file required}"
PROJECT="${3:?project slug required}"
DRY_RUN="${4:-}"
[ -f "$FILE" ] || { echo "records file not found: $FILE" >&2; exit 1; }
case "$DRY_RUN" in
''|--dry-run) ;;
*) echo "unknown option: $DRY_RUN (expected --dry-run or nothing)" >&2; exit 2 ;;
esac
run() {
if [ "$DRY_RUN" = "--dry-run" ]; then
printf ' would run: zcp %s\n' "$*"
else
zcp "$@"
fi
}
slug_for_zone() {
zcp dns list --project "$PROJECT" --region default -o json 2>/dev/null \
| jq -r --arg z "$ZONE" '(.data // .)[] | select(.name == $z) | .slug' | head -1
}
SLUG="$(slug_for_zone)"
if [ -z "$SLUG" ]; then
echo "Creating zone $ZONE in project $PROJECT"
run dns create --name "$ZONE" --project "$PROJECT" --region default -y
[ "$DRY_RUN" = "--dry-run" ] || SLUG="$(slug_for_zone)"
fi
echo "Zone slug: ${SLUG:-<pending>}"
FAILED=0
# CONTENT is the last field and may contain spaces, so read the first four
# fields and let the remainder fall into CONTENT.
while read -r NAME TYPE TTL PRIO CONTENT; do
case "$NAME" in ''|\#*) continue ;; esac
ARGS=(dns record-create --domain "${SLUG:-PENDING}" --name "$NAME" --type "$TYPE" \
--ttl "$TTL" --content "$CONTENT" --project "$PROJECT" --region default -y)
[ "$PRIO" != "-" ] && ARGS+=(--priority "$PRIO")
echo "-> $NAME $TYPE $CONTENT"
if ! run "${ARGS[@]}"; then
echo " [FAIL] $NAME $TYPE" >&2
FAILED=1
fi
done < "$FILE"
if [ "$FAILED" -ne 0 ]; then
echo "One or more records failed. The zone is partially applied." >&2
exit 1
fi
echo "Done. Verify with: dig NS $ZONE +short"

Make it executable:

chmod +x dns/apply-zone.sh

Never point a new script at a live zone. Print the commands first:

dns/apply-zone.sh example.ca dns/zones/example.ca.records my-project --dry-run

Read the output. Every line should carry the name, type and content you expect, and MX lines should carry a priority. A quoted TXT value must still show its quotes.

dns/apply-zone.sh example.ca dns/zones/example.ca.records my-project

The script reports each record as it goes, and marks any that fail with [FAIL] rather than stopping. Read that output: a partial apply is the failure mode worth catching early.

Check what the name servers actually serve, rather than what the console shows:

zcp dns show <zone-slug> --project my-project --region default
for ns in ns1.dns.example.ca ns2.dns.example.ca; do
dig @"$ns" A example.ca +short
dig @"$ns" A yow-edge.example.ca +short
dig @"$ns" A yul-edge.example.ca +short
dig @"$ns" CNAME status.example.ca +short
dig @"$ns" CNAME api.example.ca +short
dig @"$ns" MX example.ca +short
dig @"$ns" TXT example.ca +short
done

Query both name servers. A record that answers on one and not the other means the zone has not finished propagating between them.

  • Commit the file, never a token. The file describes records. Credentials belong in your secret store and reach the script through the environment.
  • Short TTLs while you iterate. 300 seconds keeps mistakes cheap. Raise them once the zone is settled.
  • The file is the source of truth. When someone changes a record in the console, the file and the zone have diverged. Re-apply from the file, or update the file to match, and say which you did.
  • record-create replaces a matching RRset. It replaces an existing record set with the same name and type. It cannot migrate an A record to a CNAME because the old A record must be removed before the CNAME can exist at that owner. Create and verify the target A record first, then remove each old service A record and add its CNAME.
  • Do not manage multi-value TXT RRsets with this loop. Each independent TXT line for the same name replaces that name’s TXT RRset. Use support for a workflow that manages separate values in one RRset, or exclude externally managed multi-value RRsets from this file to avoid overwrite.
  • The script adds and updates, it does not prune. It applies the records the file names and nothing else. Deleting a line from the file does not remove that record from the zone. Remove it yourself with zcp dns record-delete, then delete the line, so the file and the zone stay in step.

Ask the ZCP docs

Type a question and pick an assistant. The assistant opens in a new tab and reads this page and the full ZCP documentation, so its answer comes from our docs.

Example questions

Ask with

Your question goes to the assistant you pick, under its own terms.