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 a VPS, VM, or LXC. It gives users private bearer update URLs, FRITZ!Box-ready DynDNS fields, public A/AAAA records through Cloudflare or RFC 2136, and generated hostnames that also work as CNAME targets for user-owned domains.
If you want a lightweight DuckDNS / Dynu / No-IP style service that you control yourself, KarlDNS is built for that use case.
Why KarlDNS?
- Self-hosted Dynamic DNS provider for home labs, small ISPs, communities, and private infrastructure.
- FRITZ!Box compatible: copy the generated
Update-URLandDomainnamenstraight into the German FRITZ!Box DynDNS form. - CNAME-friendly hostnames: users can use the generated hostname directly, or point
home.example.comto it with a CNAME. - No user accounts required: generated hostnames use private bearer links instead of a login flow.
- CNAME alias support: users create one KarlDNS hostname, then point
home.example.comto it at their own DNS provider. - 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: small built-in API guide, public OpenAPI JSON, and endpoints under
/api/v1.
Screenshots
Create A Hostname
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 |
|---|---|
karldns/assets/logo-nav.webp |
Small navigation logo. |
karldns/assets/logo.webp |
Optimized homepage logo. |
karldns/assets/logo-192.png and logo-512.png |
PWA, app icon, and manifest icons. |
karldns/assets/favicon.ico, favicon-16x16.png, favicon-32x32.png, apple-touch-icon.png |
Browser and mobile icons. |
karldns/assets/social-preview.png |
Open Graph and Twitter large-card preview for reliable crawlers. |
karldns/assets/social-preview.webp |
Optimized WebP social preview for modern clients. |
What It Does
KarlDNS lets you host your own managed DynDNS service:
- A user opens your KarlDNS URL.
- They generate a random hostname like
a1b2c3d4.karldns.de. - KarlDNS shows the exact router settings and private values.
- The user confirms they saved the values, which registers the hostname.
- The router periodically calls the update URL with its current public IPv4/IPv6 address.
- KarlDNS publishes the matching DNS records through your configured DNS backend.
- 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.
- CNAME target hostnames for users who want
home.example.comwithout 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 under /api/v1/router/update/<secret> |
| 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 |
| User-owned DNS names | CNAME only. KarlDNS publishes the generated provider-owned hostname; the user's DNS provider serves the alias |
| Persistence | SQLite with WAL mode |
| API | FastAPI, small API docs page, public OpenAPI JSON |
| Auth model | No user accounts. Private bearer links for hostnames and dashboards |
| Operations | Rate limiting, admin audit views, update logs, cleanup jobs, Docker, reverse-proxy friendly deployment |
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 - API guide:
http://localhost:8080/docs - OpenAPI JSON:
http://localhost:8080/openapi.json
Admin auth uses HTTP Basic auth. The username can be any value; the password is DDNS_ADMIN_PASSWORD.
The API guide and OpenAPI JSON document the user and router endpoints only. Admin endpoints stay authenticated and are not exposed in the public schema.
Production Setup
For an internet-facing DynDNS service, run behind HTTPS and require a real DNS backend. The normal production shape is:
Cloudflare DNS
-> HTTPS reverse proxy
-> KarlDNS on port 8080
-> SQLite database at /data/ddns.sqlite3
KarlDNS works well in a small VPS, VM, or Proxmox LXC. The current public karldns.de deployment runs in an Alpine LXC on Proxmox, with Docker Compose in /opt/karldns and Nginx Proxy Manager forwarding HTTPS traffic to the app.
Create .env from the example and replace every placeholder:
cp .env.example .env
docker compose up -d --build
Minimum Cloudflare production settings:
DDNS_ADMIN_PASSWORD=replace-with-a-long-random-value
DDNS_ADMIN_PATH=/admin
DDNS_DASHBOARD_COOKIE_SECRET=replace-with-a-32-plus-character-random-value
DDNS_PUBLIC_BASE_URL=https://karldns.de
DDNS_HOSTNAME_SUFFIX=karldns.de
DDNS_DATABASE=/data/ddns.sqlite3
DDNS_REQUIRE_DNS_PROVIDER=1
DDNS_DNS_PROVIDER=cloudflare
DDNS_CLOUDFLARE_API_TOKEN=replace-with-cloudflare-token
DDNS_CLOUDFLARE_ZONE_ID=replace-with-cloudflare-zone-id
DDNS_TTL=60
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.
On Alpine hosts, keep Docker enabled with OpenRC:
rc-service docker status
rc-service docker start
rc-update add docker default
Common production commands:
cd /opt/karldns
docker compose ps
docker compose logs -f karldns
docker compose restart karldns
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. For the public KarlDNS model this is normally only your provider zone, such as karldns.de.
FRITZ!Box DynDNS Setup
Create a hostname in the web UI, copy the generated values, then click Register hostname. The generated Update-URL contains the secret update slug. Put that URL into the router and use the generated hostname as Domainnamen.
Update-URLDomainnamenCNAME targetPrivate dashboardRegister URL
Copy the private URLs when they are generated or rotated. They are intentionally hidden by default on the dashboard to reduce shoulder-surfing risk.
The generated FRITZ!Box URL uses the native placeholders:
https://karldns.de/api/v1/router/update/<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 whenever it detects an address change. KarlDNS returns simple DynDNS-style responses such as good 203.0.113.10, nochg 203.0.113.10, badip, or 911 dns publish failed.
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/api/v1/router/update/<random-update-slug>?myip=203.0.113.10'
# Explicit IPv6 update
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>?myipv6=2001:db8::10'
# Use the request source IP
curl -sS 'https://karldns.de/api/v1/router/update/<random-update-slug>'
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 and registers 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.
The installer is POSIX sh and works on common Linux distributions including Debian, Ubuntu, Fedora, Arch, Alpine, and Proxmox hosts. It needs curl and cron. On Alpine, install those first if they are not present:
apk add curl
rc-service crond start
rc-update add crond
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
Use an existing KarlDNS update URL:
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- \
--update-url 'https://karldns.de/api/v1/router/update/<random-update-slug>' \
--hostname 'home.karldns.de'
Common options:
--every 5 # cron interval in minutes, default 5
--mode user # install without root into ~/.local, ~/.config, and the user's crontab
--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 calls the private update URL. By default KarlDNS uses the request source address; explicit --ipv4 or --ipv6 values can be configured when needed. Re-running the installer is safe: it reuses an existing updater.env, refreshes the updater script, replaces only the managed cron block, and keeps unrelated cron entries intact.
Installed files in system mode:
/usr/local/bin/karldns-update
/etc/karldns/updater.env
/var/cache/karldns/last-ip
Cron is installed where the OS expects it:
- Debian/Ubuntu/Fedora/Arch style:
/etc/cron.d/karldns-updater - Alpine/BusyBox style: managed block in
/etc/crontabs/root - Fallback: managed block in root's
crontab
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 private URLs. The hostname itself is not secret. The update URL, registration URL, and dashboard URL are secrets.
Uninstall, keeping the private config by default:
curl -fsSL https://karldns.de/uninstall.sh | sudo sh
Clean uninstall, including saved private URLs:
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
To use your own domain, 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 and Domainnamen 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.
KarlDNS does not verify, claim, or publish records inside user-owned domains. Your DNS provider keeps serving home.example.com; KarlDNS only maintains the generated *.karldns.de target.
The generated hostname is not a secret. Treat it like any public DNS name. The update URL slug, registration URL, and private dashboard URL 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.
HTTP API
The JSON API is available under /api/v1. The public API has two surfaces: user dashboard data with history and router registration/update calls.
| Endpoint | Purpose |
|---|---|
POST /api/v1/hostnames |
Create an anonymous provider-owned hostname in pending state. |
GET /api/v1/hostnames/{dashboard_secret} |
Inspect a hostname and recent update history by private dashboard secret. |
POST /api/v1/hostnames/{dashboard_secret}/rotate-update-url |
Replace the private router update URL. |
DELETE /api/v1/hostnames/{dashboard_secret} |
Delete a hostname and its DNS records. |
GET or POST /api/v1/router/register/{dashboard_secret} |
Register a pending hostname after secrets are saved. |
GET or POST /api/v1/router/update/{update_secret} |
Update a hostname from FRITZ!Box, another router, curl, or a cron script. |
Example:
curl -sS -X POST https://karldns.de/api/v1/hostnames
The create response includes registration_url, update_url, and dashboard_url. Save the returned secrets, then POST the registration URL before using the update URL. Registration publishes an initial DNS record when a public request IP or explicit myip/myipv6 value is available; otherwise the first router update publishes the record. Pending hostnames that are never registered are cleaned up automatically.
Use the dashboard secret to rotate the update URL:
curl -sS -X POST https://karldns.de/api/v1/hostnames/<karl_dashboard_secret>/rotate-update-url
The response contains a replacement update_url. The old update URL stops working immediately.
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 uses the current Python 3.14 Alpine base image and installs production dependencies from requirements.txt. The compose file defaults 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. Production can use Caddy, Nginx, Nginx Proxy Manager, Traefik, or another reverse proxy as long as HTTPS, trusted hosts, forwarded headers, and access logs are configured carefully.
Operations Checklist
- Run behind HTTPS.
- Keep
.envout of git. - Use a long random value for
DDNS_ADMIN_PASSWORD. - Use a separate long random value for
DDNS_DASHBOARD_COOKIE_SECRETon public instances. - Change
DDNS_ADMIN_PATHif you do not want the operator UI at/admin. - Set
DDNS_PUBLIC_BASE_URLto the real public URL. KarlDNS uses it for generated links, code examples, API docs, canonical URLs, and the default trusted host. - Use a least-privilege DNS API token or TSIG key.
- Set
DDNS_TRUSTED_HOSTSonly when the app should answer for hostnames beyondDDNS_PUBLIC_BASE_URL. - KarlDNS automatically trusts
CF-Connecting-IP,X-Forwarded-For, andX-Real-IPwhen the direct peer is a private/local reverse proxy such as Docker or Nginx Proxy Manager. This lets the UI examples and router updates use the real public visitor IP behind Cloudflare. SetDDNS_TRUST_PRIVATE_PROXY_HEADERS=0if the app port is reachable by untrusted LAN clients. - Configure
DDNS_TRUSTED_PROXY_IPSwhen your reverse proxy is not on a private/local address, and only for proxies that strip user-supplied forwarding headers. - Keep
/docsavailable if you want the small developer surface. It documents only the public user and router endpoints. - 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=1orkarldns serve --access-logonly 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 UI form routes stay hidden from OpenAPI and require Basic auth. |
DDNS_PUBLIC_BASE_URL |
http://localhost:8080 |
Public URL used for generated links, public examples, API docs, canonical URLs, and the default trusted host. |
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_PENDING_ACCOUNT_HOURS |
24 |
Delete generated setup intents that were never registered after this many hours. |
DDNS_CLEANUP_UNUSED_ACCOUNT_HOURS |
1080 |
Delete registered 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 |
|---|---|
karldns/app.py |
FastAPI app factory, lifespan, middleware and router registration. |
karldns/settings.py |
Pydantic settings loaded from environment variables. |
karldns/models.py |
Shared domain models such as update results. |
karldns/routers/ |
Module-level APIRouter files for API, public web, management, admin, assets, and DynDNS update routes. |
karldns/dependencies.py |
Shared FastAPI dependencies for app state, admin auth, update auth, and router update responses. |
karldns/schemas.py |
Pydantic request and response models used by the JSON API. |
karldns/web/ |
Layout, reusable HTML components, scripts, CSS, form parsing, and security-header/body-limit support code. |
karldns/services/ddns.py |
Domain logic for account creation and IP updates. |
karldns/store/ |
SQLite persistence, rate limits, durable scheduled tasks, cleanup, and event history. |
karldns/dns/ |
Cloudflare and RFC 2136 DNS publishing. |
ruff check karldns tests
pytest
python -m compileall karldns
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 dashboard 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.

