·629 words·3 mins
Table of Contents
VTD Homelab – Internal DNS, step-ca, Traefik (End-to-End Guide) #
This document is a complete, working, end-to-end guide for building an internal PKI + HTTPS platform for a homelab.
It documents exactly what was built, including fixes discovered along the way.
Architecture Overview #
- Host OS: CentOS 9
- Docker host IP: 192.168.1.50
- LAN: 192.168.1.0/24
- Internal domain: vtd.internal
- Storage root: /storage
- DNS: CoreDNS
- Internal CA: step-ca
- Reverse proxy: Traefik
- ACME: Internal (step-ca)
- Firewall: firewalld (LAN + Docker-only)
Folder Layout #
/storage
├── coredns
├── stepca
├── traefik
├── whoami
└── hassio
1. Create Docker Network #
docker network create homelab || true
2. CoreDNS (Internal DNS) #
2.1 Create directories #
mkdir -p /storage/coredns/{config,zones}
2.2 DNS zone #
/storage/coredns/zones/vtd.internal.zone
$ORIGIN vtd.internal.
$TTL 60
@ IN SOA ns.vtd.internal. admin.vtd.internal. (
2026010501
3600
600
1209600
60
)
IN NS ns.vtd.internal.
ns IN A 192.168.1.50
@ IN A 192.168.1.50
* IN A 192.168.1.50
2.3 Corefile #
/storage/coredns/config/Corefile
.:53 {
log
errors
health
ready
file /zones/vtd.internal.zone vtd.internal
forward . 1.1.1.1 9.9.9.9
cache 30
}
2.4 Docker Compose #
/storage/coredns/docker-compose.yml
version: "3.8"
services:
coredns:
image: coredns/coredns:latest
container_name: coredns
command: -conf /etc/coredns/Corefile
volumes:
- ./config/Corefile:/etc/coredns/Corefile:ro
- ./zones:/zones:ro
ports:
- "53:53/udp"
- "53:53/tcp"
networks:
- homelab
restart: unless-stopped
networks:
homelab:
external: true
Start:
cd /storage/coredns
docker compose up -d
3. Host DNS (CentOS 9) #
nmcli -t -f NAME,DEVICE,TYPE con show --active
Example connection name: enp88s0
sudo nmcli con mod "enp88s0" ipv4.dns "192.168.1.50"
sudo nmcli con mod "enp88s0" ipv4.ignore-auto-dns yes
sudo nmcli con up "enp88s0"
Verify:
dig +short whoami.vtd.internal
4. Docker DNS (Required for ACME) #
/etc/docker/daemon.json
{
"dns": ["192.168.1.50"]
}
Restart Docker:
sudo systemctl restart docker
Verify:
docker run --rm alpine sh -lc 'apk add --no-cache bind-tools >/dev/null; nslookup whoami.vtd.internal'
5. step-ca (Internal CA) #
5.1 Directories #
mkdir -p /storage/stepca/{config,secrets}
5.2 Password #
openssl rand -base64 32 > /storage/stepca/secrets/step-ca-password.txt
chmod 644 /storage/stepca/secrets/step-ca-password.txt
5.3 Initialize CA #
docker run --rm -it \
-v "/storage/stepca/config:/home/step" \
-v "/storage/stepca/secrets:/secrets:ro" \
-e STEPPATH=/home/step \
smallstep/step-ca:latest \
step ca init \
--name "VTD Internal CA" \
--dns "stepca.vtd.internal" \
--address ":8443" \
--provisioner "acme" \
--password-file /secrets/step-ca-password.txt
5.4 Runtime password location #
mkdir -p /storage/stepca/config/secrets
cp /storage/stepca/secrets/step-ca-password.txt /storage/stepca/config/secrets/password
chmod 600 /storage/stepca/config/secrets/password
5.5 Fix provisioner name collision #
Rename JWK provisioner:
python3 - <<'PY'
import json
p="/storage/stepca/config/config/ca.json"
j=json.load(open(p))
for x in j["authority"]["provisioners"]:
if x.get("type")=="JWK" and x.get("name")=="acme":
x["name"]="jwk"
json.dump(j, open(p,"w"), indent=2)
PY
5.6 Docker Compose #
/storage/stepca/docker-compose.yml
version: "3.8"
services:
stepca:
image: smallstep/step-ca:latest
container_name: stepca
environment:
- STEPPATH=/home/step
volumes:
- ./config:/home/step
ports:
- "8443:8443"
dns:
- 192.168.1.50
networks:
- homelab
restart: unless-stopped
networks:
homelab:
external: true
6. Traefik #
6.1 Directories #
mkdir -p /storage/traefik/{data,dynamic}
touch /storage/traefik/data/acme.json
chmod 600 /storage/traefik/data/acme.json
6.2 traefik.yml #
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: homelab
file:
directory: /dynamic
watch: true
certificatesResolvers:
stepca:
acme:
caServer: https://stepca.vtd.internal:8443/acme/acme/directory
storage: /data/acme.json
httpChallenge:
entryPoint: web
6.3 Docker Compose #
/storage/traefik/docker-compose.yml
version: "3.8"
services:
traefik:
image: traefik:latest
container_name: traefik
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./data:/data
- ./dynamic:/dynamic
dns:
- 192.168.1.50
networks:
- homelab
restart: unless-stopped
networks:
homelab:
external: true
7. Firewall (LAN + Docker only) #
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="80" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.1.0/24" port protocol="tcp" port="443" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.17.0.0/16" port protocol="tcp" port="80" accept'
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.21.0.0/16" port protocol="tcp" port="80" accept'
sudo firewall-cmd --reload
8. Example Service (whoami) #
labels:
- traefik.enable=true
- traefik.http.routers.whoami.rule=Host(`whoami.vtd.internal`)
- traefik.http.routers.whoami.entrypoints=websecure
- traefik.http.routers.whoami.tls=true
- traefik.http.routers.whoami.tls.certresolver=stepca
9. Home Assistant (host network) #
Traefik dynamic config:
http:
routers:
homeassistant:
rule: Host(`homeassistant.vtd.internal`)
entryPoints:
- websecure
tls:
certResolver: stepca
service: homeassistant
services:
homeassistant:
loadBalancer:
servers:
- url: http://192.168.1.50:8123
Home Assistant config:
http:
use_x_forwarded_for: true
trusted_proxies:
- 192.168.1.50
Final Verification #
openssl s_client -connect whoami.vtd.internal:443 -servername whoami.vtd.internal </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject
Expected issuer: VTD Internal CA
Status #
✅ Internal DNS
✅ Internal PKI
✅ Dynamic HTTPS
✅ LAN-only firewall
✅ Fully automated