Skip to main content

·629 words·3 mins

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