DNSControl + CoreDNS Container Example - Announcement
Building a Test-Driven DNS Pipeline with DNSControl and CoreDNS
Current Situation Analysis
DNS management remains one of the most fragile components of modern infrastructure. Despite the widespread adoption of GitOps, infrastructure-as-code, and automated CI/CD pipelines, DNS configuration frequently lags behind. Teams typically rely on manual zone file edits, syntax-only validation, and hope-based deployments. When changes propagate, resolution failures often surface only after user impact occurs, making rollback procedures reactive rather than preventive.
This problem is frequently overlooked because DNS has historically been treated as a static, low-churn service. The operational model hasn't evolved alongside container orchestration and declarative configuration paradigms. Most organizations validate configuration syntax using linters or provider-specific dry-run commands, assuming that a valid BIND zone file guarantees correct behavior. In reality, syntax validation catches formatting errors but misses logical misconfigurations, plugin conflicts, and resolver-specific quirks.
Modern DNS workflows can decouple configuration from serving. DNSControl translates declarative JavaScript definitions into standard BIND zone files. These files can be consumed directly by CoreDNS, eliminating the operational overhead of legacy BIND servers while maintaining full RFC compliance. Automated test suites can then query the running resolver to validate A, AAAA, CNAME, TXT, NS, and MX records against expected responses. This shifts validation from static parsing to dynamic verification, catching misconfigurations before they reach production. The combination of declarative configuration, container-native resolution, and programmatic testing creates a feedback loop that aligns DNS management with modern DevOps standards.
WOW Moment: Key Findings
The transition from traditional BIND workflows to a DNSControl + CoreDNS pipeline fundamentally changes how DNS changes are validated and deployed. The following comparison highlights the operational shift:
| Approach | Configuration Language | Validation Method | Deployment Model | Test Coverage | Operational Overhead |
|---|---|---|---|---|---|
| Traditional BIND | Raw zone files (RFC 1035) | Syntax check + manual dig |
Bare metal / VM | Manual, ad-hoc | High (patching, config drift) |
| DNSControl + CoreDNS | JavaScript (declarative) | Automated resolution tests | Containerized | Programmatic, repeatable | Low (immutable images, GitOps) |
This finding matters because it transforms DNS from a manual operations task into a testable, version-controlled artifact. Teams can now run integration tests against a local resolver before merging changes, enforce RFC 2606 (reserved domains) and RFC 1918/4193 (private addressing) compliance automatically, and deploy resolvers as stateless containers. The pipeline enables immediate feedback, reduces human error, and provides a clear path from configuration development to production serving without vendor lock-in.
Core Solution
The architecture separates three concerns: configuration definition, zone generation, and resolution serving. DNSControl handles the first two, while CoreDNS handles the third. A Go-based test suite validates the final output.
Step 1: Declarative Configuration
DNSControl uses JavaScript to define DNS records. This approach provides type safety, reusable functions, and multi-provider abstraction. Instead of writing raw BIND syntax, you define domains and records programmatically.
// dnsconfig.js
var REG_NONE = NewRegistrar("none", "NONE");
var DSP_BIND = NewDnsProvider("bind", "BIND");
D("infra.internal", REG_NONE, DnsProvider(DSP_BIND),
DefaultTTL(300),
A("web", "10.0.1.10"),
A("api", "10.0.1.11"),
AAAA("web", "fd00:db8::10"),
AAAA("api", "fd00:db8::11"),
CNAME("cdn", "web.infra.internal."),
MX("@", 10, "mail.infra.internal."),
TXT("@", "v=spf1 ip4:10.0.1.0/24 -all"),
NS("@", "ns1.infra.internal."),
NS("@", "ns2.infra.internal."),
A("ns1", "10.0.1.20"),
A("ns2", "10.0.1.21"),
A("mail", "10.0.1.30")
);
Why this structure? Using DefaultTTL reduces repetition. Grouping records by service (web, api, mail) improves readability. The NONE registrar indicates this configuration is for internal zone generation, not external registrar management.
Step 2: Zone Generation
DNSControl compiles the JavaScript into standard BIND zone files. The push command writes the output to a designated directory.
dnscontrol push --domains=infra.internal --providers=bind
This generates infra.internal.zone containing properly formatted SOA, NS, A, AAAA, CNAME, MX, and TXT records. The output is fully RFC 1035 compliant and ready for consumption by any BIND-compatible resolver.
Step 3: CoreDNS Serving Configuration
CoreDNS natively reads BIND zone files. The file plugin loads the generated zone and serves it over UDP/TCP.
# Corefile
.:1053 {
errors
log
health {
lameduck 5s
}
file /etc/coredns/zones/infra.internal.zone {
reload 60s
}
prometheus :9153
forward . /etc/resolv.conf
}
Architecture rationale: The file plugin is stateless and memory-efficient. The reload directive allows CoreDNS to pick up zone changes without restarting. health and prometheus plugins enable Kubernetes readiness probes and metrics scraping. forward handles external resolution fallback.
Step 4: Containerization
Packaging CoreDNS with the generated zones creates an immutable, reproducible resolver.
# Dockerfile
FROM coredns/coredns:1.11.1
COPY Corefile /etc/coredns/Corefile
COPY zones/ /etc/coredns/zones/
EXPOSE 1053/udp 1053/tcp 9153/tcp
USER 1000:1000
ENTRYPOINT ["/coredns"]
CMD ["-conf", "/etc/coredns/Corefile"]
Running as a non-root user (1000:1000) follows container security best practices. The image includes only the necessary configuration and zone files, minimizing attack surface.
Step 5: Automated Resolution Testing
Syntax validation is insufficient. A Go test suite queries the running resolver and validates actual DNS responses.
// dns_test.go
package main
import (
"net"
"testing"
"time"
"github.com/miekg/dns"
)
const resolverAddr = "127.0.0.1:1053"
func TestARecordResolution(t *testing.T) {
m := new(dns.Msg)
m.SetQuestion("web.infra.internal.", dns.TypeA)
m.RecursionDesired = true
c := &dns.Client{Timeout: 2 * time.Second}
r, _, err := c.Exchange(m, resolverAddr)
if err != nil {
t.Fatalf("DNS query failed: %v", err)
}
if len(r.Answer) == 0 {
t.Fatal("Expected A record, got empty response")
}
aRecord, ok := r.Answer[0].(*dns.A)
if !ok {
t.Fatalf("Expected A record type, got %T", r.Answer[0])
}
expectedIP := net.ParseIP("10.0.1.10")
if !aRecord.A.Equal(expectedIP) {
t.Errorf("Expected IP %s, got %s", expectedIP, aRecord.A)
}
}
func TestMXRecordPriority(t *testing.T) {
m := new(dns.Msg)
m.SetQuestion("infra.internal.", dns.TypeMX)
m.RecursionDesired = true
c := &dns.Client{Timeout: 2 * time.Second}
r, _, err := c.Exchange(m, resolverAddr)
if err != nil {
t.Fatalf("DNS query failed: %v", err)
}
if len(r.Answer) == 0 {
t.Fatal("Expected MX record, got empty response")
}
mxRecord, ok := r.Answer[0].(*dns.MX)
if !ok {
t.Fatalf("Expected MX record type, got %T", r.Answer[0])
}
if mxRecord.Preference != 10 {
t.Errorf("Expected MX priority 10, got %d", mxRecord.Preference)
}
}
Why Go? The miekg/dns library provides low-level DNS protocol access, enabling precise validation of record types, priorities, TTLs, and response codes. Tests run against the actual resolver, catching plugin misconfigurations, zone loading errors, and network binding issues that syntax checkers miss.
Pitfall Guide
1. Testing Syntax Instead of Resolution
Explanation: Running dnscontrol check validates JavaScript syntax and provider compatibility but doesn't verify that CoreDNS loads the zone correctly or that records resolve over the network.
Fix: Always run integration tests against a live resolver instance. Use docker compose up or a local container to spin up CoreDNS before executing the test suite.
2. Ignoring Zone Serial Management
Explanation: BIND zones require a serial number for secondary servers to detect changes. Manually updating the serial leads to drift or missed updates.
Fix: Use DNSControl's SERIAL_UNIXTIME or SERIAL_INCREMENT directive to automate serial generation. Example: DefaultTTL(300), SERIAL_UNIXTIME() ensures the serial reflects the build timestamp.
3. Hardcoding Resolver IPs in Tests
Explanation: Tests that assume 127.0.0.1:1053 will fail in CI/CD environments or when running multiple resolver instances.
Fix: Parameterize the resolver address using environment variables or test flags. Example: os.Getenv("DNS_RESOLVER_ADDR") with a fallback to 127.0.0.1:1053.
4. Overlooking CoreDNS Plugin Ordering
Explanation: CoreDNS processes plugins in the order they appear in the Corefile. Misordering can cause caching, logging, or forwarding to behave unexpectedly.
Fix: Place file before forward to ensure internal zones are resolved locally before external fallback. Place errors and log at the top for consistent observability.
5. Neglecting RFC Compliance in Internal Zones
Explanation: Using public IP ranges or unreserved domains in internal DNS causes routing conflicts and security warnings.
Fix: Strictly use RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (fd00::/8) for internal addressing. Reserve .internal, .local, or .test domains per RFC 2606.
6. Failing to Version Control Generated Zones
Explanation: Treating generated zone files as ephemeral artifacts makes debugging difficult and breaks audit trails.
Fix: Commit the output of dnscontrol push to a dedicated zones/ directory. Use Git hooks or CI pipelines to regenerate zones on every config change and track diffs.
7. Running Containers Without Resource Constraints
Explanation: CoreDNS is lightweight but can consume excessive memory under high query volume or misconfigured forwarding loops.
Fix: Set CPU/memory limits in container orchestration. Example: resources: { limits: { memory: "128Mi", cpu: "250m" } }. Monitor with Prometheus and alert on query latency spikes.
Production Bundle
Action Checklist
- Define DNS records using DNSControl JavaScript with explicit TTLs and serial strategy
- Generate BIND zone files using
dnscontrol pushand commit to version control - Configure CoreDNS with
fileplugin, reload interval, and observability plugins - Build container image with non-root user and minimal base layer
- Implement Go test suite covering A, AAAA, CNAME, MX, TXT, and NS records
- Integrate test execution into CI/CD pipeline before deployment approval
- Configure Prometheus metrics scraping and alerting for query latency/errors
- Document zone update procedure and rollback strategy for on-call engineers
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Internal microservices routing | DNSControl + CoreDNS container | Low latency, full control, GitOps compatible | Minimal (compute only) |
| Multi-cloud public DNS | DNSControl + Cloud Provider API | Native integration, global Anycast, DDoS protection | Moderate (provider fees) |
| Compliance-heavy regulated environments | DNSControl + CoreDNS + Air-gapped CI | Audit trails, RFC compliance, no external dependencies | High (infrastructure overhead) |
| Rapid prototyping / dev environments | DNSControl + local CoreDNS | Fast iteration, no cloud costs, easy teardown | Near zero |
Configuration Template
// dnsconfig.js
var REG_NONE = NewRegistrar("none", "NONE");
var DSP_BIND = NewDnsProvider("bind", "BIND");
D("platform.internal", REG_NONE, DnsProvider(DSP_BIND),
DefaultTTL(300),
SERIAL_UNIXTIME(),
A("gateway", "10.10.0.1"),
A("auth", "10.10.0.2"),
A("cache", "10.10.0.3"),
AAAA("gateway", "fd00:cafe::1"),
AAAA("auth", "fd00:cafe::2"),
CNAME("dashboard", "gateway.platform.internal."),
MX("@", 20, "smtp.platform.internal."),
TXT("@", "v=spf1 ip4:10.10.0.0/24 -all"),
NS("@", "ns1.platform.internal."),
NS("@", "ns2.platform.internal."),
A("ns1", "10.10.0.10"),
A("ns2", "10.10.0.11"),
A("smtp", "10.10.0.20")
);
# Corefile
.:1053 {
errors
log
health
file /etc/coredns/zones/platform.internal.zone {
reload 60s
}
prometheus :9153
forward . /etc/resolv.conf
loadbalance
}
# Dockerfile
FROM coredns/coredns:1.11.1
COPY Corefile /etc/coredns/Corefile
COPY zones/ /etc/coredns/zones/
EXPOSE 1053/udp 1053/tcp 9153/tcp
USER 1000:1000
ENTRYPOINT ["/coredns"]
CMD ["-conf", "/etc/coredns/Corefile"]
Quick Start Guide
- Initialize configuration: Create
dnsconfig.jswith your domain and records. Rundnscontrol pushto generate the BIND zone file. - Start resolver: Place the generated zone and
Corefilein azones/directory. Build the container:docker build -t internal-dns . - Run container:
docker run -d -p 1053:1053/udp -p 1053:1053/tcp -p 9153:9153/tcp internal-dns - Validate: Execute
go test ./...against the running container. Verify responses withdig @127.0.0.1 -p 1053 gateway.platform.internal A - Integrate: Add the test step to your CI pipeline. Trigger zone regeneration and container rebuild on
dnsconfig.jschanges.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
