
Building a Self-Hosted Dynamic DNS Server with Fastify and dns2
Table of Contents
Table of Contents
Dynamic DNS (DDNS) has been a staple tool for hobbyists, homelab enthusiasts, and small businesses for years. Its primary goal is simple: provide a stable hostname for devices that live behind a changing IP address. Whether you’re hosting a service on your home network, tunneling traffic to a VPS, or keeping IoT devices reachable, DDNS makes the connection reliable.
In this blog, we’ll walk through building a self-hosted dynamic DNS server using two lightweight yet powerful tools: Fastify (for the HTTP API) and dns2 (for the UDP DNS server). The result is a small footprint service that you can run anywhere — a Raspberry Pi, a low-cost VPS, or your main homelab server.
Why Build Your Own DDNS?
Before diving into the implementation, let’s explore the “why.” Plenty of free and paid DDNS services already exist. But running your own brings some unique advantages:
-
Control – No third-party dependency, your records stay private.
-
Flexibility – Extend functionality to suit your needs (IoT, VPN tunnels, remote dev clients).
-
Learning – Deepen your understanding of DNS, caching, and distributed services.
-
Cost – If you already have a server, the incremental cost is essentially zero.
If you’re running a homelab or experimenting with networking concepts, this project offers both practical utility and educational value.
Core Architecture
The service is composed of four key pieces:
-
Fastify HTTP API
Exposes routes like/update
,/records
,/myip
, and/health
. These allow clients to register or update their DNS records programmatically. -
dns2 UDP DNS Server
Responds to DNS A-record queries for subdomains of a configuredBASE_DOMAIN
. For example,mydevice.home.local
could map to your changing home IP. -
Authentication Layer
Simple bearer tokens protect sensitive operations like updating records. This keeps rogue clients from hijacking your DNS entries. -
Persistence + Caching
DNS records and user accounts are stored in SQLite via Prisma. An in-memory cache accelerates lookups and minimizes DB load.
Optionally, you can layer on:
-
S3 Backup using
@aws-sdk/client-s3
-
Discord Notifications for updates or health alerts
-
Scheduled Cron Jobs for periodic backups
Together, these components form a compact yet powerful DDNS service that can serve real-world needs.
Application Bootstrap (Real Code)
Below is a trimmed version of the startup orchestration from AppService
showing how services are wired and servers started:
// src/services/app.service.ts (excerpt)
export class AppService {
constructor() {
this.app = fastify({ logger: true });
this.discordWebhook = new DiscordWebhookService(configService.discordWebhookUrl, this.logger);
this.tokenManager = new TokenManager(this.logger);
this.userService = new UserService(prisma, this.logger);
this.authService = new AuthService(this.tokenManager, this.userService);
this.backupService = new BackupService(this.logger);
this.dnsService = new DNSService(this.logger);
this.userRoutesService = new UserRoutesService(this.userService, this.authService, this.logger);
this.httpRoutesService = new HTTPRoutesService(
this.authService,
this.backupService,
this.discordWebhook,
this.tokenManager,
this.userService,
this.logger
);
}
public async start(): Promise<void> {
await this.setupCORS();
await this.setupRateLimits();
await this.initializeDatabase();
await this.app.register(async (f) => {
this.httpRoutesService.registerRoutes(f);
this.userRoutesService.registerUserRoutes(f);
});
await this.dnsService.start();
this.backupService.startScheduler();
await this.app.listen({ port: configService.httpPort, host: "0.0.0.0" });
this.setupGracefulShutdown();
}
}
Key points:
- Services are dependency-injected manually (no external DI framework)
- DNS + HTTP start in same lifecycle
- Database + token cache warm before serving traffic
- Graceful shutdown handles DNS, DB, HTTP in order
Technology Choices
Fastify
Fastify is a high-performance Node.js framework optimized for speed and developer experience. It offers a plugin ecosystem, schema validation, and a clean async API. For our use case, it serves as the HTTP entrypoint where clients authenticate and push DNS record updates.
dns2
dns2
is a minimal DNS server implementation for Node.js. Instead of deploying a heavyweight authoritative server (like BIND or PowerDNS), dns2
focuses on lightweight DNS packet handling. Perfect for a DDNS service that just needs to respond to a subset of queries.
Prisma + SQLite
Prisma provides a type-safe ORM with migrations and schema management. Combined with SQLite, it keeps the system simple — one binary database file, easy to back up, no additional service dependencies. For small homelab workloads, SQLite is more than enough.
In-Memory Cache
DNS traffic can be bursty, and queries may come in more frequently than updates. A cache layer ensures that the majority of DNS lookups never touch the database, yielding fast responses.
The HTTP API
The Fastify server exposes a few essential routes:
-
/update
-
Authenticated via Bearer token
-
Accepts subdomain + IP (or defaults to caller’s IP)
-
Inserts or updates the record in the DB and cache
Example request:
curl -X POST https://ddns.example.com/update \ -H "Authorization: Bearer <TOKEN>" \ -d '{"subdomain": "mydevice", "ip": "203.0.113.45"}'
-
-
/records
-
Authenticated
-
Lists all active DNS records for the user
-
-
/myip
-
Public route
-
Returns the caller’s IP address (detected from request headers)
-
-
/health
-
Public route
-
Reports service status (useful for monitoring)
-
These APIs give clients a simple way to programmatically keep their hostnames fresh.
/update
Route Logic (Real Code)
The core dynamic update endpoint performs auth, ownership checks, caching + persistence, auditing, and optional Discord notification:
// src/services/http-routes.service.ts (excerpt)
fastify.post<{ Body: UpdateRequest }>("/update", {
preHandler: this.authService.enhancedAuthMiddleware,
}, async (request, reply) => {
const { subdomain, ip } = request.body;
if (!subdomain || !ip) return reply.status(400).send({ error: "Missing required fields: subdomain and ip" });
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (!ipRegex.test(ip)) return reply.status(400).send({ error: "Invalid IP address format" });
const fullDomain = `${subdomain}.${configService.baseDomain}`;
// Ownership / token validation (master, admin, user, or subdomain token)
// ...omitted for brevity (see source)
const { previousIp, updated } = await db.setRecord(fullDomain, ip, this.authService.getCurrentUser(request)?.id || null);
if (!updated) {
return { message: "DNS record unchanged - IP is the same", domain: fullDomain, ip, previousIp };
}
// Audit + Discord notification (if enabled)
await this.discordWebhook.sendNotification({ type: "dns_update", subdomain, domain: fullDomain, ip, previousIp, timestamp: new Date() });
return { message: "DNS record updated successfully", domain: fullDomain, ip, previousIp };
});
Highlights:
- Accepts updates only after layered auth/mode checks
- Uses a cache-aware
db.setRecord
that returns prior value + update flag - Avoids unnecessary writes & notifications when IP unchanged
- Emits structured event for Discord integration
The DNS Server
The dns2 server listens on DNS_PORT
(typically 53/udp, but you can choose another if running unprivileged). Its job is simple:
-
Inspect incoming DNS queries
-
If the query is for a subdomain of
BASE_DOMAIN
, look it up in cache/DB -
Respond with an A record containing the stored IP address
-
Otherwise, ignore or forward (depending on configuration)
For example:
-
Query:
mydevice.homelab.local
-
Response:
A 203.0.113.45
This tight loop ensures queries resolve quickly and accurately.
DNS Query Handling (Real Code)
// src/services/dns.service.ts (excerpt)
this.dnsServer = dns2.createServer({
udp: true,
handle: async (request, send, rinfo) => {
const response = Packet.createResponseFromRequest(request);
const [question] = request.questions;
const { name, type } = question;
this.logger.info(`DNS Query: ${name} (${type}) from ${rinfo.address}`);
if (type === Packet.TYPE.A) {
const domain = name.toLowerCase();
try {
const ip = await db.getRecord(domain); // cache-backed
if (ip) {
response.answers.push({ name: domain, type: Packet.TYPE.A, class: Packet.CLASS.IN, ttl: 300, address: ip });
this.logger.info(`DNS Response: ${domain} -> ${ip} (cached)`);
} else {
this.logger.warn(`DNS NXDOMAIN: ${domain} not found in cache`);
}
} catch (err) {
this.logger.error(`DNS Query Error: ${err}`);
}
}
send(response);
},
});
Notes:
- Only A records are answered (simple authoritative behavior)
- All reads flow through cache (no full-table scan)
- Logs cover both hits and misses for observability
Authentication
Instead of a complex identity system, we use Bearer tokens for authentication. Each user is assigned a token in the DB. When making update requests, the token must be included in the Authorization header.
This approach strikes a balance between simplicity and security, especially in a homelab or small-team setup.
Layered Authentication (Real Code)
// src/services/auth.service.ts (excerpt)
public enhancedAuthMiddleware = async (request, reply) => {
const token = this.getTokenFromRequest(request);
if (!token) return reply.status(401).send({ error: "Authentication token required" });
if (token === configService.authToken) { (request as any).isMasterToken = true; (request as any).isAdminToken = true; return; }
const user = await this.userService.getUserByApiToken(token);
if (user) { (request as any).currentUser = user; (request as any).isAdminToken = user.isAdmin; return; }
const validation = this.tokenManager.validateToken(token);
if (validation.isValid && validation.token) { (request as any).subdomainToken = validation.token; return; }
return reply.status(401).send({ error: "Invalid authentication token", details: validation.error });
};
Modes supported:
- Master token (full control)
- User API token (optionally admin)
- Per-subdomain token (scoped)
- Unified extraction from header/query/body for flexibility
Persistence and Caching
-
Database (SQLite)
Records are persisted in a simple schema with tables likeusers
,records
, andtokens
. -
Cache (In-Memory)
At startup, the app warms the cache by loading all records from DB. Subsequent queries hit the cache, with DB writes occurring on updates.
This design keeps DNS responses fast and ensures resilience against restarts.
Optional Enhancements
S3 Backups
Using the AWS SDK, we can periodically push a copy of the SQLite database to S3 or MinIO. A cron job handles this automatically, giving us durable offsite backups.
Discord Notifications
Hooking into Fastify’s lifecycle, we can send notifications when a record updates or when health checks fail. For small teams, Discord serves as a lightweight monitoring channel.
Backup Scheduler
A cron-like job scheduler ensures backups run daily or weekly. This helps guard against accidental data loss.
Startup Flow
When the service boots, the following sequence occurs:
-
AppService loads config from environment variables or
.env
file -
Cache warms from DB to preload all DNS records
-
DNS server binds to DNS_PORT to start answering queries
-
HTTP API binds to HTTP_PORT to handle updates and health checks
-
Backup scheduler optionally kicks off recurring tasks
This deterministic startup process ensures the service is ready to handle both queries and updates immediately.
Why dns2?
At this point, you might ask: why not just run BIND, NSD, or PowerDNS?
The answer is focus. Traditional DNS servers are powerful but come with configuration overhead and features we don’t need. For DDNS, the requirements are minimal:
-
Respond to A record queries for a small set of subdomains
-
Allow dynamic updates via API
-
Stay lightweight and easy to maintain
dns2
fits perfectly here. It’s a simple library, built for Node.js, with just enough power to serve as our authoritative DNS for BASE_DOMAIN
.
Real-World Use Cases
So where can you deploy this service?
-
Homelab Tunnels
Running a VPN into your home network? Keep your tunnel endpoint discoverable with a stable hostname. -
Remote Clients
Developers working remotely can register their laptop’s IP under a subdomain, making ad-hoc peer connections seamless. -
IoT Devices
Sensor nodes, Raspberry Pis, or smart devices that need to “phone home” can do so reliably. -
Self-Hosted Services
Expose a web dashboard, SSH service, or monitoring endpoint without worrying about your ISP’s changing IP.
Example Deployment
Here’s a sample docker-compose.yml
to get started:
version: "3.9"
services:
ddns:
image: my-ddns:latest
container_name: ddns
ports:
- "3000:3000" # Fastify API
- "5353:53/udp" # DNS server (UDP)
environment:
BASE_DOMAIN: homelab.local
HTTP_PORT: 3000
DNS_PORT: 53
DATABASE_URL: file:./data/dev.db
AUTH_TOKEN: supersecrettoken
volumes:
- ./data:/app/data
restart: unless-stopped
Deploy, point your clients to use the DNS server, and start updating records via the HTTP API.
Final Thoughts
Dynamic DNS may seem like a solved problem, but self-hosting opens the door to customization, control, and learning. By combining Fastify and dns2, we get a DDNS solution that is:
-
Lightweight
-
Easy to deploy
-
Secure with token authentication
-
Extendable with backups and notifications
For homelabbers, tinkerers, and small teams, this project delivers exactly what’s needed — no more, no less. It strips DDNS back to its essentials and puts you in charge.
Whether you deploy it on a Raspberry Pi at home, a VPS in the cloud, or as part of a larger distributed system, this service keeps your devices reachable and your infrastructure under your control.
Related Posts
Microservices là gì? Bạn có thật sự cần đến nó?
Tìm hiểu về kiến trúc microservices: thành phần, ưu nhược điểm và khi nào nên áp dụng cho dự án của bạn.
Read moreWhat are Microservices? Do You Really Need Them?
Learn about microservices architecture: components, pros and cons, and when to apply it to your project.
Read moreBuilding a Self-Hosted Dynamic DNS Server with Fastify and dns2
Learn how to create a self-hosted Dynamic DNS (DDNS) server using Fastify and dns2, perfect for homelabs and small networks.
Read moreHướng dẫn xây dựng và triển khai Remote MCP Server miễn phí với Cloudflare Workers
Cách tự tạo và deploy Remote Model Context Protocol (MCP) Server trên Cloudflare Workers, hỗ trợ xác thực OAuth, tối ưu cho AI, Claude Desktop, DevOps.
Read more