This is a DynDNS Service that can be used to update the IP address of a Fritz!Box to a Cloudflare DNS record. It supports updates of A- and AAAA-records. Every time your IP Address changes the service will be called by your Fritz!Box and the IP address will be updated.
✅ Key features: 1️⃣ Automatic Cloudflare DNS updates 🔄 2️⃣ Effortless One-Click deployment on Cloudflare or Vercel 🚀 3️⃣ Powered by Nuxt.js 🎨 4️⃣ Open-source for community collaboration 🌍 5️⃣ Supports both IPv4 and IPv6 6️⃣ Utilizes DNS A-Records and AAAA-Records instead of CNAME-Records (e.g., via Fritz.net) 7️⃣ Runs on the cloud in a serverless environment (vercel / cloudflare)
Prefer a guided version with a URL builder? Follow the steps at fritzdns.piscis.dev/setup.
Create a Cloudflare API token with read permissions for the scope Zone.Zone and edit permissions for the scope Zone.DNS.
Under Zone Resources → Include → Specific zone, pick the one zone your FRITZ!Box lives in. Then a leaked token can't touch your other domains.
Create an A- and AAAA-record for your domain in Cloudflare. The A-record should point to your IPv4 address and the AAAA-record should point to your IPv6 address.
Set the TTL of each Record to 1 minute. The Service will only update existing records. It will not delete or create new records, to avoid polluting your DNS zone in case of a configuration error.
The A-Record will be used to update your FRITZ!Box IPv4 address in Cloudflare DNS. To create this record use a random IP Address for example 192.0.2.1 and make sure the proxy is disabled and the TTL is set to 1 minute. After the configuration of your FRITZ!Box is done the record should be updated with your current IPv4 address.
The AAAA-Record will be used to update your FRITZ!Box IPv6 address in Cloudflare DNS. To create this record use a random IP Address for example 2001:0db8:85a3:0000:0000:8a2e:0370:7334 and make sure the proxy is disabled and the TTL is set to 1 minute. After the configuration of your FRITZ!Box is done the record should be updated with your current IPv6 address.
Deploy this project to your own Cloudflare account and use it as a service for your FRITZ!Box.
Cloudflare clones this repository into your GitHub account, lets you pick a Worker name, then builds and deploys it. Nothing to configure: the service holds no secrets, and the build detects Cloudflare on its own.
You end up with a Worker on https://<worker-name>.<your-subdomain>.workers.dev, so the
Update URL for your FRITZ!Box is:
https://<worker-name>.<your-subdomain>.workers.dev/api/fritz-dyndns/?token=<pass>&record=home.example.com&zone=example.com&ipv4=<ipaddr>&ipv6=<ip6addr>
To serve it from your own domain instead, set CF_ROUTE_PATTERN (see
Environment variables) and redeploy — the workers.dev
subdomain is switched off automatically once a custom domain is configured.
If the build fails while installing dependencies, Workers Builds picked its default pnpm rather than the version this repo pins. Set
PNPM_VERSIONto the version in thepackageManagerfield ofpackage.jsonunder Worker → Settings → Build → Variables and retry. The Node version needs no help — the builder reads.nvmrc.
Manual deploy (advanced)
Use this if you would rather deploy from your machine than through Workers Builds. Adjust
the environment variables in the .env file to match your Cloudflare account and routing
patterns.
cp .env.example .env
# Nuxt loads .env natively for `nuxt build`, so no extra tooling is needed.
NITRO_PRESET=cloudflare_module pnpm exec nuxt build
# wrangler does NOT read .env, so export the values for the deploy step.
set -a && . ./.env && set +a
pnpm exec wrangler --cwd .output deployThe
pnpm build:cfandpnpm deploy:cfshortcuts are for maintainers — they pull configuration from Phase (see Secrets) and will not work without access to that org. The commands above are the equivalent for a fork.
If necessary make some adjustments to nuxt.config.ts to match your Cloudflare account and routing patterns. (see the nitro preset config in the nuxt.config.ts file)
Deploy this project to your Vercel account and use it as a service for your FRITZ!Box.
If you don't want to host "FRITZ!Box Cloudflare DynDNS" yourself, feel free to use my cloud service. Just use this Update URL in your FRITZ!Box: This service is provided for free and without any warranty. Since this service is provided for free, I can't guarantee that it will always be available. If you need a more reliable service, please consider self-hosting.
Please note that this option is not secure due to the fact that the API token is visible in the URL. Although cloudflare REDACTS the API token in the logs it is still possible to get your API token by inspecting the network traffic. If you need a more secure option, please consider self-hosting.
https://fritzdns.piscis.dev/api/fritz-dyndns/?token=<pass>&record=home.example.com&zone=example.com&ipv4=<ipaddr>&ipv6=<ip6addr>
| FRITZ!Box Setting | Value | Description |
|---|---|---|
| Update URL | https://fritzdns.piscis.dev/api/fritz-dyndns/?token=<pass>&record=home.example.com&zone=example.com&ipv4=<ipaddr>&ipv6=<ip6addr> |
Replace the URL parameter record and zone with your domain name. If required you can omit either the ipv4 or ipv6 URL parameter. |
| Domain Name | home.example.com | The FQDN from the URL parameter record and zone. |
| Username | fritz | You can choose whatever value you want. |
| Password | ●●●●●● | The Cloudflare API token you’ve created earlier. |
Please note, if you self-host your service URL will be different — replace the host and keep
the rest of the URL as-is. A Cloudflare deployment made with the button above answers on
https://<worker-name>.<your-subdomain>.workers.dev/api/fritz-dyndns/?token=..., and a Vercel
deployment on https://some-random-name.vercel.app/api/fritz-dyndns/?token=....
The service publishes an interactive OpenAPI reference. Open
http://localhost:3000/api/ locally, or visit
fritzdns.piscis.dev/api. The raw spec is at
/api/spec.json.
A health endpoint is available at /api/health-check.
- Node.js 24 — see
.nvmrc(fnm use/nvm use) - pnpm 12 — pinned by the
packageManagerfield;corepack enablepicks it up automatically
Make sure to install the dependencies:
# Install dependencies via pnpm
pnpm installStart the development server on http://localhost:3000
# Run development server
pnpm devBuild the production application:
# Run production build
pnpm buildLocally preview production build:
# Run production build locally
pnpm previewCheck out the deployment documentation for more information.
None of these is a runtime secret. The Cloudflare API token this service uses to change
DNS is the one your FRITZ!Box sends as the token query parameter on every request — it
is never read from the environment and never stored.
| Variable | Consumed by | When | Required |
|---|---|---|---|
NITRO_PRESET |
nuxt.config.ts → nitro.preset |
build | manual builds only — hosts auto-detect |
CF_WORKER_NAME |
nuxt.config.ts → wrangler.name |
build | no — falls back to wrangler.jsonc |
CF_ROUTE_PATTERN |
nuxt.config.ts → wrangler.route.pattern |
build | no — unset ⇒ workers.dev |
CF_LOG_ENABLED |
nuxt.config.ts → wrangler.observability.enabled |
build | no |
WORKERS_CI |
Nitro's host auto-detection | build | set by Workers Builds itself |
CLOUDFLARE_API_TOKEN |
wrangler deploy |
deploy | manual Cloudflare deploy only |
CLOUDFLARE_ACCOUNT_ID |
wrangler deploy |
deploy | manual Cloudflare deploy only |
Every CF_* variable is consumed at build time, because nuxt.config.ts generates
.output/server/wrangler.json — so they must be set for build:cf, not only for
deploy:cf.
Leaving CF_ROUTE_PATTERN empty is what a one-click deploy does: no custom-domain route is
emitted and the workers.dev subdomain is enabled instead, so the Worker is still reachable.
Setting it flips both back. Likewise an empty CF_WORKER_NAME leaves the name to
wrangler.jsonc.
NITRO_PRESET is only needed when you build for a host by hand. Unset, nuxt.config.ts pins
no preset at all and Nitro detects the host it is running on — the vercel preset on Vercel,
cloudflare_module under Workers Builds, which exports WORKERS_CI for exactly that purpose.
That is what lets both one-click buttons work without configuration.
The route is configured as a Cloudflare custom domain, which needs only a pattern;
Cloudflare infers the zone from the hostname. No CF_ROUTE_ZONE_NAME is required.
wrangler.jsonc is checked in only so the Deploy to Cloudflare button
recognises this repo as a Workers app and has a name to rewrite. No deploy uses it: nuxt build generates .output/server/wrangler.json from nuxt.config.ts and writes a
.wrangler/deploy/config.json redirect next to it, which wrangler follows in preference to any
root config — it prints Using redirected Wrangler configuration when it does. Nitro also
merges wrangler.jsonc underneath the nitro.cloudflare.wrangler block, so anything set in
both is won by nuxt.config.ts, and main/assets are always re-derived from the build (which
is why each Cloudflare build logs two is overridden and will be ignored warnings).
Editing wrangler.jsonc therefore has no effect on staging or production — change
nuxt.config.ts instead.
This project uses Cloudflare API tokens for two unrelated purposes, and they need different permissions. Mixing them up produces a confusing failure, because the wrong token still authenticates — it just cannot do the job.
| DynDNS token | Deploy token (CLOUDFLARE_API_TOKEN) |
|
|---|---|---|
| Sent by | your FRITZ!Box, as ?token= |
wrangler deploy, from CI |
| Used to | update one A/AAAA record | upload and route a Worker |
| Permissions | Zone.Zone: Read, Zone.DNS: Edit |
see below |
A deploy token needs, at minimum:
- Account → Workers Scripts: Edit — upload the Worker
- Account → Account Settings: Read — resolve the account
- Zone → Workers Routes: Edit — attach the custom-domain route
- Zone → DNS: Edit — a custom domain creates a DNS record
- Zone → Zone: Read
A DynDNS-scoped token used for deployment fails with
Authentication error [code: 10000] on /accounts/<id>/workers/services/<name>, after
wrangler has already printed the account name — so the log reads as if authentication
succeeded. Check the token's permissions rather than its value.
Build and deploy configuration lives in Phase, on a self-hosted
instance. .phase.json links this repo to the app; it contains only
opaque identifiers and is safe to commit. Maintainers: ask for the host URL and an
invite to the org.
brew install phase # or https://pkg.phase.dev/install.sh
export PHASE_HOST=https://phase.example.com # the self-hosted host; add to your shell profile
phase auth
phase secrets list # sanity check: the Development set
pnpm dev # runs through `phase run`phase run spawns the child via sh -c, which does not have node_modules/.bin on
PATH — use pnpm exec <bin> inside it. Quote the whole command when it takes flags of
its own (phase run "pnpm exec wrangler --cwd .output deploy"), otherwise phase parses
them as its own.
Contributors without access to that Phase org should follow
Option 1 and use a .env instead.
Two Cloudflare Workers, one per stage. Which one a push targets is derived from the branch, and the matching Phase environment supplies the Worker name and route — so the two can never overwrite each other.
| Push to | GitHub Environment | Phase environment | Worker |
|---|---|---|---|
main |
staging |
Staging |
fritzbox-cf-dyndns-stage on stage-fritzdns.piscis.dev |
released |
production |
Production |
fritzbox-cf-dyndns on fritzdns.piscis.dev |
Pull requests run lint, typecheck and test only — they never deploy.
Both Workers use Cloudflare Smart Placement
(placement.mode: 'smart') and observability with full head sampling. That is set in
nuxt.config.ts and baked into the generated
.output/server/wrangler.json at build time.
Release path: merge into main (deploys staging) → merge main into released
(deploys production).
The suite is split into three Vitest projects:
| Project | Location | Environment | Covers |
|---|---|---|---|
unit |
tests/unit/ |
node | Everything under server/, with the Cloudflare SDK mocked — fast, never hits the network |
nuxt |
tests/nuxt/ |
nuxt | Component rendering under app/ |
e2e |
tests/e2e/ |
node | A real Nuxt server booted via @nuxt/test-utils/e2e |
# Run everything once
pnpm test
# Watch mode for the local loop
pnpm test:watch
# A single project — `unit` runs in well under a second
pnpm test:unit
pnpm test:e2e
# With a coverage report (text + lcov + html in ./coverage)
pnpm test:coverageCoverage is measured over server/** and gated in CI. Note that --coverage must
be passed as a flag rather than enabled in config: @nuxt/test-utils watches for it
to turn on client sourcemaps, without which .vue coverage is inaccurate.
The DynDNS happy path is deliberately not covered end-to-end. The handler builds a real Cloudflare client from the caller-supplied token, so an e2e request would reach
api.cloudflare.com. Only the validation failures — which are rejected before the client is constructed — are exercised over HTTP.
- Look at the Nuxt 4 documentation to learn more.
CLAUDE.md is the entry point: the conventions that are not obvious
from the code. AGENTS.md is a symlink to it for tools that read that name.
Skills live in .agents/skills/ (the canonical, vendor-neutral
location). .claude/skills/<name> are committed relative symlinks into it, so the
same set is available to Claude Code and to any tool that reads .agents/.
Every skill is pinned in skills-lock.json — manage them with
npx skills@latest add|update|remove, never by editing the lock file or the skill
folders. The repo-specific orpc-api skill is authored in
piscis/agent-skills; change it there, then
run npx skills@latest update -p.
On Windows, git needs
core.symlinks trueand a fresh checkout, or the symlinks (skills andAGENTS.md) materialise as plain text files.
Two MCP servers are registered per project in .mcp.json for Claude
Code. They point at Nuxt's hosted documentation endpoints — https://nuxt.com/mcp and
https://ui.nuxt.com/mcp — so an agent can look up current Nuxt APIs instead of
recalling them from training data. They carry no credentials.
Claude Code asks for approval the first time it sees a project
.mcp.json. Answer yes once per clone —claude mcp reset-project-choicesclears the answer.
Fork, branch, and open a PR against main. Please use
Conventional Commits — CHANGELOG.md is
generated from them. Run pnpm lint:fix && pnpm typecheck && pnpm test before
opening the PR, and read CLAUDE.md first.
Security issues should go through SECURITY.md, not a public issue.
Original port for Vercel from: https://github.com/L480/cloudflare-dyndns




