Short Note: Sync Cloudflare DNS targeting Caddy within Nomad
... with just a tiny bit of Python

Search for a command to run...
... with just a tiny bit of Python

No comments yet. Be the first to comment.
I have been playing around with the https://ergaster.org/posts/2025/07/28-direnv-bitwarden-integration/. It is a necessary read before you read this note since it explains the problem in quite nice details and builds the proposed solution step by ste...
The time has come: my homelab, for which I have been writing how-tos and occasional status updates on this blog had / has been serving me very well, but k8s ecosystem has reached maturity levels and broad acceptance and my Nomad cluster raises more q...

I figured that over night one of my PCs is spending time on “something”. System logs don’t show anything, app logs neither. So, I spent like 30min and made this tiny script which might be useful for you as well. I connect to my host using SSH, and I ...
I figured out after full day of work a very peculiar behavior in Spring Boot and wanted to write this one down since it was a pretty annoying one. Let’s say you are: using Spring Boot and need to make HTTP client connections (using RestTemplate or R...
In case you use some tools (like goreleaser) that depend on running in the case of tagged commits only, but you wish at the same time not to bother with manually tagging your commits but just to push to master… I found a nice & easy way to auto-tag c...
Let's say, for the sake of this short note, that you have:
Well, now, here is how I synchronize DNS records on Cloudflare based on the service discovery in Nomad / Consul using some shortcuts and a very small Python script...
Result of this process is that if I have a service with name "chronograf" my DNS record chronograf.mycooldomain.com gets updated.
First, the nomad job definition:
job "internal-proxy" {
datacenters = ["DC1"]
type = "service"
constraint {
attribute = "${attr.unique.hostname}"
value = "pluto"
}
group "main" {
ephemeral_disk {
migrate = true
size = 150
sticky = true
}
task "caddy" {
# define the Caddy task. not relevant for this short note,
#perhaps a nice topic for another one
}
task "syncer" {
driver = "docker"
config {
image = "python:3.10.4-slim-bullseye"
volumes = [
"local/:/etc/dns-sync"
]
command = "python3"
args = ["/etc/dns-sync/syncer.py"]
}
env {
ZONE_ID_FILE = "/etc/dns-sync/cf_zone_id"
CF_API_TOKEN_FILE = "/etc/dns-sync/cf_api_key"
DNS_MAPPING_FILE = "/etc/dns-sync/records.txt"
}
template {
data = <<EOF
[[ fileContents "syncer.py" ]]
EOF
destination = "local/syncer.py"
}
template {
data = "{{ key \"cloudFlare/zoneId\" }}"
destination = "local/cf_zone_id"
change_mode = "signal"
change_signal = "SIGUSR1"
}
template {
data = "{{ key \"cloudFlare/cfApi\" }}"
destination = "local/cf_api_key"
change_mode = "signal"
change_signal = "SIGUSR1"
}
template {
data = <<EOF
{{range $tag, $services := services | byTag}}{{ if eq $tag "expose-internal" }}{{range $services}}{{ .Name }}.{{ key "cloudFlare/domain" }}|{{ with node "pluto" }}{{ .Node.Address }}{{ end }}
{{end}}{{end}}{{end}}
EOF
destination = "local/records.txt"
change_mode = "signal"
change_signal = "SIGUSR1"
}
resources {
cpu = 100
memory = 100
}
}
}
}
Some assumptions I took in the job above:
expose-internalpluto,Now, the script that does the syncing Nomad -> Cloudflare using pure Python 3.
import argparse
import json
import logging
import os
import signal
import sys
import urllib.parse
import urllib.request
from typing import Dict, List, Optional
def read_secret_multi(secret_name_file: str) -> List[str]:
with open(read_env_or_fail(secret_name_file), 'r') as env_file_file:
return [x.strip() for x in env_file_file.readlines()]
def read_secret(secret_name_file: str) -> str:
with open(read_env_or_fail(secret_name_file), 'r') as env_file_file:
return env_file_file.readline()
def read_env_or_fail(secret_name_file):
env_file = os.getenv(secret_name_file, None)
if env_file is None:
logging.error(f"Environment variable {secret_name_file} was not present, giving up")
sys.exit(1)
return env_file
class Syncer:
def __init__(self, zone_id: str, cf_api_token: str, dns_records: List[str]):
self.zone_id = zone_id
self.cf_api_token = cf_api_token
self.mappings = {x.split('|')[0]: x.split('|')[1] for x in dns_records if x}
def cf_api(self, path: str, method: Optional[str] = 'GET', data: Optional[Dict] = None):
req = urllib.request.Request(
url=f'https://api.cloudflare.com/client/v4/{path}',
headers={
"Authorization": f"Bearer {self.cf_api_token}",
"Content-Type": "application/json",
},
data=json.dumps(data).encode('utf-8') if data else None,
method=method,
)
with urllib.request.urlopen(req) as f:
return json.loads(f.read().decode('utf-8'))
def sync(self):
logging.info("Syncing records...")
records = self.cf_api(f'zones/{self.zone_id}/dns_records')['result']
cf_records = {rec['name']: rec for rec in records}
for our_mapping, target in self.mappings.items():
cf_mapping: Dict = cf_records.get(our_mapping, None)
if cf_mapping:
if cf_mapping['content'] == target and cf_mapping['type'] == 'A':
logging.info(f"Identical mapping on CF for: {our_mapping}, ignoring")
else:
logging.info(f"Updating existing mapping on CF for: {our_mapping} -> {target}")
self.cf_api(path=f"zones/{self.zone_id}/dns_records/{cf_mapping['id']}", method='PATCH',
data=self.make_cf_record_dto(our_mapping, target))
else:
logging.info(f"Adding new mapping on CF for: {our_mapping} -> {target}")
self.cf_api(path=f'zones/{self.zone_id}/dns_records', method='POST',
data=self.make_cf_record_dto(our_mapping, target))
@staticmethod
def make_cf_record_dto(our_mapping, target):
return {
"type": "A",
"name": our_mapping,
"content": target,
"proxied": False
}
def run():
Syncer(
zone_id=read_secret("ZONE_ID_FILE"),
cf_api_token=read_secret("CF_API_TOKEN_FILE"),
dns_records=read_secret_multi("DNS_MAPPING_FILE"),
).sync()
if __name__ == '__main__':
parser = argparse.ArgumentParser(prog='syncer.py', description="Automation script for Cloudflare record syncing")
parser.add_argument('--debug', default=False, required=False, action='store_true', dest="debug",
help='debug flag')
args = parser.parse_args()
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
else:
logging.getLogger().setLevel(logging.INFO)
# initial run
run()
# subsequent run
signal.signal(signal.SIGUSR1, lambda sig, frame: run())
# exit gracefully
signal.signal(signal.SIGINT, lambda sig, frame: sys.exit(0))
signal.signal(signal.SIGTERM, lambda sig, frame: sys.exit(0))
while True:
logging.info('Waiting for signals...')
signal.pause()