A high-performance authoritative DNS server written in Python with a pluggable backend system. Store your zone data in Google Sheets, PostgreSQL, or any custom data source — edit your records and they go live automatically.
- Architecture
- Prerequisites
- Installation
- Configuration
- Backends
- Running the Server
- Testing
- Performance
- DNS Behavior
- Multiple Zones
- Reverse DNS (PTR)
- Troubleshooting
- Project Structure
- Limitations
┌──────────────────────────────────────────────────────┐
│ DNS Server │
│ │
UDP/TCP queries ──────►│ ┌──────────────┐ ┌────────────┐ ┌──────────┐ │
│ │ UDP Workers │───►│ Response │───►│ Zone │ │
│ │ (64 threads) │ │ LRU Cache │ │ Cache │ │
│ ├──────────────┤ │ (100k) │ │ (dict) │ │
│ │ TCP Listener │───►│ │ │ │ │
│ │ (threaded) │ └────────────┘ └────┬─────┘ │
│ └──────────────┘ │ │
└────────────────────────────────────────────┼───────┘
│
Background │ refresh
(every 30s)│
▼
┌─────────────────┐
│ Pluggable │
│ Backend │
│ │
│ Google Sheets │
│ PostgreSQL │
│ Custom ... │
└─────────────────┘
- Zone data is loaded from a pluggable backend into an in-memory
ZoneCache(Pythondictkeyed by(fqdn, record_type)for O(1) lookups). - A background thread re-syncs from the backend every
refresh_intervalseconds. - A packed-response LRU cache sits in front of the resolver. Repeated queries for the same
(qname, qtype)are answered by patching the two-byte query ID into an already-serialized DNS packet — no parsing or RR construction on the hot path. - 64 UDP worker threads call
recvfromon a single shared socket (kernel-demultiplexed, standard pattern for high-throughput UDP servers on Linux). - A TCP listener with per-connection threads handles TCP fallback for large responses.
The backend is never on the hot path. Every DNS lookup is answered from memory.
- Python 3.10+
- For the Google Sheets backend: a GCP service-account key and a shared spreadsheet
- For the PostgreSQL backend: a PostgreSQL 12+ server
git clone <repository-url>
cd sheetdns
# Create and activate a virtual environment (recommended)
python3 -m venv venv
source venv/bin/activate
# Install all dependencies (both backends)
pip install -r requirements.txt
# Or install only what you need:
pip install dnslib PyYAML # core
pip install google-api-python-client google-auth # + Sheets backend
pip install psycopg2-binary # + Postgres backendEdit config.yaml to select your backend and tune the server.
The backend: section has a type key that selects which backend to use. All other keys in the section are passed to the backend class — each backend reads the keys it needs and ignores the rest.
server:
host: "0.0.0.0" # Bind address
port: 53 # DNS port (53 requires root or CAP_NET_BIND_SERVICE)
udp_workers: 64 # UDP recv threads (recommended: 2x CPU cores)
tcp_workers: 32 # Max concurrent TCP connections
tcp_timeout: 10 # TCP idle timeout (seconds)
cache:
max_entries: 100000 # Response LRU cache size (~512 bytes/entry ≈ 50 MB)
backend:
# "sheets", "postgres", or a custom "my_package.module.ClassName"
type: sheets
# How often (seconds) to re-pull zone data from the backend.
refresh_interval: 30
# ── Google Sheets options (type: sheets) ──
spreadsheet_id: "YOUR_SPREADSHEET_ID_HERE"
credentials_file: "credentials.json"
# ── PostgreSQL options (type: postgres) ──
# host: "localhost"
# port: 5432
# database: "dns"
# user: "dns"
# password: "secret"
# dsn: "postgresql://dns:secret@localhost:5432/dns" # alternative to individual keys
logging:
level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"Store zone data in a Google Sheets spreadsheet. Each worksheet tab is one zone.
- Go to the Google Cloud Console.
- Create a new project (or select an existing one).
- Navigate to APIs & Services > Library and enable the Google Sheets API.
- Navigate to APIs & Services > Credentials.
- Click Create Credentials > Service account.
- Give it a name (e.g.
dns-server) and click through the wizard. - On the service account page, go to the Keys tab.
- Click Add Key > Create new key > JSON and save the file as
credentials.jsonin the project directory. - Copy the service account's email address (looks like
dns-server@your-project.iam.gserviceaccount.com). - Open your Google Sheets spreadsheet and click Share.
- Paste the service account email and give it Viewer access.
backend:
type: sheets
refresh_interval: 30
spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
credentials_file: "credentials.json"Each worksheet (tab) represents one DNS zone. The tab title is the zone name.
┌─────────────────────────────────────────────────┐
│ Tab: "example.com" │ Tab: "internal.corp" │
└─────────────────────────────────────────────────┘
Row 1 of each worksheet must be a header row. The following column names are recognized (case-insensitive):
| Column | Aliases | Required | Description |
|---|---|---|---|
| Name | hostname, host, record |
Yes | Record name relative to the zone |
| Type | record type, rtype |
Yes | DNS record type |
| Value | data, rdata, address, target, content |
Yes | Record data |
| TTL | — | No | Time to live in seconds (default: 300) |
| Priority | prio, preference |
No | Priority for MX and SRV records |
| Weight | — | No | Weight for SRV records |
| Port | — | No | Port for SRV records |
| Value in Sheet | Resolves To | Description |
|---|---|---|
@ |
example.com |
Zone apex |
www |
www.example.com |
Subdomain |
mail.us |
mail.us.example.com |
Multi-level subdomain |
*.dev |
*.dev.example.com |
Wildcard |
* |
*.example.com |
Zone-wide wildcard |
Names are automatically converted to FQDNs using the zone (tab) name. Trailing dots are not required.
| Type | Value Format | Extra Columns | Example Value |
|---|---|---|---|
| A | IPv4 address | — | 192.168.1.1 |
| AAAA | IPv6 address | — | 2001:db8::1 |
| CNAME | Target hostname | — | cdn.cloudfront.net |
| MX | Mail server hostname | Priority | mail.example.com |
| NS | Nameserver hostname | — | ns1.example.com |
| TXT | Text string | — | v=spf1 include:_spf.google.com ~all |
| SOA | Space-separated fields | — | ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400 |
| SRV | Target hostname | Priority, Weight, Port | sip.example.com |
| PTR | Target hostname | — | host1.example.com |
| CAA | flags tag value |
— | 0 issue letsencrypt.org |
SOA value fields (space-separated): mname rname serial refresh retry expire minimum
| Field | Description | Example |
|---|---|---|
| mname | Primary nameserver | ns1.example.com. |
| rname | Admin email (@ replaced with .) | admin.example.com. |
| serial | Zone serial number (increment on every change) | 2024010101 |
| refresh | Secondary refresh interval (seconds) | 3600 |
| retry | Secondary retry interval (seconds) | 900 |
| expire | Secondary expiry time (seconds) | 604800 |
| minimum | Negative-cache TTL (seconds) | 86400 |
A worksheet named example.com:
| Name | Type | Value | TTL | Priority | Weight | Port |
|---|---|---|---|---|---|---|
| @ | SOA | ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400 | 86400 | |||
| @ | NS | ns1.example.com | 86400 | |||
| @ | NS | ns2.example.com | 86400 | |||
| @ | A | 93.184.216.34 | 300 | |||
| www | A | 93.184.216.34 | 300 | |||
| www | AAAA | 2606:2800:220:1:248:1893:25c8:1946 | 300 | |||
| @ | MX | mail.example.com | 300 | 10 | ||
| @ | MX | mail2.example.com | 300 | 20 | ||
| A | 93.184.216.50 | 300 | ||||
| cdn | CNAME | d111111abcdef8.cloudfront.net | 300 | |||
| @ | TXT | v=spf1 include:_spf.google.com ~all | 300 | |||
| *.dev | A | 10.0.1.1 | 60 | |||
| _sip._tcp | SRV | sip.example.com | 300 | 10 | 60 | 5060 |
| @ | CAA | 0 issue letsencrypt.org | 86400 |
Store zone data in PostgreSQL for environments where a database is more appropriate than a spreadsheet.
The PostgreSQL backend uses two tables: zones and records. Create them automatically with:
python3 server.py --setupOr run the SQL manually:
CREATE TABLE IF NOT EXISTS zones (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS records (
id SERIAL PRIMARY KEY,
zone_id INTEGER NOT NULL REFERENCES zones(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- @ for apex, www, *.dev, etc.
type VARCHAR(10) NOT NULL, -- A, AAAA, CNAME, MX, NS, TXT, SOA, SRV, PTR, CAA
value TEXT NOT NULL,
ttl INTEGER NOT NULL DEFAULT 300,
priority INTEGER, -- MX / SRV priority
weight INTEGER, -- SRV weight
port INTEGER -- SRV port
);
CREATE INDEX IF NOT EXISTS idx_records_zone_id ON records(zone_id);
CREATE INDEX IF NOT EXISTS idx_records_lookup ON records(zone_id, name, type);The name column uses the same conventions as the Google Sheets backend: @ for the zone apex, www for a subdomain, *.dev for a wildcard, etc.
# 1. Create the database and user
sudo -u postgres psql <<'SQL'
CREATE USER dns WITH PASSWORD 'your_password';
CREATE DATABASE dns OWNER dns;
SQL
# 2. Configure config.yaml
# (see Config section below)
# 3. Create tables
python3 server.py --setup
# 4. Populate zone data
psql -U dns -d dns <<'SQL'
INSERT INTO zones (name) VALUES ('example.com');
INSERT INTO records (zone_id, name, type, value, ttl) VALUES
(1, '@', 'SOA', 'ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400', 86400),
(1, '@', 'NS', 'ns1.example.com', 86400),
(1, '@', 'NS', 'ns2.example.com', 86400),
(1, '@', 'A', '93.184.216.34', 300),
(1, 'www', 'A', '93.184.216.34', 300);
INSERT INTO records (zone_id, name, type, value, ttl, priority) VALUES
(1, '@', 'MX', 'mail.example.com', 300, 10);
SQL
# 5. Start the server
sudo python3 server.pybackend:
type: postgres
refresh_interval: 10
# Option A: individual parameters
host: "localhost"
port: 5432
database: "dns"
user: "dns"
password: "your_password"
# Option B: connection string (takes precedence over individual params)
# dsn: "postgresql://dns:your_password@localhost:5432/dns"-- Add a zone
INSERT INTO zones (name) VALUES ('example.org');
-- Add records (zone_id from the zones table)
INSERT INTO records (zone_id, name, type, value, ttl)
VALUES (2, '@', 'A', '203.0.113.50', 300);
-- Update a record
UPDATE records SET value = '203.0.113.51' WHERE id = 42;
-- Delete a record
DELETE FROM records WHERE id = 42;
-- List all records for a zone
SELECT r.name, r.type, r.value, r.ttl, r.priority
FROM records r
JOIN zones z ON r.zone_id = z.id
WHERE z.name = 'example.com'
ORDER BY r.name, r.type;Changes are picked up on the next background refresh cycle (refresh_interval seconds).
You can use any data source — MySQL, Redis, a REST API, flat files — by creating a Python class that subclasses BaseBackend.
Create a file (e.g. mysql_backend.py) in the project directory or anywhere on your Python path:
# mysql_backend.py
"""MySQL backend for sheetdns DNS."""
import logging
from backends import BaseBackend, VALID_RECORD_TYPES
logger = logging.getLogger("dns.mysql")
class MySQLBackend(BaseBackend):
"""
Loads DNS zone data from a MySQL database.
Uses the same schema as the PostgreSQL backend — a ``zones`` table
and a ``records`` table.
"""
SCHEMA = """\
CREATE TABLE IF NOT EXISTS zones (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS records (
id INT AUTO_INCREMENT PRIMARY KEY,
zone_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(10) NOT NULL,
value TEXT NOT NULL,
ttl INT NOT NULL DEFAULT 300,
priority INT,
weight INT,
port INT,
FOREIGN KEY (zone_id) REFERENCES zones(id) ON DELETE CASCADE
);
CREATE INDEX idx_records_zone_id ON records(zone_id);
CREATE INDEX idx_records_lookup ON records(zone_id, name, type);
"""
def __init__(self, config: dict):
super().__init__(config)
import mysql.connector
self._mysql = mysql.connector
def load_all_zones(self) -> dict[str, list[dict]]:
conn = self._connect()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT z.name, r.name, r.type, r.value, r.ttl, "
" r.priority, r.weight, r.port "
"FROM records r "
"JOIN zones z ON r.zone_id = z.id "
"ORDER BY z.name"
)
zone_records: dict[str, list[dict]] = {}
for (zone_name, name, rtype, value, ttl,
priority, weight, port) in cursor:
rec: dict = {
"name": self.normalize_name(name, zone_name),
"type": rtype.upper().strip(),
"value": value.strip(),
"ttl": str(ttl or 300),
"zone": zone_name.lower().rstrip("."),
}
if priority is not None:
rec["priority"] = str(priority)
if weight is not None:
rec["weight"] = str(weight)
if port is not None:
rec["port"] = str(port)
zone_records.setdefault(zone_name, []).append(rec)
return zone_records
finally:
conn.close()
def setup(self):
"""Create the zones and records tables."""
conn = self._connect()
try:
cursor = conn.cursor()
for statement in self.SCHEMA.split(";"):
statement = statement.strip()
if statement:
cursor.execute(statement)
conn.commit()
logger.info("MySQL schema created successfully")
finally:
conn.close()
def _connect(self):
return self._mysql.connect(
host=self.config.get("host", "localhost"),
port=self.config.get("port", 3306),
database=self.config.get("database", "dns"),
user=self.config.get("user", "dns"),
password=self.config.get("password", ""),
)Use the full Python import path (module.ClassName) as the backend type:
backend:
type: "mysql_backend.MySQLBackend"
refresh_interval: 15
host: "localhost"
port: 3306
database: "dns"
user: "dns"
password: "secret"If your module is in a package (e.g. my_backends/mysql.py), use dotted notation:
backend:
type: "my_backends.mysql.MySQLBackend"python3 server.py --setup # calls MySQLBackend.setup()
sudo python3 server.py # starts the DNS serverYour class must subclass backends.BaseBackend and implement load_all_zones():
from backends import BaseBackend
class MyBackend(BaseBackend):
def __init__(self, config: dict):
"""
Called once at server startup.
``config`` is the full ``backend:`` section from config.yaml
as a Python dict. Read whatever keys you need (host, port,
api_key, file_path, …).
"""
super().__init__(config)
def load_all_zones(self) -> dict[str, list[dict]]:
"""
Called on startup and every ``refresh_interval`` seconds.
Must return a dict mapping zone names to lists of record dicts.
Each record dict MUST contain:
name – FQDN, lowercase, no trailing dot
(use self.normalize_name("www", "example.com")
→ "www.example.com")
type – uppercase: A, AAAA, CNAME, MX, NS, TXT, SOA,
SRV, PTR, CAA
value – record data as a string
ttl – TTL in seconds as a string ("300")
zone – zone name, lowercase, no trailing dot
Optional keys (for MX / SRV):
priority, weight, port – as strings
"""
...
def setup(self):
"""
Optional. Called via ``python server.py --setup``.
Use this to create tables, initialize schemas, etc.
"""
...Key helper: self.normalize_name(name, zone) converts relative names (@, www, *.dev) to FQDNs. Always use it when building record dicts.
# Port 53 (requires root)
sudo python3 server.py
# Custom config path
sudo python3 server.py /etc/sheetdns/config.yaml
# High port for testing (no root needed — set port: 5053 in config.yaml)
python3 server.py
# One-time backend setup (e.g. create PostgreSQL tables)
python3 server.py --setup
python3 server.py --setup /path/to/config.yamlThe server loads all zone data on startup, then begins accepting DNS queries. Zone data is refreshed in the background every refresh_interval seconds.
Press Ctrl+C or send SIGTERM to shut down gracefully. The server logs response cache statistics on exit.
Create /etc/systemd/system/sheetdns-dns.service:
[Unit]
Description=sheetdns DNS Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/sheetdns
ExecStart=/opt/sheetdns/venv/bin/python3 /opt/sheetdns/server.py /opt/sheetdns/config.yaml
Restart=always
RestartSec=5
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/opt/sheetdns
PrivateTmp=yes
# Allow binding to port 53
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now sheetdns-dns
sudo journalctl -u sheetdns-dns -fFROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py config.yaml ./
COPY backends/ backends/
EXPOSE 53/udp 53/tcp
CMD ["python3", "server.py"]docker build -t sheetdns-dns .
docker run -d \
--name sheetdns-dns \
-p 53:53/udp \
-p 53:53/tcp \
-v $(pwd)/config.yaml:/app/config.yaml:ro \
sheetdns-dnsQuery the server using dig:
# A record (use @127.0.0.1 -p 5053 if using a high port)
dig @127.0.0.1 www.example.com A
# MX record
dig @127.0.0.1 example.com MX
# SOA record
dig @127.0.0.1 example.com SOA
# TXT record
dig @127.0.0.1 example.com TXT
# AAAA record
dig @127.0.0.1 www.example.com AAAA
# SRV record
dig @127.0.0.1 _sip._tcp.example.com SRV
# Wildcard test
dig @127.0.0.1 anything.dev.example.com A
# Test NXDOMAIN (name doesn't exist)
dig @127.0.0.1 nonexistent.example.com A
# Test REFUSED (zone not hosted here)
dig @127.0.0.1 google.com A
# TCP query
dig @127.0.0.1 example.com ANY +tcp
# Short output
dig @127.0.0.1 www.example.com A +shortExpected output for an A record query:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 12345
;; flags: qr aa; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;www.example.com. IN A
;; ANSWER SECTION:
www.example.com. 300 IN A 93.184.216.34
Key flags to verify:
aa— Authoritative Answer is setstatus: NOERROR— query was successfulANSWER: 1— one record returned
| Layer | Technique | Impact |
|---|---|---|
| Socket | 4 MB send/receive buffers | Absorbs traffic bursts without drops |
| Concurrency | 64 UDP threads on shared socket, kernel-demultiplexed | Saturates multi-core under load |
| Response cache | 100k-entry LRU of pre-serialized DNS packets | Cache hits bypass all parsing, lookup, and serialization; only a 2-byte query-ID patch |
| Zone cache | Python dict keyed by (fqdn, type) |
O(1) exact-match lookups |
| Wildcards | Iterative suffix stripping with dict lookups | O(label depth), typically 2-3 lookups |
| Zone reload | Atomic swap of entire dict under lock | Zero downtime; readers never see partial state |
| Cache invalidation | Serial-number comparison on each query | Response cache auto-clears on zone refresh, no stale data |
| Backend loading | Single batch query (Sheets batchGet / Postgres JOIN) |
Minimizes round-trips |
udp_workers: Set to roughly 2x your CPU core count. More threads than cores adds context-switch overhead without improving throughput. For a 16-core server, 32-64 workers is a good starting point.
cache.max_entries: Size this for your expected cardinality of unique (qname, qtype) pairs. At ~512 bytes per entry, 100,000 entries uses ~50 MB. For a zone with 10,000 records queried across 5 record types, 50,000 entries gives excellent hit rates.
backend.refresh_interval: Balance between change propagation speed and backend load. For Google Sheets, keep it at 30s+ to stay within the API quota (300 req/min). For PostgreSQL, you can safely go as low as 5-10s.
Socket buffers: The server requests 4 MB send/receive buffers. If the kernel cap is lower (check sysctl net.core.rmem_max), increase it:
sudo sysctl -w net.core.rmem_max=4194304
sudo sysctl -w net.core.wmem_max=4194304The server implements correct authoritative DNS semantics:
| Scenario | Response Code | Authority Section | Description |
|---|---|---|---|
| Record found | NOERROR |
— | Answer section contains matching records |
| Name exists, wrong type | NOERROR |
SOA | NODATA — name exists but not for the queried type |
| Name doesn't exist | NXDOMAIN |
SOA | Name not found in any authoritative zone |
| Zone not hosted | REFUSED |
— | Server is not authoritative for the queried zone |
| CNAME at name | NOERROR |
— | Returns the CNAME when querying for A/AAAA/etc. at a CNAME name |
| Wildcard match | NOERROR |
— | Answer uses the queried name (not *.zone) per RFC 4592 |
Host as many zones as you need. How you add them depends on your backend:
Google Sheets — add a new worksheet tab. The server discovers tabs automatically on each refresh.
PostgreSQL — insert a row into the zones table and add records referencing it.
INSERT INTO zones (name) VALUES ('newzone.org');
INSERT INTO records (zone_id, name, type, value, ttl)
VALUES (currval('zones_id_seq'), '@', 'A', '10.0.0.1', 300);No config changes or restarts are needed. New zones are picked up on the next refresh cycle.
For reverse DNS, create a zone named after the reverse zone (e.g. 10.in-addr.arpa):
| Name | Type | Value | TTL |
|---|---|---|---|
| @ | SOA | ns1.example.com. admin.example.com. 2024010101 3600 900 604800 86400 | 86400 |
| @ | NS | ns1.example.com | 86400 |
| 1.0.0 | PTR | gateway.example.com | 300 |
| 2.0.0 | PTR | server1.example.com | 300 |
| 3.0.0 | PTR | server2.example.com | 300 |
Here 1.0.0 expands to 1.0.0.10.in-addr.arpa which is the PTR name for 10.0.0.1.
For IPv6, use the ip6.arpa zone with nibble-format names.
Server won't start — "Address already in use"
Another process is using port 53. Check with sudo lsof -i :53 or sudo ss -tlnp sport = :53. On Ubuntu/Debian, systemd-resolved often occupies port 53:
sudo systemctl disable --now systemd-resolvedServer won't start — "Permission denied"
Port 53 requires root. Either run with sudo, use setcap, or use a high port for testing:
sudo setcap cap_net_bind_service=+ep $(which python3)"Cannot import backend module" on startup
The dependencies for your chosen backend aren't installed. Install them:
pip install google-api-python-client google-auth # for type: sheets
pip install psycopg2-binary # for type: postgres"Unknown backend type"
Check the backend.type value in config.yaml. Valid built-in values are sheets and postgres. For a custom backend, use the full import path: my_module.MyBackend.
"Failed to load zone data" on startup
For Google Sheets:
- Verify
credentials.jsonexists and is a valid service-account key file. - Verify the spreadsheet is shared with the service account email.
- Verify
spreadsheet_idmatches the ID in the spreadsheet URL. - Verify the Google Sheets API is enabled in your GCP project.
For PostgreSQL:
- Verify the database is reachable:
psql -U dns -h localhost dns - Verify the tables exist: run
python3 server.py --setup - Check connection parameters in
config.yaml.
Records not updating
Changes propagate on the next background refresh cycle (default: 30 seconds). Check the logs for Zone cache updated messages. Set log level to DEBUG for verbose output.
REFUSED responses for a zone you're hosting
The zone name must exactly match the zone identifier (worksheet tab name or zones.name column). Comparison is case-insensitive, but make sure there are no trailing spaces or hidden characters.
Queries timing out
- Check firewall rules — port 53 must be open for both UDP and TCP.
- Verify the server is bound to the correct interface (
0.0.0.0for all interfaces). - Check
dig @127.0.0.1from the server itself to rule out network issues.
High memory usage
Reduce cache.max_entries in config.yaml. Each cache entry is ~512 bytes, so 100,000 entries ≈ 50 MB. For smaller deployments, 10,000 is sufficient.
sheetdns/
├── server.py # DNS server: UDP/TCP listeners, zone cache,
│ # response cache, resolver, entry point
├── backends/
│ ├── __init__.py # BaseBackend class, backend loader, registry
│ ├── sheets.py # Google Sheets backend
│ └── postgres.py # PostgreSQL backend
├── config.yaml # Server configuration
├── requirements.txt # Python dependencies
└── credentials.json # GCP service-account key (Sheets backend only)
| Component | File | Description |
|---|---|---|
BaseBackend |
backends/__init__.py |
Abstract base class for all backends |
load_backend() |
backends/__init__.py |
Factory that instantiates the configured backend |
SheetsBackend |
backends/sheets.py |
Google Sheets API client — batch-loads all worksheets |
PostgresBackend |
backends/postgres.py |
PostgreSQL client — single JOIN query loads all zones |
LRUCache |
server.py |
Thread-safe LRU cache for pre-serialized DNS response packets |
ZoneCache |
server.py |
In-memory zone data store with O(1) dict lookups and wildcard fallback |
DNSResolver |
server.py |
Query resolution engine — lookup, RR building, response caching |
UDPWorker |
server.py |
Worker thread calling recvfrom on the shared UDP socket |
TCPListener |
server.py |
TCP accept loop spawning per-connection TCPConnection threads |
DNSServer |
server.py |
Top-level orchestrator — config, startup, shutdown, background refresh |
- Python GIL: CPU-bound DNS processing is single-threaded despite multiple worker threads. For the highest possible throughput (millions of QPS), consider placing this behind a DNS load balancer or running multiple server processes.
- No DNSSEC: The server does not sign responses. Use a DNSSEC-signing proxy if signatures are required.
- No zone transfers (AXFR/IXFR): Secondary DNS servers cannot replicate from this server via zone transfers.
- No recursion: This is a pure authoritative server. It does not resolve queries for zones it doesn't host — it returns
REFUSED. - Eventual consistency: Record changes take up to
refresh_intervalseconds to go live. - Backend-specific limits: Google Sheets supports ~1.4M records (10M cells / 7 columns). PostgreSQL is limited only by available memory for the in-memory cache.