feat(dns): Add self-hosted PowerDNS for 5 managed domains

- Deploy PowerDNS on k3s with SQLite backend
- Add DNS ports 53 UDP/TCP to security group
- Configure zones for superviber.com, muffinlabs.ai, letemcook.com,
  appbasecamp.com, thanksforborrowing.com
- Add deploy-powerdns.sh standalone deployment script
- Document in RFC 0003

Glue records updated at GoDaddy to point ns1/ns2 to 3.218.167.115.
DNS verified working via Google DNS (8.8.8.8).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 07:42:48 -05:00
parent 0d904fe130
commit da40273177
3 changed files with 738 additions and 1 deletions

View file

@ -0,0 +1,340 @@
# RFC 0003: PowerDNS Self-Hosted DNS
| | |
|---|---|
| **Status** | Implemented |
| **Created** | 2026-01-24 |
| **Related** | [coherence-mcp RFC 0046](../../coherence-mcp/docs/rfcs/0046-domain-email-migration.md) |
---
## Problem
We have 5 domains registered at GoDaddy that need DNS management:
- superviber.com
- muffinlabs.ai
- letemcook.com
- appbasecamp.com
- thanksforborrowing.com
Current state:
1. **Glue records exist** at GoDaddy pointing ns1/ns2.superviber.com to IPs 3.12.26.86 and 3.140.40.205
2. **No DNS server running** at those IPs (planned EKS infrastructure never deployed)
3. **Forgejo running** at 3.218.167.115 on k3s (hearth minimal), but DNS not configured
4. **Cloudflare partially configured** for superviber.com but nameservers not switched
We want self-hosted DNS for:
- Control over all records without third-party dashboard
- Integration with other self-hosted services (mail, git, auth)
- Single source of truth in infrastructure-as-code
## Goals
1. **Deploy PowerDNS** on existing k3s infrastructure (hearth)
2. **Configure zones** for all 5 managed domains
3. **Expose DNS** via port 53 UDP/TCP
4. **Update glue records** at GoDaddy to point to working DNS servers
5. **Add git.beyondtheuniverse.superviber.com** record pointing to Forgejo
## Non-Goals
- HA DNS with 3 separate IPs (future scope, current setup is single-user)
- DNSSEC (future scope)
- Secondary/slave DNS (future scope)
- DNS-over-HTTPS or DNS-over-TLS (future scope)
---
## Proposal
### Architecture
Deploy PowerDNS on the existing hearth k3s instance (3.218.167.115) as a single authoritative DNS server.
```
┌─────────────────────────────────────┐
│ EC2: 3.218.167.115 │
│ (hearth-forgejo) │
│ │
┌─────────────┐ │ ┌─────────────────────────────┐ │
│ GoDaddy │ │ │ k3s │ │
│ Glue: │ │ │ │ │
│ ns1/ns2 ────┼────────►│ │ ┌─────────┐ ┌──────────┐ │ │
│ .superviber │ :53 │ │ │PowerDNS │ │ Forgejo │ │ │
│ .com │ │ │ │ :53 │ │ :3000 │ │ │
└─────────────┘ │ │ └─────────┘ └──────────┘ │ │
│ │ │ │
│ │ Traefik (ingress) │ │
│ │ :80, :443, :22 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Why Single Instance
For a ~1 user personal infrastructure:
- Simpler ops (one machine to manage)
- Lower cost (~$7.50/month total)
- Acceptable availability (spot instance with persistent EIP)
- GoDaddy remains as backup (can revert NS records if needed)
### DNS Records
Initial zones will include:
**superviber.com:**
```
@ A 3.218.167.115
ns1 A 3.218.167.115
ns2 A 3.218.167.115
beyondtheuniverse A 3.218.167.115
git.beyondtheuniverse A 3.218.167.115
mail.beyondtheuniverse A 3.218.167.115
auth.beyondtheuniverse A 3.218.167.115
@ NS ns1.superviber.com.
@ NS ns2.superviber.com.
```
Other domains (muffinlabs.ai, etc.) will have minimal records pointing to the same infrastructure.
---
## Implementation
### Phase 1: Deploy PowerDNS on k3s
Add to hearth user-data.sh or apply directly:
```yaml
apiVersion: v1
kind: Namespace
metadata:
name: dns
---
apiVersion: v1
kind: Secret
metadata:
name: powerdns-api-key
namespace: dns
type: Opaque
stringData:
api-key: "GENERATED_SECRET_KEY"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: powerdns
namespace: dns
spec:
replicas: 1
selector:
matchLabels:
app: powerdns
template:
metadata:
labels:
app: powerdns
spec:
containers:
- name: powerdns
image: powerdns/pdns-auth-49:4.9.2
ports:
- name: dns-udp
containerPort: 53
protocol: UDP
- name: dns-tcp
containerPort: 53
protocol: TCP
- name: api
containerPort: 8081
protocol: TCP
env:
- name: PDNS_AUTH_API_KEY
valueFrom:
secretKeyRef:
name: powerdns-api-key
key: api-key
args:
- --api=yes
- --api-key=$(PDNS_AUTH_API_KEY)
- --webserver=yes
- --webserver-address=0.0.0.0
- --webserver-port=8081
- --launch=gsqlite3
- --gsqlite3-database=/var/lib/powerdns/pdns.sqlite3
volumeMounts:
- name: data
mountPath: /var/lib/powerdns
volumes:
- name: data
hostPath:
path: /data/powerdns
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: powerdns
namespace: dns
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: powerdns
ports:
- name: dns-udp
port: 53
targetPort: 53
protocol: UDP
- name: dns-tcp
port: 53
targetPort: 53
protocol: TCP
- name: api
port: 8081
targetPort: 8081
protocol: TCP
```
### Phase 2: Update Security Group
Add to hearth main.tf security group:
```hcl
# DNS (UDP)
ingress {
description = "DNS UDP"
from_port = 53
to_port = 53
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
}
# DNS (TCP)
ingress {
description = "DNS TCP"
from_port = 53
to_port = 53
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
```
### Phase 3: Configure Zones
Use PowerDNS API to create zones:
```bash
# Create zone
curl -X POST http://localhost:8081/api/v1/servers/localhost/zones \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "superviber.com.",
"kind": "Native",
"nameservers": ["ns1.superviber.com.", "ns2.superviber.com."]
}'
# Add records
curl -X PATCH http://localhost:8081/api/v1/servers/localhost/zones/superviber.com. \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"rrsets": [
{
"name": "superviber.com.",
"type": "A",
"ttl": 300,
"changetype": "REPLACE",
"records": [{"content": "3.218.167.115", "disabled": false}]
},
{
"name": "ns1.superviber.com.",
"type": "A",
"ttl": 3600,
"changetype": "REPLACE",
"records": [{"content": "3.218.167.115", "disabled": false}]
},
{
"name": "ns2.superviber.com.",
"type": "A",
"ttl": 3600,
"changetype": "REPLACE",
"records": [{"content": "3.218.167.115", "disabled": false}]
},
{
"name": "git.beyondtheuniverse.superviber.com.",
"type": "A",
"ttl": 300,
"changetype": "REPLACE",
"records": [{"content": "3.218.167.115", "disabled": false}]
}
]
}'
```
### Phase 4: Update GoDaddy Glue Records
At GoDaddy for superviber.com:
1. Update glue records to point ns1 and ns2 to 3.218.167.115
2. Set nameservers to ns1.superviber.com and ns2.superviber.com
### Phase 5: Verify
```bash
# Test directly against PowerDNS
dig @3.218.167.115 git.beyondtheuniverse.superviber.com A
# Test via public DNS (after propagation)
dig git.beyondtheuniverse.superviber.com A
```
---
## File Changes
| File | Change | Status |
|------|--------|--------|
| `hearth/terraform/minimal/main.tf` | Add DNS ports 53 UDP/TCP to security group | Done |
| `hearth/scripts/deploy-powerdns.sh` | Standalone PowerDNS deployment script | Done |
| `hearth/docs/rfcs/0003-powerdns-self-hosted.md` | This RFC | Done |
---
## Rollback Plan
If PowerDNS fails:
1. Update GoDaddy to use its default nameservers (restore from backup)
2. Or: Use Cloudflare (already partially configured)
The Elastic IP remains stable, so only NS records need updating.
---
## Test Plan
- [x] PowerDNS pod running in k3s
- [x] Port 53 UDP/TCP accessible from internet
- [x] `dig @3.218.167.115 superviber.com NS` returns ns1/ns2
- [x] `dig @3.218.167.115 git.beyondtheuniverse.superviber.com A` returns 3.218.167.115
- [x] GoDaddy glue records updated for all 5 domains (2026-01-24)
- [x] `dig @8.8.8.8 git.beyondtheuniverse.superviber.com A` resolves to 3.218.167.115
- [x] Forgejo accessible at https://git.beyondtheuniverse.superviber.com with valid TLS
---
## Future Work
1. **HA DNS** - Deploy on separate instance with its own EIP for redundancy
2. **DNSSEC** - Sign zones for security
3. **Backup zones** - Export zone files to S3 with daily backups
4. **DNS UI** - PowerDNS-Admin web interface for zone management
5. **Integration with Blue** - Manage DNS records as code via Blue
---
## References
- [PowerDNS Documentation](https://doc.powerdns.com/authoritative/)
- [coherence-mcp RFC 0046](../../coherence-mcp/docs/rfcs/0046-domain-email-migration.md)
- [coherence-mcp dns-zones-rfc0046.yaml](../../coherence-mcp/infra/kubernetes/powerdns/dns-zones-rfc0046.yaml)

352
scripts/deploy-powerdns.sh Executable file
View file

@ -0,0 +1,352 @@
#!/bin/bash
set -euo pipefail
# Deploy PowerDNS on k3s
# Usage: sudo ./deploy-powerdns.sh <PUBLIC_IP>
# Example: sudo ./deploy-powerdns.sh 3.218.167.115
if [ $# -lt 1 ]; then
echo "Usage: $0 <PUBLIC_IP>"
echo "Example: $0 3.218.167.115"
exit 1
fi
PUBLIC_IP="$1"
echo "=========================================="
echo "Deploying PowerDNS with IP: $PUBLIC_IP"
echo "=========================================="
# Create data directory
echo "Creating data directory..."
mkdir -p /data/powerdns
chown 953:953 /data/powerdns
# Generate API key
PDNS_API_KEY=$(openssl rand -hex 32)
echo "$PDNS_API_KEY" > /root/.pdns-api-key
chmod 600 /root/.pdns-api-key
echo "API key saved to /root/.pdns-api-key"
# Deploy PowerDNS
echo "Deploying PowerDNS to k3s..."
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
name: dns
---
apiVersion: v1
kind: Secret
metadata:
name: powerdns-api-key
namespace: dns
type: Opaque
stringData:
api-key: "$PDNS_API_KEY"
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: powerdns-data
spec:
capacity:
storage: 1Gi
accessModes:
- ReadWriteOnce
hostPath:
path: /data/powerdns
storageClassName: local
persistentVolumeReclaimPolicy: Retain
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: powerdns-data
namespace: dns
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: local
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: powerdns
namespace: dns
spec:
replicas: 1
selector:
matchLabels:
app: powerdns
strategy:
type: Recreate
template:
metadata:
labels:
app: powerdns
spec:
securityContext:
fsGroup: 953
initContainers:
- name: init-db
image: powerdns/pdns-auth-49:4.9.2
command:
- /bin/sh
- -c
- |
if [ ! -f /var/lib/powerdns/pdns.sqlite3 ]; then
sqlite3 /var/lib/powerdns/pdns.sqlite3 < /usr/local/share/doc/pdns/schema.sqlite3.sql
chown 953:953 /var/lib/powerdns/pdns.sqlite3
fi
volumeMounts:
- name: data
mountPath: /var/lib/powerdns
containers:
- name: powerdns
image: powerdns/pdns-auth-49:4.9.2
ports:
- name: dns-udp
containerPort: 53
protocol: UDP
- name: dns-tcp
containerPort: 53
protocol: TCP
- name: api
containerPort: 8081
protocol: TCP
env:
- name: PDNS_AUTH_API_KEY
valueFrom:
secretKeyRef:
name: powerdns-api-key
key: api-key
command:
- pdns_server
args:
- --api=yes
- --api-key=\$(PDNS_AUTH_API_KEY)
- --webserver=yes
- --webserver-address=0.0.0.0
- --webserver-port=8081
- --webserver-allow-from=0.0.0.0/0
- --launch=gsqlite3
- --gsqlite3-database=/var/lib/powerdns/pdns.sqlite3
- --local-address=0.0.0.0
- --local-port=53
volumeMounts:
- name: data
mountPath: /var/lib/powerdns
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: powerdns-data
---
apiVersion: v1
kind: Service
metadata:
name: powerdns
namespace: dns
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: powerdns
ports:
- name: dns-udp
port: 53
targetPort: 53
protocol: UDP
- name: dns-tcp
port: 53
targetPort: 53
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: powerdns-api
namespace: dns
spec:
type: ClusterIP
selector:
app: powerdns
ports:
- name: api
port: 8081
targetPort: 8081
protocol: TCP
EOF
echo "Waiting for PowerDNS pod to be ready..."
kubectl wait --for=condition=ready pod -l app=powerdns -n dns --timeout=120s
# Get PowerDNS API service IP
PDNS_HOST="http://$(kubectl get svc powerdns-api -n dns -o jsonpath='{.spec.clusterIP}'):8081"
echo "PowerDNS API: $PDNS_HOST"
# Wait for API to be ready
echo "Waiting for PowerDNS API..."
until curl -sf -H "X-API-Key: $PDNS_API_KEY" "$PDNS_HOST/api/v1/servers/localhost" > /dev/null 2>&1; do
sleep 2
done
echo "PowerDNS API is ready!"
# Function to create zone
create_zone() {
local DOMAIN=$1
echo "Creating zone: $DOMAIN"
curl -sf -X POST "$PDNS_HOST/api/v1/servers/localhost/zones" \
-H "X-API-Key: $PDNS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$DOMAIN.\",
\"kind\": \"Native\",
\"nameservers\": [\"ns1.$DOMAIN.\", \"ns2.$DOMAIN.\"]
}" 2>/dev/null || echo " (zone may already exist)"
}
# Function to setup records
setup_records() {
local DOMAIN=$1
echo "Setting up records for: $DOMAIN"
curl -sf -X PATCH "$PDNS_HOST/api/v1/servers/localhost/zones/$DOMAIN." \
-H "X-API-Key: $PDNS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"rrsets\": [
{
\"name\": \"$DOMAIN.\",
\"type\": \"NS\",
\"ttl\": 86400,
\"changetype\": \"REPLACE\",
\"records\": [
{\"content\": \"ns1.$DOMAIN.\", \"disabled\": false},
{\"content\": \"ns2.$DOMAIN.\", \"disabled\": false}
]
},
{
\"name\": \"ns1.$DOMAIN.\",
\"type\": \"A\",
\"ttl\": 3600,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"ns2.$DOMAIN.\",
\"type\": \"A\",
\"ttl\": 3600,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"$DOMAIN.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"www.$DOMAIN.\",
\"type\": \"CNAME\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$DOMAIN.\", \"disabled\": false}]
}
]
}"
}
echo ""
echo "Creating DNS zones..."
for domain in superviber.com muffinlabs.ai letemcook.com appbasecamp.com thanksforborrowing.com alignment.coop; do
create_zone $domain
setup_records $domain
done
# Setup superviber.com beyondtheuniverse services
echo ""
echo "Setting up beyondtheuniverse.superviber.com services..."
curl -sf -X PATCH "$PDNS_HOST/api/v1/servers/localhost/zones/superviber.com." \
-H "X-API-Key: $PDNS_API_KEY" \
-H "Content-Type: application/json" \
-d "{
\"rrsets\": [
{
\"name\": \"beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"git.beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"mail.beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"auth.beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"vault.beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
},
{
\"name\": \"grafana.beyondtheuniverse.superviber.com.\",
\"type\": \"A\",
\"ttl\": 300,
\"changetype\": \"REPLACE\",
\"records\": [{\"content\": \"$PUBLIC_IP\", \"disabled\": false}]
}
]
}"
echo ""
echo "=========================================="
echo "PowerDNS deployment complete!"
echo "=========================================="
echo ""
echo "Verification:"
echo " dig @$PUBLIC_IP superviber.com NS"
echo " dig @$PUBLIC_IP git.beyondtheuniverse.superviber.com A"
echo ""
echo "Next steps:"
echo "1. Update GoDaddy glue records for each domain:"
echo " - ns1.<domain> -> $PUBLIC_IP"
echo " - ns2.<domain> -> $PUBLIC_IP"
echo ""
echo "2. Update nameservers at GoDaddy:"
echo " - ns1.<domain>"
echo " - ns2.<domain>"
echo ""
echo "3. Wait for DNS propagation (up to 48 hours)"
echo "=========================================="

View file

@ -166,6 +166,24 @@ resource "aws_security_group" "forgejo" {
cidr_blocks = var.admin_cidr_blocks
}
# DNS (UDP) - PowerDNS authoritative server
ingress {
description = "DNS UDP"
from_port = 53
to_port = 53
protocol = "udp"
cidr_blocks = ["0.0.0.0/0"]
}
# DNS (TCP) - PowerDNS authoritative server
ingress {
description = "DNS TCP"
from_port = 53
to_port = 53
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
# All outbound
egress {
from_port = 0
@ -340,7 +358,7 @@ resource "aws_instance" "forgejo" {
}
lifecycle {
ignore_changes = [ami] # Don't replace on AMI updates
ignore_changes = [ami, user_data] # Don't replace on AMI or user-data changes
}
}
@ -395,3 +413,30 @@ output "dns_record" {
description = "DNS A record to create"
value = "${var.domain}${aws_eip.forgejo.public_ip}"
}
output "powerdns_deploy_command" {
description = "Command to deploy PowerDNS after instance is running"
value = <<-EOT
# Copy and run deployment script:
scp -P ${var.admin_ssh_port} scripts/deploy-powerdns.sh ec2-user@${aws_eip.forgejo.public_ip}:
ssh -p ${var.admin_ssh_port} ec2-user@${aws_eip.forgejo.public_ip} 'sudo bash deploy-powerdns.sh ${aws_eip.forgejo.public_ip}'
EOT
}
output "dns_glue_records" {
description = "Glue records to configure at GoDaddy after PowerDNS is running"
value = <<-EOT
For each managed domain, set nameservers and glue records:
Nameservers:
ns1.<domain>
ns2.<domain>
Glue Records:
ns1 ${aws_eip.forgejo.public_ip}
ns2 ${aws_eip.forgejo.public_ip}
Domains: superviber.com, muffinlabs.ai, letemcook.com,
appbasecamp.com, thanksforborrowing.com, alignment.coop
EOT
}