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.