During these days, I’m tidying up my homelab and found the necessity of having an internal domain to expose some apps inside my local network but not to the internet.

For example, I use Vault to store secrets, and I want an easy way to access the web-ui rather than using the IP address. The solution in Kubernetes is to create an Ingress; right now, I only have Ingresses with my main domain diomedet.com but if I use it will be exposed to the whole internet, and I don’t want that.

external-dns is my tool of choice to handle the synchronization between my Ingresses and the DNS provider; on my local network, I use Pi-hole to filter all my DNS request and to block some of them.

Pi-hole already has a “Local DNS Records” section where you could list an arbitrary domain and point it to a specific IP inside or outside your network. So, if there is a way to make external-dns updates that list, what I’m trying to achieve would be possible with a bit of effort. Unfortunately, there is no way to update the list of local DNS records on Pi-hole programmatically at the moment of writing, so we’ve to find another way to do that.

Messing around with the interface of Pi-hole, I’ve noticed that under “Settings -> DNS” you can choose which DNS server redirects all the incoming requests that the blacklist has not blocked. Besides the classic list of “Upstream DNS Servers” there is also a list of custom upstream DNS servers:

Pi-hole DNS Settings

So, the idea is to create a custom DNS server that can be updated by external-dns and used by Pi-hole as an upstream DNS server. In this way, every Ingress with my internal domain will be resolved to the IP of my Kubernetes cluster.

Great, we’ve got a plan. Now it’s time to make it real!

First things first, we need a DNS server

Scouting between the providers supported by external-dns there a bunch of choices that can be self-hosted, something like PowerDNS or CoreDNS, at this point I was like:

“mmh interesting, CoreDNS is the one used by Kubernetes internally must be a good choice, let’s go with it.”

A colleague suggested using PowerDNS**, but I was already set on my path, so I stuck with CoreDNS.

To be clear, it’s not a wrong choice, but it might be a little overkill for this specific purpose but let’s see what difficulties this path reserved for us.

In the external-dns repo, there is a folder docs/tutorial with a markdown file for each supported provider (I think each didn’t count), we’re looking for the CoreDNS one: https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/coredns.md

It is a tutorial for minikube, but ignoring that part, we can use it for every Kubernetes cluster and bonus point, show us even how to install CoreDNS, great two birds with one stone.

If you’ve opened the file, you can see from the very beginning that the birds are not two anymore but three. The more, the merrier, right, right?!

If you haven’t opened the link, let me recap that for you what we need to install:

  • CoreDNS (obviously)
  • etcd
  • another instance of external-dns (you need an instance of external-dns for each dns provider you’re going to support)

Wait, why we need etcd?

We need etcd because this is the way how external-dns talks to CoreDNS, we have to create a section in the configuration of CoreDNS that tells him to read the value from a specific path on the etcd instance we’re going to configure and external-dns will update the same path with the information about the Ingresses we’re going to create with our internal domain.

Note: Before switching to etcd directly, CoreDNS was using SkyDNS (a service built on top of etcd) to serve these kinds of request, so, in the manifest files, we’re going to see you’ll find some refuse of that implementation.

Install etcd

Let’s get down to business and install etcd. In the end, it is a core component of Kubernetes; there nothing wrong with learning more about it. Just to let you know, don’t use the internal etcd for a user application (like the one we want to install here); it is not meant for that.

The tutorial linked above suggests we use the etcd-operator and use https://raw.githubusercontent.com/coreos/etcd-operator/HEAD/example/example-etcd-cluster.yaml to create our etcd cluster.

Great, an operator nothing more simple than that…

Slow down; the etcd-operator repo was archived more than a year ago; even if it could work for a case like this, we don’t want to install an operator that is not maintained anymore, so let’s see how to deploy it manually.

After searching around, I ended up on this documentation page https://etcd.io/docs/v3.4/op-guide/container/#docker that shows how to deploy, etcd with a single node configuration; prefect is what we need here.

Basically we need to port the command showed in the link in a manifest for kubernetes:

docker run from etcd documentation

docker run \
  -p 2379:2379 \
  -p 2380:2380 \
  --volume=${DATA_DIR}:/etcd-data \
  --name etcd ${REGISTRY}:latest \
  /usr/local/bin/etcd \
  --data-dir=/etcd-data --name node1 \
  --initial-advertise-peer-urls http://${NODE1}:2380 --listen-peer-urls \
  --advertise-client-urls http://${NODE1}:2379 --listen-client-urls \
  --initial-cluster node1=http://${NODE1}:2380

Manifest file etc-sts.yml

apiVersion: apps/v1
kind: StatefulSet
  name: etcd
    app.kubernetes.io/name: etcd
  serviceName: etcd
  replicas: 1
    type: OnDelete
      app.kubernetes.io/name: etcd
        app.kubernetes.io/name: etcd
        - name: etcd
          image: gcr.io/etcd-development/etcd:latest
          imagePullPolicy: IfNotPresent
          - /usr/local/bin/etcd
            - name: ETCD_NAME
              value: node1
            - name: ETCD_DATA_DIR
              value: /etcd-data
            - name: ETCD_LISTEN_PEER_URLS
            - name: ETCD_LISTEN_CLIENT_URLS
            - name: ETCD_ADVERTISE_CLIENT_URLS
            - name: ETCD_INITIAL_CLUSTER
              value: "node1="
            - name: data
              mountPath: /etcd-data
            - containerPort: 2379
              name: client
            - containerPort: 2380
              name: peer
    - metadata:
        name: data
          - ReadWriteOnce
            storage: 1Gi # we don't need much space to store DNS information

We’re going to use a StatefulSet because etcd is a stateful app and needs a volume to persist its data. Rather than the classic Deploy with a StatefulSet we’re certain that the generated pod will always receive the same name, and the volume attached to it will always be the same. More on the StatefulSet

The only noticeable difference between the docker run ... command and this manifest file is that we’re using environment variables instead of configuration flags. I had some trouble getting the flags working, and I like more the environment variables, anyway; here a list of etcd configuration flags with the matching variable.

Now, in order to expose etcd to the other applications in the cluster we need to create a Service too:


apiVersion: v1
kind: Service
  name: etcd
    app.kubernetes.io/name: etcd
    - port: 2379
      targetPort: 2379   
      name: client
    - port: 2380
      targetPort: 2380
      name: peer
    app.kubernetes.io/name: etcd

Nothing special here, but this completes the manifest needed for our etcd instance.

Install CoreDNS

Now that we have our etcd, we can continue with the tutorial and install our custom version of CoreDSN. You can use helm to install it, or if you want a more instructive approach, you can use helm template to render the file and applying them manually or with kustomize. In this way, you can check them individually to see what the chart will create in your cluster.

Since my homelab is a way to learn more about Kubernetes, I choose to render the file with helm template and use kustomize to apply them later.

Whichever way you choose, the important part is to set a couple of options inside the values.yml file correctly.

# if you don't have RBAC enabled on your cluster, I think you can set this to false
  create: true

# isClusterService specifies whether the chart should be deployed as cluster-service or regular k8s app.
isClusterService: true

- zones:  
  - zone: .
  port: 53
  # all other plugins
  - name: forward
    parameters: . # tells where to forward all the DNS requests that CoreDNS can't solve
  - name: etcd
    parameters: diomedet.internal # insert your domain here
    configBlock: |-
      path /skydns
      endpoint http://etcd:2379      

The most important part is the last one, we’re going to configure the etcd plugin and tell CoreDNS to look inside the http://etcd:2379 to find the information about the domain diomedet.internal (this is my internal domain)

Also, the forward part is important; it tells CoreDNS where to redirect all the DNS that it can’t solve. Later on, I’ll explain why it is crucial.

With these values, we can run the command.

helm template custom coredns/coredns --output-dir . --values values.yaml

(custom is the name of my release, then it’ll turn out in custom-coredns)

Helm will create a folder coredns/template with five files in it:

├── clusterrole.yaml
├── clusterrolebinding.yaml
├── configmap.yaml
├── deployment.yaml
└── service.yaml

Now the only thing we’ve to do is to kubectl apply these files, and we’ll end up with a working CoreDNS instance. Working but still not reachable outside the cluster, if you have MetalLB configured, you can change the ServiceType from ClusterIP to LoadBalancer to get an IP. I haven’t this feature in my cluster yet, so for now, I’m going to use the NodePort type; this allows me to use a port of my node and point it to the service.

With kustomize, there is the concept of patches, so I can create a patch that will modify the service.yaml file without directly touching it. I prefer this way, so if I have to re-run helm template ... I don’t have to mind any modification I could have made because kustomize will patch everything.


apiVersion: v1
kind: Service
  name: custom-coredns
  type: NodePort
  - {port: 53, protocol: UDP, name: udp-53, nodePort: 30053}
  - {port: 53, protocol: TCP, name: tcp-53, nodePort: 30053}

Here I tell Kubernetes to use the port 30053 for both UDP and TCP. With the NodePort, you can use ports from 30000 to 32767 if you do not modify it.

To wrap it up, here my kustomization.yml file:

kind: Kustomization
apiVersion: kustomize.config.k8s.io/v1beta1
namespace: custom-coredns
- templates/clusterrole.yaml
- templates/clusterrolebinding.yaml
- templates/configmap.yaml
- templates/deployment.yaml
- templates/service.yaml
- etcd-sts.yml
- etcd-service.yml
- patches/service.yaml

If you followed my path, you should have all the files to make it work, anyway. If you’ve installed the helm chart directly, you can always change the service manifest directly on Kubernetes. You can even set the serviceType in the values.yaml file, but I didn’t find a way to specify the nodePort to use, so I decided to go with the patch.

Finally, install external-dns

Now we can finally install the instance of external-dns that will monitor the Ingress created with our internal domain.

I have RBAC enabled on my cluster, so my manifest look like this:


apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
  name: external-dns
- apiGroups: [""]
  resources: ["services","endpoints","pods"]
  verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
  resources: ["ingresses"]
  verbs: ["get","watch","list"]
- apiGroups: [""]
  resources: ["nodes"]
  verbs: ["list"]
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
  name: external-dns-viewer
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: external-dns
- kind: ServiceAccount
  name: external-dns
  namespace: external-dns
apiVersion: v1
kind: ServiceAccount
  name: external-dns
  namespace: external-dns
apiVersion: apps/v1
kind: Deployment
  name: external-dns
    type: Recreate
      app: external-dns
        app: external-dns
      serviceAccountName: external-dns
      - name: external-dns
        image: k8s.gcr.io/external-dns/external-dns:v0.7.6
        - --source=ingress
        - --provider=coredns
        - --log-level=debug # debug only
        - --domain-filter=diomedet.internal
        - name: ETCD_URLS
          value: http://etcd.custom-coredns:2379

If you don’t have RBAC enable, you can use only the Deployment manifest.

This is the most straightforward part, just set the correct ETCD_URLS with the correct value, and you’re good to go. I have deployed my external-dns in a namespace different than the etcd one, so in the ETCD_URLS variable, I have to specify the service with the namespace too http://etcd.custom-coredns:2379

Once you applied your manifest you can create an ingress with the internal domain you chose, in my case is something like:


apiVersion: networking.k8s.io/v1beta1
kind: Ingress
    kubernetes.io/ingress.class: nginx
  name: vault-ui-internal
  namespace: vault
  - host: vault.diomedet.internal
      - backend:
          serviceName: vault
          servicePort: http
        path: /

After you create an Ingress with your internal domain on the external-dns pod, you should see a log like the following one:

level=debug msg="Endpoints generated from ingress: vault/vault-ui-internal: [vault.diomedet.internal 0 IN A []]" is the IP address of my Kubernetes cluster, it’s called “Scyther”, the Pokédex number of Scyther is #123, so here explained my IP, not that you asked, but here it is anyway :P

Now, if I use dig to check the name resolution, it should work correctly:

❯ dig @ -p 30053 vault.diomedet.internal

; <<>> DiG 9.11.3-1ubuntu1.13-Ubuntu <<>> @ -p 30053 vault.diomedet.internal
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5546
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

; EDNS: version: 0, flags:; udp: 4096
; COOKIE: bc40278d825e2b16 (echoed)
;vault.diomedet.internal.       IN      A

vault.diomedet.internal. 30     IN      A

;; Query time: 3 msec
;; WHEN: Sat May 08 16:11:48 CEST 2021
;; MSG SIZE  rcvd: 103

But if I run the nslookup command, I still get an error:

❯ nslookup vault.diomedet.internal

** server can't find vault.diomedet.internal: NXDOMAIN

This error appears because we still have to change the Pi-hole configurations.

Configure Pi-hole to use our new DNS Server

To configure Pi-hole, you need to return to DNS Setting tab http://pihole.local/admin/settings.php?tab=dns, uncheck all the “Upstream DNS Servers” and insert your custom one, in my case (the # is used to specify the port).

Pi-hole DNS Settings Updated

Now, if you run the nslookup command again, you should end with the correct result:

❯ nslookup vault.diomedet.internal

Non-authoritative answer:
Name:   vault.diomedet.internal

Great! We can create as many Ingress with our internal domain as we want, and they will always be resolved to our cluster IP.


Unfortunately, our instance of CoreDNS will become a central point for our network in this scenario. If something happens to our cluster or the CoreDNS pod stops, we’ll lose the ability to resolve domain names. I’m still searching for a way to solve this problem and have a more reliable solution, but for now, I have to stick with this downside.

Do you remember the forward value that we set on the values.yaml for CoreDNS?

That option has become the only way to choose which DNS server we want to use to solve all the DNS requests that can’t be solved internally and aren’t blocked by Pi-hole. This is because if we check some of the “Upstream DNS Server”, we’ll lose the ability to resolve our internal domain.

I have some ideas on how to solve that:

  • A second Pi-hole that is going to be my “Custom 2” upstream DNS Server
  • An ingress that masks the IP of the DNS server I want to use, something like I’ve done in a previous post Expose an external resource with a Kubernetes Ingress. A mask is needed because if you insert into the “Custom 2” field, Pi-hole will automatically check the Google server for you.

But I haven’t tested any of that, so, for today, this is it.

I’m also looking for a way to have a certificate on my internal domain, so I don’t get those annoying alerts when I’m trying to access my apps via HTTPS.

I hope you’ve found this article helpful. Stay tuned for future updates!