Dynamic DNS sync with Cloudflare
If you ever get yourself a Raspberry Pi, NAS, or turn an old computer into a server, you start to think about accessing it from outside of your network. This usually requires you to open a port on your router and forward to the IP of your device. Some of the services you might want to access could be a Wireguard/OpenVPN VPN or some other service.
One note about VPN, there is a way to get into your network without opening a port. You can use https://tailscale.com/ to open a tunnel to your network and access it with better security. However, using Tailscale doesn’t work with all use cases. In this blog post, I go over how to automatically sync your public IP with Cloudflare and always have your Dynamic DNS domain point to the correct IP address.
Also those of you who have experience with Cloudflare and using their proxy, you will know that Cloudflare only will proxy certain ports and the port you have for your VPN or other service might not work. If you need to have a dedicate subdomain for VPN, you can modify the code below and have it update two records. That's how I have setup.
Another note on syncing IPs with cloudflare: there are already some tools that can do this for you. I went this path, because I wanted to understand how it works under the hood and it was fun! If you are interested in writing some code to automate some tasks, this post is for you!
Assumptions
- Your domain is on Cloudflare (or you are willing to change your domain provider to Cloudflare)
- You can run a docker container in your server
- You have
deno
installed on your computer - You are comfortable writing some TypeScript code to check IP and update it using Cloudflare APIs
Get started
Cloudflare Domain Settings
Create an A record for your Dynamic DNS subdomain or full domain for your public IP.
You can skip this step, if you already have an A record for your public IP.
Cloudflare API Token
Go to Account API Tokens > Create Token and select the Edit Zone DNS template.
You want to only allow Zone DNS Edit/Read permissions.
You also want to limit access to the specific zones you want to work with. In this case, you want to only work with the zone that will have your Dynamic DNS IP A Record. This will limit the blast radius of the API token.
Then you need to Continue to Summary and Create Token.
Store the API key securely while you build the rest.
Cloudflare Worker
You can either use the Cloudflare CLI or the Dashboard to create your worker. I started out by doing this on the Dashboard. Later, I generated a Cloudflare Worker project with CLI and pushed it to a Gitlab repo. Then I connected the existing Worker to the repo. This added support for automatic deployments on push to the main branch.
Create a hello world worker and get it deployed to Cloudflare workers. This will set up all the basics.
You might be asking, why are we using Cloudflare Workers? Because I preferred to keep the Cloudflare API related work within Cloudflare’s walled garden. API key, ideally, never leaves the platform. You will be directly making requests against the Cloudflare Worker and the worker will take care of interacting with Cloudflare API.
Architecture
Code
We will be using TypeScript and Deno.
Cloudflare Worker
You need to make sure you have some secrets set:
-
WORKER_API_KEY
: API key to authenticate with the Cloudflare worker -
API_KEY
: Cloudflare API key you created earlier -
DNS_ZONE_ID
: Cloudflare Zone ID. You can get it by making a GET request to https://api.cloudflare.com/client/v4/zones with "Authorization: BearerAPI_KEY
" in the headers. Be sure to replaceAPI_KEY
with your actual API key. Grab theid
of the zone you want to update. -
DNS_RECORD_ID
: This is the ID of the Dynamic DNS record. You can find it by sending a GET request to https://api.cloudflare.com/client/v4/zones/DNS_ZONE_ID
/dns_records. It will return an array of DNS records. Grab theid
of the record you want to update.
wrangler.toml
: These are the environment variables for the worker
[vars]
DDNS_NAME="ddns.YOUR_DOMAIN.com"
DDNS_COMMENT="DDNS for my server"
DDNS_PROXIED=true
index.ts
import { getIp } from "./get-ip";
import { updateDnsRecord } from "./update-dns-record";
export default {
async fetch(request, env): Promise<Response> {
const WORKER_API_KEY = env.WORKER_API_KEY
const API_KEY = request.headers.get('Authorization')
if (API_KEY !== WORKER_API_KEY) {
return new Response('Not Authorized', { status: 403 })
}
if (request.method === 'PUT') {
const { ip } = await request.json() as { ip: string }
return updateDnsRecord(ip, env)
} else {
return getIp(env)
}
},
} satisfies ExportedHandler<Env>;
get-ip.ts
export async function getIp(env: Env) {
const options = {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.API_KEY}`,
}
};
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${env.DNS_ZONE_ID}/dns_records/${env.DNS_RECORD_ID}`,
options
)
const { result: { content: ip } } = await response.json() as { result: { content: string } }
return new Response(JSON.stringify({ ip }), { headers: { 'Content-Type': 'application/json' } });
} catch (err) {
console.error(err)
return new Response(JSON.stringify(err))
}
}
update-dns-record.ts
export async function updateDnsRecord(ip: string, env: Env) {
if (ip) {
// snake_case because Cloudflare API uses snake_case :)
const zone_id = env.DNS_ZONE_ID
const id = env.DNS_RECORD_ID
const name = env.DDNS_NAME
const comment = env.DDNS_COMMENT
const proxied = env.DDNS_PROXIED
try {
const update = {
id,
zone_id,
name,
comment,
type: "A",
content: ip,
proxied
}
const body = JSON.stringify(update);
const options = {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.API_KEY}`,
},
body,
};
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zone_id}/dns_records/${update.id}`,
options
)
const responseBody = await response.json()
return new Response(JSON.stringify(responseBody), { headers: { 'Content-Type': 'application/json' } });
} catch (err) {
console.error(err)
return new Response(JSON.stringify(err))
}
}
return new Response(JSON.stringify({ message: 'bad request' }));
}
Docker Container
You can get started by installing deno
on your machine and generate a new deno app with deno init ddns_sync
.
I used Deno in this example, because it is so easy to get started and have a running code without installing bunch of stuff. Deno is so awesome for that. I installed zero packages to achieve cron
and HTTP requests.
Dockerfile
FROM denoland/deno:alpine-2.1.4
WORKDIR /app
ADD *.ts .
ADD *.json .
RUN deno cache main.ts
ENTRYPOINT ["deno", "task", "start"]
main.ts
Deno.cron("Sample cron job", "*/1 * * * *", async () => {
const currentDateTime = new Date().toISOString()
try {
const [publicIp, ddnsIp] = await Promise.all([
getPublicIp(),
getExistingDdnsIp(),
]);
if (publicIp === ddnsIp) {
console.log(currentDateTime, "IP address did not change");
} else {
const updatedIp = await updateDdnsIp(publicIp);
console.log(
currentDateTime,
updatedIp === publicIp
? "DDNS IP has been updated"
: "DDNS failed to update"
);
}
} catch (error) {
console.error(currentDateTime, 'Error: ', error)
}
});
async function getPublicIp(): Promise<string> {
const response = await fetch("https://api.ipify.org/?format=json");
const body = await response.json();
return body.ip;
}
async function getExistingDdnsIp(): Promise<string> {
const response = await fetch(Deno.env.get("WORKER_URL"), {
headers: { Authorization: Deno.env.get("API_KEY") },
});
const body = await response.json();
return body.ip;
}
async function updateDdnsIp(ip: string) {
const options = {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: Deno.env.get("API_KEY"),
},
body: JSON.stringify({ ip }),
};
const response = await fetch(Deno.env.get("WORKER_URL"), options);
const body = await response.json();
return body.result.content;
}
deno.json
{
"tasks": {
"dev": "deno run --unstable-cron --allow-net --allow-env --watch main.ts",
"start": "deno run --unstable-cron --allow-env --allow-net main.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1"
}
}
You can build your docker image and run it on your server like this:
docker build -t ddns-sync .
docker run -d --name ddns-sync \
-e API_KEY=WORKER_API_KEY \
-e WORKER_URL="http://ddns.YOUR_USERNAME.workers.dev" \
--restart unless-stopped ddns-sync
That’s it. That should have you check your public IP every minute and compare it against the DNS record. If your public IP changes, this will automatically update it. You will get about a minute of outage if this happens.
I recently had to do this and decided to document it as a blog post. I hope someone finds this helpful!
The code quality is at the level of a homelab project, so there is definitely room for improvement. Let me know if you have suggestions on how to improve this.
If you have any questions or corrections, definitely leave a comment. I am more than happy to answer them.
Top comments (0)