A high-performance DNS server with custom record management and domain blocking capabilities. Features intelligent query routing with MongoDB-backed storage for custom DNS records, Redis caching for fast lookups, and automatic upstream fallback to public DNS servers. Built with Node.js for home labs, development environments, and network management.
- Management Dashboard: Built in web UI for adding, editing and deleting records and blocked domains
- Fast Response Times: Redis caching for frequently queried domains
- Domain Blocking: MongoDB-based blocklist for ad/malware blocking
- Upstream Forwarding: Falls back to public DNS servers (Cloudflare, Google DNS) for unknown queries
- Custom Records: Support for A, AAAA, MX, TXT and CNAME records, plus PTR answers derived from your A records
- In-Memory Blocklist: O(1) lookup performance for blocked domains loaded in memory from MongoDB
The dashboard runs inside the DNS server process. That is what lets a blocklist edit refresh the in-memory set directly, with no restart.
The DNS server processes queries through a multi-layered resolution strategy for optimal performance and control:
-
Blocklist Check (In-Memory set loaded from Mongo DB)
- First line of defense against unwanted domains
- O(1) lookup using in-memory Set
- An
Aquery gets0.0.0.0, anAAAAquery gets::, and every other type gets an empty answer, all with a fixed 300 second TTL
-
Cache Lookup (Redis)
- Previously resolved queries are cached with TTL
- Follows CNAME chains automatically
- Only caches upstream responses - your custom records are never written here
- Dramatically reduces response time for repeated queries
-
Custom Records (MongoDB)
- Checks for user-defined DNS records
- Supports A, AAAA, MX, TXT, CNAME records, and answers PTR queries from your A records
- Never cached - ensures instant updates when you modify records
- Perfect for home lab services and custom domains
-
Upstream Forwarding (Cloudflare / Google DNS)
- If not found in previous layers, forwards to public DNS
- If the first resolver does not answer within 2 seconds, the next one is tried
- Response is cached for future queries
- Ensures full DNS coverage for all domains
Why Custom Records Aren't Cached:
- Instant Updates: Changes to MongoDB records take effect immediately
- No Stale Data: Always serve the latest version of your custom records
- Zero Cache Invalidation: Eliminates the complexity of cache invalidation logic
- Predictable Behavior: What you see in the database is what gets served
What Gets Cached:
- Only responses from upstream DNS servers (Google, Cloudflare, etc.)
- Cached with original TTL values from upstream
- Automatically expires based on DNS TTL
One Caveat, Cache Is Checked Before MongoDB:
The cache layer runs ahead of the database layer, so a name that upstream already answered
for stays served from Redis until that entry expires. This never affects private names such
as myapp.home, because upstream has no answer to cache. It does matter if you add a record
to override a public domain you have already queried through this server: the cached
upstream answer wins until its TTL runs out. Flush the key (or wait out the TTL) if you need
the override to apply right away.
- Docker
- Docker Compose
For development mode without Docker you also need Node.js 22 or newer, plus MongoDB and Redis reachable from your machine.
Note for Windows Users: Port 53 is often used by Windows DNS services, which may cause conflicts. If you ran into port already used issue, you can run the DNS server on a different port by setting PORT (e.g. PORT=5353) and changing the published port in compose.yaml, or use WSL2 for a better experience.
- Clone the repository:
git clone https://github.com/neerajann/dns-server.git
cd dns-server- Start all services:
docker compose up -d- Test the DNS server:
dig @localhost example.com- Open the dashboard at
http://localhost:8053
To run in development mode with auto-reload:
npm install
npm run devMake sure MongoDB and Redis are running locally, then copy .env.example to .env and fill
in your connection strings.
Open the dashboard at http://localhost:8053.
It runs inside the DNS server process and is published on loopback only, so it has no authentication. Do not expose port 8053 to a network you do not trust.
- Open
http://localhost:8053 - Click Add record
- Pick a type, enter the name and content, then Save
The type you pick changes the content field. A and AAAA take one address per line, CNAME takes a single target, TXT takes one value per line, and MX gives you a numeric priority next to each mail server. Leave TTL blank for Auto (50 seconds).
Records are grouped one row per domain and type, so a name with two A records is a single row with both addresses. Edits apply to the next query immediately, since database records are never cached. The one exception is a public domain you are overriding, which stays cached until its upstream TTL expires.
PTR is not in the type list on purpose. Reverse lookups are answered automatically from your existing A records, so a manually added PTR record would never be read.
- Open the Blocklist tab
- Paste domains into the box, one per line
- Click Block domains
Hosts file lines such as 0.0.0.0 ads.example.com work too, and the IP prefix is stripped
for you. Invalid entries are skipped and reported instead of failing the whole paste.
No restart is needed. The dashboard reloads the in-memory blocklist as part of the request, and the domain count shown is the live count the DNS query path is using.
The dashboard writes documents in this shape, which is also what you would see in MongoDB:
{
"name": "myapp.local",
"records": [
{
"type": "A",
"content": ["192.168.1.100"]
}
]
}content is always an array, even for a single value, and ttl is only present when you
set one explicitly.
The dashboard is a thin client over these endpoints, useful for scripting bulk changes:
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/records?q=&type= |
List records as flat rows, plus totals |
POST |
/api/records |
Create a record |
PATCH |
/api/records/:name/:type |
Replace a record's content and TTL |
DELETE |
/api/records/:name/:type |
Delete a record |
GET |
/api/blocklist?q=&page=&limit= |
List blocked domains, 50 per page |
POST |
/api/blocklist |
Block one or many domains |
DELETE |
/api/blocklist/:name |
Unblock a domain |
| Type | Description | Content Format |
|---|---|---|
| A | IPv4 address | ["192.168.1.1"] |
| AAAA | IPv6 address | ["2001:db8::1"] |
| CNAME | Canonical name | ["alias.example.com"] |
| MX | Mail exchange | [{exchange: "mail.example.com", preference: 10}] |
| TXT | Text record | ["v=spf1 include:_spf.example.com ~all"] |
| PTR | Reverse DNS | Not stored, answered from your A records |
Note: The content field is always an array, even for single values. Names are stored
lowercased and without a trailing dot, and wildcards are rejected because lookups match
exactly. TTL accepts 0 to 604800 seconds, or blank for Auto (50 seconds).
# Query A record
dig @localhost nodepost.home
# Query MX record
dig @localhost nodepost.home MX
# Query TXT record
dig @localhost nodepost.home TXT# Query A record
Resolve-DnsName -Name nodepost.home -Server localhost -Type A
# Query MX record
Resolve-DnsName -Name nodepost.home -Server localhost -Type MX
# Query TXT record
Resolve-DnsName -Name nodepost.home -Server localhost -Type TXTcompose.yaml defines three services:
- DNS Server: Main application (port 53, UDP and TCP) plus the dashboard (port 8053, published on loopback only)
- MongoDB: Database for records and blocklist
- Redis: Cache layer
MongoDB and Redis publish no ports to the host. They are reachable only from the DNS server
over the Compose network, so the only listeners you expose are 53 and 8053. MongoDB data
persists in the mongo-dns-data volume; the Redis cache is deliberately not persisted.
# Start all services
docker compose up -d
# View logs
docker compose logs -f dns-server
# Stop all services
docker compose down
# Restart DNS server
docker compose restart dns-serverAll settings are environment variables. compose.yaml sets the ones the container needs; for
npm run dev copy .env.example to .env.
| Variable | Default | Purpose |
|---|---|---|
MONGO_DB_URL |
required | MongoDB connection string |
REDIS_HOST |
required | Redis host name or IP |
REDIS_PORT |
6379 |
Redis port |
PORT |
53 |
Port the DNS server listens on |
DASHBOARD_PORT |
8053 |
Port the dashboard listens on |
DASHBOARD_HOST |
127.0.0.1 |
Interface the dashboard binds |
The DNS server always binds 0.0.0.0 so it can answer other machines on your network. The
database name is always dns, taken from the code rather than the URL path.
DASHBOARD_HOST defaults to loopback so npm run dev never exposes the dashboard to your
LAN. Compose sets it to 0.0.0.0 inside the container and publishes 127.0.0.1:8053:8053,
which keeps it reachable only from the host.
If the dashboard port is already taken, the DNS server logs the failure and keeps serving DNS.
Modify config/constant.js to change upstream DNS providers:
export const UPSTREAM_DNS = [
{ address: '1.1.1.1', port: 53 }, // Cloudflare DNS
{ address: '8.8.8.8', port: 53 }<
5CC0
span class="pl-kos">, // Google DNS
]Servers are tried in order. The first entry gets every query, and the next one is used only if no reply arrives within 2 seconds.
- Home Lab DNS: Manage custom domains for your home network and internal services
- Development Environment: Local DNS for microservices, testing, and dev workflows
- Ad Blocking: Block advertising and tracking domains network-wide
- Mail Server Setup: Configure MX, SPF, and DKIM records for email infrastructure
- Network Filtering: Control domain access and implement parental controls
- Performance Optimization: Cache frequently accessed DNS records for faster resolution
- Custom CDN/Load Balancing: Route traffic to specific servers based on domain names
- IoT Device Management: Assign friendly domain names to IoT devices on your network
- Privacy Enhancement: Prevent DNS queries from leaking to ISP by using your own resolver
- Local Service Discovery: Enable easy access to local services without remembering IP addresses
- Testing DNS Changes: Test DNS configurations before deploying to production
Nirajan Paudel
- LinkedIn: n3erajan
- GitHub: @n3erajann