Cover image for Building a Self-Hosted Dynamic DNS Server with Fastify and dns2

Building a Self-Hosted Dynamic DNS Server with Fastify and dns2

15 min read

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:

  1. Control – No third-party dependency, your records stay private.

  2. Flexibility – Extend functionality to suit your needs (IoT, VPN tunnels, remote dev clients).

  3. Learning – Deepen your understanding of DNS, caching, and distributed services.

  4. 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:

  1. Fastify HTTP API
    Exposes routes like /update, /records, /myip, and /health. These allow clients to register or update their DNS records programmatically.

  2. dns2 UDP DNS Server
    Responds to DNS A-record queries for subdomains of a configured BASE_DOMAIN. For example, mydevice.home.local could map to your changing home IP.

  3. Authentication Layer
    Simple bearer tokens protect sensitive operations like updating records. This keeps rogue clients from hijacking your DNS entries.

  4. 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:

  1. /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"}'

  2. /records

    • Authenticated

    • Lists all active DNS records for the user

  3. /myip

    • Public route

    • Returns the caller’s IP address (detected from request headers)

  4. /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 like users, records, and tokens.

  • 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:

  1. AppService loads config from environment variables or .env file

  2. Cache warms from DB to preload all DNS records

  3. DNS server binds to DNS_PORT to start answering queries

  4. HTTP API binds to HTTP_PORT to handle updates and health checks

  5. 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?

  1. Homelab Tunnels
    Running a VPN into your home network? Keep your tunnel endpoint discoverable with a stable hostname.

  2. Remote Clients
    Developers working remotely can register their laptop’s IP under a subdomain, making ad-hoc peer connections seamless.

  3. IoT Devices
    Sensor nodes, Raspberry Pis, or smart devices that need to “phone home” can do so reliably.

  4. 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.

Thanks for reading!

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 more

What are Microservices? Do You Really Need Them?

Learn about microservices architecture: components, pros and cons, and when to apply it to your project.

Read more

Building 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 more

Hướ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