**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.
6. KarlDNS publishes the matching DNS records through your configured DNS backend. If no public IP is visible during registration, it publishes a temporary `192.0.2.1` A record until the router sends its first real update.
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.
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:
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.
`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`.
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`.
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`.
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:
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:
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.
`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.
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:
```bash
curl -fsSL https://karldns.de/install.sh | sudo sh -s -- --ipv6 off
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.
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. If no public IP is visible, KarlDNS publishes a temporary `192.0.2.1` A record and replaces it on the first router update. Pending hostnames that are never registered are cleaned up automatically.
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.
- Set `DDNS_PUBLIC_BASE_URL` to the real public URL. KarlDNS uses it for generated links, code examples, API docs, canonical URLs, and the default trusted host.
- KarlDNS automatically trusts `CF-Connecting-IP`, `X-Forwarded-For`, and `X-Real-IP` when 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. Set `DDNS_TRUST_PRIVATE_PROXY_HEADERS=0` if the app port is reachable by untrusted LAN clients.
- Configure `DDNS_TRUSTED_PROXY_IPS` when your reverse proxy is not on a private/local address, and only for proxies that strip user-supplied forwarding headers.
- 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.
- 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.
| `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. |
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.
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.