Dynamic DNS on Kubernetes with Cloudflare
A big challenge of hosting applications on home Internet is that ISPs usually provide a dynamic IP address and a static IP address is expensive or unavailable. Services such as noip and dyndns solve this, but they usually have a small fee or are very limited and require running their software. I’m already using Cloudflare for DDoS protection and caching so it made sense to me to use Cloudflare as my authoritative DNS and use the Cloudflare API to update the IP address when it changes. It typically doesn’t change except when I lose Internet service for an extended period of time, but this way I don’t have to think about it.
Set up Cloudflare
First, I created a single A record for the domain (domain.tld) itself pointing to my current external IP address. Then I created a wildcard CNAME record pointing back to that A record. Any subdomains now resolve to my external IP address and I only have to update the single A record when it changes. I’m using docker-ddns-cloudflare to periodically check my external IP address and update the A record.
I created a new API key using the Edit DNS Zone template and saved it to be used in a Kubernetes secret.
DDNS role
First, add new variables to inventory/group_vars/k3s_cluster:
ddns_image: cupcakearmy/ddns-cloudflare:1.1
cloudflare_token: insert-token-here
ddns_namespace: ddns
ddns_name: cloudflare-ddns
ddns_record: domain.tld
ddns_zone: domain.tld
ddns_resolver: https://ipv4.icanhazip.com/
roles/k3s_cluster/ddns/tasks/main.yml:
---
- name: DDNS Namespace
kubernetes.core.k8s:
kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
state: present
definition: "{{ lookup('template', 'manifests/namespace.j2') }}"
validate:
fail_on_error: yes
run_once: true
delegate_to: "{{ ansible_host }}"
- name: DDNS Secrets
kubernetes.core.k8s:
kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
state: present
definition: "{{ lookup('template', 'manifests/secrets.j2') }}"
validate:
fail_on_error: yes
run_once: true
delegate_to: "{{ ansible_host }}"
- name: Deployment
kubernetes.core.k8s:
kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
state: present
definition: "{{ lookup('template', 'manifests/deployment.j2') }}"
validate:
fail_on_error: yes
delegate_to: "{{ ansible_host }}"
run_once: true
roles/k3s_cluster/ddns/manifests/namespace.j2:
kind: Namespace
apiVersion: v1
metadata:
name: {{ ddns_namespace }}
roles/k3s/cluster/ddns/manifests/secrets.j2:
apiVersion: v1
stringData:
apiToken: {{ cloudflare_token }}
kind: Secret
metadata:
name: cloudflare-apitoken-secret
namespace: {{ ddns_namespace }}
type: Opaque
roles/k3s_cluster/ddns/maneifests/deployment.j2:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ ddns_name }}
namespace: {{ ddns_namespace }}
labels:
app: {{ ddns_name }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ ddns_name }}
template:
metadata:
labels:
app: {{ ddns_name }}
spec:
containers:
- name: {{ ddns_name }}
env:
- name: DNS_RECORD
value: {{ ddns_record }}
- name: RESOLVER
value: {{ ddns_resolver }}
- name: ZONE
value: {{ ddns_zone }}
- name: TOKEN
valueFrom:
secretKeyRef:
key: apiToken
name: cloudflare-apitoken-secret
optional: false
image: {{ ddns_image }}
imagePullPolicy: IfNotPresent
resources: {}
securityContext:
allowPrivilegeEscalation: false
capabilities: {}
privileged: false
readOnlyRootFilesystem: false
runAsNonRoot: false
stdin: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
tty: true
dnsConfig: {}
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler
securityContext: {}
terminationGracePeriodSeconds: 30
DDNS Playbook
k3s-ddns.yml:
---
- hosts: master[0]
become: yes
vars:
ansible_python_interpreter: /usr/bin/python3
remote_user: ansible
pre_tasks:
- name: Install Kubernetes Python module
pip:
name: kubernetes
- name: Install Kubernetes-validate Python module
pip:
name: kubernetes-validate
roles:
- role: k3s_cluster/ddns
Wrap up
That’s it, the container will run and check if the external IP address matches the value of the DNS record. If it change, it updates it. I’ve run this configuration for over two years and it works flawlessly.