This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.

KarlDNS

Self-hosted Dynamic DNS (DynDNS / DDNS) for FRITZ!Box, OpenWrt, pfSense, OPNsense, UniFi, MikroTik, Linux cron jobs, and any router that can call a custom DDNS update URL.

KarlDNS is the friendly name for karldns: a small FastAPI Dynamic DNS provider you can run on your own VPS. It gives users accountless private update URLs, FRITZ!Box-ready DynDNS fields, public A/AAAA records through Cloudflare or RFC 2136, generated hostnames that work as CNAME targets, and verified custom domains with DNS TXT ownership checks.

If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case.

KarlDNS self-hosted DynDNS home screen

Why KarlDNS?

  • Self-hosted Dynamic DNS provider for home labs, small ISPs, communities, and private infrastructure.
  • FRITZ!Box compatible: copy the generated Update-URL, Domainnamen, Benutzername, and Kennwort straight into the German FRITZ!Box DynDNS form.
  • CNAME-friendly hostnames: users can use the generated hostname directly, or point home.example.com to it with a CNAME.
  • No registration required: provider hostnames and custom-domain setup both use private magic links.
  • Custom domain support: users enter a domain, save the private claim link, add a DNS TXT record, then press a button to verify ownership before credentials are issued.
  • Real DNS publishing through Cloudflare or RFC 2136 with A and AAAA support.
  • Small-server friendly: FastAPI, SQLite WAL, simple Docker deployment, and no heavy background platform.
  • API first: OpenAPI, Swagger UI, ReDoc, and JSON endpoints under /api/v1.

Screenshots

Create Router Credentials

Generated FRITZ!Box DynDNS credentials

Verify a Custom Domain

DNS TXT verification flow for custom domains

Logo And Social Preview

KarlDNS ships web-ready brand assets so self-hosted instances and shared links look clean out of the box:

Asset Purpose
router_dyndns/assets/logo-nav.webp Small navigation logo.
router_dyndns/assets/logo.webp Optimized homepage logo.
router_dyndns/assets/logo-192.png and logo-512.png PWA, app icon, and manifest icons.
router_dyndns/assets/favicon.ico, favicon-16x16.png, favicon-32x32.png, apple-touch-icon.png Browser and mobile icons.
router_dyndns/assets/social-preview.png Open Graph and Twitter large-card preview for reliable crawlers.
router_dyndns/assets/social-preview.webp Optimized WebP social preview for modern clients.

What It Does

KarlDNS lets you host your own managed DynDNS service:

  1. A user opens your KarlDNS URL.
  2. They generate a random hostname like a1b2c3d4.karldns.de, or verify their own domain with a TXT record.
  3. KarlDNS shows the exact router settings for the DynDNS form.
  4. The router periodically calls the update URL with its current public IPv4/IPv6 address.
  5. KarlDNS publishes the matching DNS records through your configured DNS backend.
  6. If the user wants their own DNS name, they can create a CNAME to the generated hostname.

That means users always have a current hostname for VPN, remote access, self-hosted apps, home servers, NAS devices, and lab networks.

Best Use Cases

  • Self-hosted DynDNS for FRITZ!Box routers.
  • Free DDNS service for friends, customers, a community, or a home lab.
  • Dynamic DNS for IPv4 and IPv6 home internet connections.
  • Custom domain DDNS with DNS TXT verification.
  • CNAME target hostnames for users who want home.example.com without sharing DNS API access.
  • Lightweight Cloudflare DDNS provider with a web UI.
  • RFC 2136 / TSIG DynDNS frontend for BIND, Knot, PowerDNS, or compatible DNS servers.

Feature Overview

Area Support
Router update URLs FRITZ!Box custom provider URL, /u/<slug>, and /nic/update compatibility
DNS records A and AAAA
DNS providers Cloudflare API and RFC 2136 dynamic DNS
Generated hostnames Random provider-owned names that can be used directly or as CNAME targets
Custom domains TXT challenge verification before hostname creation
Persistence SQLite with WAL mode
API FastAPI, OpenAPI, Swagger UI, ReDoc
Auth model No user accounts. Private bearer links for hostnames and custom-domain claims
Operations Rate limiting, admin audit views, update logs, cleanup jobs, Docker, Caddy example

Quick Start

git clone https://github.com/StasonJatham/karldns.de.git
cd karldns.de
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

Run locally:

export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
export DDNS_ADMIN_PATH='/admin'
export DDNS_PUBLIC_BASE_URL='http://localhost:8080'
export DDNS_HOSTNAME_SUFFIX='karldns.de'
export DDNS_DATABASE='./ddns.sqlite3'

karldns serve --host 127.0.0.1 --port 8080

Open:

  • Web UI: http://localhost:8080/
  • Admin UI: http://localhost:8080/admin
  • Swagger UI: http://localhost:8080/docs
  • ReDoc: http://localhost:8080/redoc

Admin auth uses HTTP Basic auth. The username can be any value; the password is DDNS_ADMIN_PASSWORD. DDNS_SHARED_SECRET, when configured for legacy updater clients, is not accepted for admin routes.

The public OpenAPI schema documents the developer API under /api/v1. Admin/operator endpoints are intentionally omitted from Swagger UI and ReDoc.

Production Setup

For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend:

export DDNS_ADMIN_PASSWORD='replace-with-a-long-random-value'
export DDNS_ADMIN_PATH='/admin'
export DDNS_DASHBOARD_COOKIE_SECRET='replace-with-a-32-plus-character-random-value'
export DDNS_PUBLIC_BASE_URL='https://karldns.de'
export DDNS_HOSTNAME_SUFFIX='karldns.de'
export DDNS_DATABASE='/data/ddns.sqlite3'
export DDNS_REQUIRE_DNS_PROVIDER=1
export DDNS_DNS_PROVIDER='cloudflare'
export DDNS_CLOUDFLARE_API_TOKEN='replace-with-cloudflare-token'
export DDNS_CLOUDFLARE_ZONE_ID='replace-with-cloudflare-zone-id'
export DDNS_TTL=60

That is the normal Cloudflare setup. Use a least-privilege Cloudflare API token limited to the zone KarlDNS should update. DDNS_TRUSTED_HOSTS is optional because KarlDNS automatically trusts the host in DDNS_PUBLIC_BASE_URL; set it only when the same instance should answer for additional public hostnames.

RFC 2136 DNS Backend

Use this instead of the Cloudflare variables when your DNS server supports RFC 2136 dynamic updates:

export DDNS_DNS_PROVIDER='rfc2136'
export DDNS_RFC2136_SERVER='127.0.0.1'
export DDNS_RFC2136_ZONE='karldns.de'
export DDNS_RFC2136_KEY_NAME='ddns-key'
export DDNS_RFC2136_KEY_SECRET='replace-with-tsig-secret'
export DDNS_TTL=60

DDNS_DNS_ZONES is the allowlist of DNS zones this service may publish. A custom hostname must be inside one of these zones.

FRITZ!Box DynDNS Setup

Create a hostname in the web UI. For the normal accountless flow, the generated Update-URL already contains the secret update slug. Put that URL into the router and use the generated hostname as Domainnamen.

KarlDNS also shows optional compatibility fields for routers that insist on separate credentials:

  • Update-URL
  • Domainnamen
  • CNAME target
  • Benutzername (optional)
  • Kennwort (optional)

Copy the router password and full update URL when they are generated or rotated. They are intentionally not recoverable from the dashboard later.

The generated FRITZ!Box URL uses the native placeholders:

https://karldns.de/u/<random-update-slug>?myip=<ipaddr>&myipv6=<ip6addr>

FRITZ!Box replaces <ipaddr> and <ip6addr> with the current WAN IPv4/IPv6 address and sends an HTTP GET request to that URL whenever it detects an address change. KarlDNS returns DynDNS-style responses such as good 203.0.113.10, nochg 203.0.113.10, badauth, or nohost.

This URL is enough for routers that only expose a single update URL field and do not send separate credentials. KarlDNS also shows a Full update URL that embeds hostname, username, and password as query parameters for clients that require credential-style DynDNS URLs but do not expose separate credential fields. Treat both URLs as secrets.

The compatibility endpoint is also available:

https://karldns.de/nic/update?hostname=<domain>&myip=<ipaddr>&myipv6=<ip6addr>&username=<username>&password=<pass>

Prefer /u/<slug> for managed service use because the router request cannot change the hostname.

Manual Updates And Automation

Every generated hostname can be updated with curl, cron, a shell script, Home Assistant, OpenWrt, pfSense, or any client that can send an HTTP GET request:

# Explicit IPv4 update
curl -sS 'https://karldns.de/u/<random-update-slug>?myip=203.0.113.10'

# Explicit IPv6 update
curl -sS 'https://karldns.de/u/<random-update-slug>?myipv6=2001:db8::10'

# Use the request source IP
curl -sS 'https://karldns.de/u/<random-update-slug>'

The /nic/update compatibility endpoint also works for clients that expect hostname and credentials in the URL:

curl -sS 'https://karldns.de/nic/update?hostname=home.example.net&username=router&password=<password>&myip=203.0.113.10'

Linux Cron Installer

For Linux servers, Proxmox hosts, NAS boxes, and small home-lab machines that are always on, KarlDNS ships a cron updater. It creates a hostname if you do not pass one, installs a tiny updater script, schedules it with cron, runs the first update, and prints the hostname plus private dashboard URL.

System-wide install:

curl -fsSL https://karldns.de/install.sh | sudo sh

The same script is hosted in GitHub and can be used directly:

curl -fsSL https://raw.githubusercontent.com/StasonJatham/karldns.de/main/scripts/install-linux.sh | sudo sh

User-level install without sudo:

curl -fsSL https://karldns.de/install.sh | sh -s -- --mode user

Use an existing KarlDNS update URL:

curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
  --update-url 'https://karldns.de/u/<random-update-slug>' \
  --hostname 'home.karldns.de'

Common options:

--every 5          # cron interval in minutes, default 5
--ipv4 auto        # auto, off, or a static IPv4
--ipv6 auto        # auto, off, or a static IPv6
--no-cron          # install files only
--no-run           # skip the first update

The updater discovers the public address through https://karldns.de/ip and sends it to the private update URL. If explicit discovery fails, it falls back to calling the update URL without IP parameters so KarlDNS can use the request source address. Re-running the installer is safe: it replaces the managed cron block and keeps unrelated cron entries intact.

Installed files in system mode:

/usr/local/bin/karldns-update
/etc/karldns/updater.env
/etc/cron.d/karldns-updater
/var/cache/karldns/last-ip

Installed files in user mode:

~/.local/bin/karldns-update
~/.config/karldns/updater.env
~/.cache/karldns/last-ip

updater.env is written with mode 600 because it contains the private update URL and dashboard URL. The hostname itself is not secret. The update URL and dashboard URL are secrets.

Uninstall, keeping the private config by default:

curl -fsSL https://karldns.de/uninstall.sh | sudo sh

Remove the private config and last-IP cache too:

curl -fsSL https://karldns.de/uninstall.sh | sudo sh -s -- --delete-config

For Proxmox, install on the host if the host owns the public connection. If you install inside an LXC that has no IPv6 route, use --ipv6 off so the cron job only updates A records:

curl -fsSL https://karldns.de/install.sh | sudo sh -s -- --ipv6 off

Use Your Own Subdomain With CNAME

Most users do not need domain verification. Create a KarlDNS hostname first, then create a CNAME at your DNS provider:

home.example.com.  CNAME  a1b2c3d4.karldns.de.

Put the KarlDNS Update-URL, Domainnamen, Benutzername, and Kennwort into the router. The router updates a1b2c3d4.karldns.de; home.example.com follows through the CNAME.

Use a subdomain such as home.example.com. Root/apex domains like example.com usually cannot be plain CNAME records unless your DNS provider supports CNAME flattening or ALIAS/ANAME records.

The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, router password, private dashboard URL, and domain claim link are the secrets. Dashboard forms use CSRF tokens, and dashboard password unlock cookies last 24 hours by default unless the user chooses the 30-day stay-unlocked option.

Custom Domain Flow

This flow is only needed when KarlDNS should publish A/AAAA records directly inside a user-owned domain instead of using a CNAME target.

Custom-domain publishing is intentionally simple:

  1. Enter the domain.
  2. Save the private claim link.
  3. Add the generated TXT record at your DNS provider.
  4. Press I added it, check DNS.
  5. Create router credentials after the TXT record verifies.

KarlDNS stores the domain claim in SQLite and only creates hostnames below verified, publishable zones. Claims created from a hostname dashboard are bound to that dashboard owner, so another dashboard cannot verify or use them. The private claim link is the bearer credential for returning later after DNS propagation.

HTTP API

The JSON API is available under /api/v1.

Endpoint Purpose
POST /api/v1/hostnames/magic Create an anonymous provider-owned hostname.
GET /api/v1/management/{management_slug} Inspect a hostname by private management link.
DELETE /api/v1/management/{management_slug} Delete a hostname and its DNS records.
POST /api/v1/domains/challenges Create a TXT challenge and private claim secret.
POST /api/v1/domains/verify Verify a TXT challenge with the claim secret.
POST /api/v1/hostnames/custom Create a custom hostname below a verified domain with the claim secret.
GET /api/v1/updates/{update_slug} JSON update endpoint for routers or automation.

Example:

curl -sS https://karldns.de/api/v1/hostnames/magic \
  -H 'content-type: application/json' \
  -d '{"username":"home-router"}'

Docker

Copy the example environment file and replace every placeholder:

cp .env.example .env
docker compose up -d --build

The example environment is intentionally short: admin password/path, dashboard cookie signing secret, public URL, managed hostname suffix, Cloudflare token, Cloudflare zone ID, and the SQLite path. The app creates the SQLite parent directory and all tables on first run. Re-running the container or starting the app twice is idempotent; schema creation uses CREATE TABLE IF NOT EXISTS, and the cleanup scheduler keeps a single durable task row.

The image and compose file default DDNS_DATABASE to /data/ddns.sqlite3, and the compose volume persists /data.

The default container command runs one Uvicorn worker. Keep that deployment shape unless you have tested multi-worker update behavior for your traffic pattern; rate-limit counters and per-host update locks are process-local.

Caddyfile.example contains a minimal HTTPS reverse proxy example.

Operations Checklist

  • Run behind HTTPS.
  • Keep .env out of git.
  • Use a long random value for DDNS_ADMIN_PASSWORD.
  • Use a separate long random value for DDNS_DASHBOARD_COOKIE_SECRET on public instances.
  • Change DDNS_ADMIN_PATH if you do not want the operator UI at /admin.
  • Use a least-privilege DNS API token or TSIG key.
  • Set DDNS_TRUSTED_HOSTS only when the app should answer for hostnames beyond DDNS_PUBLIC_BASE_URL.
  • Configure DDNS_TRUSTED_PROXY_IPS only for reverse proxies that strip user-supplied forwarding headers.
  • Keep /docs and /redoc available for API users if you want a public developer surface. Admin routes are hidden from OpenAPI and still require Basic auth.
  • Leave cleanup enabled. The cleanup scheduler is stored in SQLite with a task row, next run time, status, last error, and a database lease so only one app process runs a due cleanup job.
  • Back up /data/ddns.sqlite3.
  • Run one Uvicorn worker with SQLite unless you have tested your deployment under load. Scheduled cleanup is lease-protected across workers, but rate-limit state and per-host update locks are process-local.
  • Keep Uvicorn access logs disabled. Use DDNS_REDACTED_ACCESS_LOG=1 or karldns serve --access-log only for KarlDNS' built-in redacted access log, which masks bearer URL paths and query strings.

Security and cleanup defaults:

Variable Default Purpose
DDNS_RATE_LIMIT_PER_MINUTE 60 Public update/API/form requests allowed per source IP and path.
DDNS_ADMIN_RATE_LIMIT_PER_MINUTE 20 Admin/operator requests allowed per source IP and path.
DDNS_MAX_REQUEST_BODY_BYTES 16384 Maximum accepted request body for form/API endpoints.
DDNS_ADMIN_PATH /admin Private operator UI path. Admin routes are hidden from OpenAPI and require Basic auth.
DDNS_DASHBOARD_COOKIE_SECRET DDNS_ADMIN_PASSWORD fallback HMAC signing secret for dashboard unlock cookies. Use a separate 32+ character random value in production.
DDNS_REDACTED_ACCESS_LOG 0 Enable KarlDNS request logs with bearer paths and update query strings redacted.
DDNS_TRUSTED_HOSTS from DDNS_PUBLIC_BASE_URL Host header allowlist. Add every public hostname that should serve the app.
DDNS_CLEANUP_CHALLENGE_HOURS 72 Delete unverified DNS TXT claims after this many hours.
DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS 1080 Delete generated hostnames that never received a router update after this many hours.
DDNS_CLEANUP_EVENT_HISTORY_HOURS 4320 Delete old raw update/audit events after this many hours. Rollups and recent events remain visible.
DDNS_CLEANUP_INTERVAL_SECONDS 3600 Store the cleanup task cadence in SQLite and schedule the next cleanup run at this interval.
DDNS_DASHBOARD_UNLOCK_SECONDS 86400 Default dashboard password unlock cookie lifetime.
DDNS_DASHBOARD_STAY_UNLOCKED_SECONDS 2592000 Dashboard unlock lifetime when the user chooses to stay unlocked on this browser.

SQLite backup example:

sqlite3 /data/ddns.sqlite3 ".backup '/backups/karldns.sqlite3'"

Project Status

KarlDNS is suitable for self-hosting and private/public beta use when a real DNS backend is configured. For a larger public free DynDNS provider, add external abuse controls, monitoring, alerting, and a tested backup/restore process before launch.

Development

Backend layout:

Module Purpose
router_dyndns/app.py FastAPI app factory, lifespan, middleware and router registration.
router_dyndns/ddns.py Compatibility exports for existing imports and CLI users.
router_dyndns/routers/ Module-level APIRouter files for API, public web, management, admin, assets, and DynDNS update routes.
router_dyndns/dependencies.py Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses.
router_dyndns/schemas.py Pydantic request and response models used by the JSON API.
router_dyndns/web/ HTML rendering, form parsing, and security-header/body-limit support code.
router_dyndns/ddns_service.py Domain logic for account creation, verification, and IP updates.
router_dyndns/store/ SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history.
router_dyndns/ddns_store.py Compatibility export for DdnsStore.
router_dyndns/dns/ Cloudflare and RFC 2136 DNS publishing.
router_dyndns/ddns_dns.py Compatibility exports for DNS provider helpers.
ruff check router_dyndns tests
pytest
python -m compileall router_dyndns

SEO Keywords

Self-hosted DynDNS, self hosted DDNS, Dynamic DNS server, FRITZ!Box DynDNS, FRITZ Box DDNS, Cloudflare DDNS, RFC 2136 DDNS, custom DynDNS provider, home lab DDNS, router dynamic DNS, IPv6 DynDNS, open source DynDNS provider, DuckDNS alternative, No-IP alternative, Dynu alternative.

Security

The repository intentionally contains only placeholders in .env.example and documentation. Do not commit a real .env, DNS token, TSIG secret, admin password, database, private status page URL, domain claim URL, or router-generated update URL. Generated hostnames and CNAME targets are public identifiers, not authentication secrets.

KarlDNS does not need secret-bearing query strings in application logs. The bundled server command leaves Uvicorn access logs disabled by default; configure reverse-proxy logs the same way or redact full request URLs.

If you find a vulnerability, open a private advisory or contact the repository owner before publishing details.

Languages
Python 93.9%
Shell 3.4%
JavaScript 2.6%
Dockerfile 0.1%