From 7564cb14f0a4a2a6ee334095a68bb2e47f5ff05f Mon Sep 17 00:00:00 2001 From: qitpydev Date: Tue, 9 Jun 2026 16:22:54 +0700 Subject: [PATCH] feat: add Helm chart with GHCR OCI publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add charts/cloudflare-ddns/ Helm chart (env-var config, hostNetwork by default) - Add .github/workflows/helm.yml — lint → package → push to oci://ghcr.io/timothymiller - Update README Kubernetes section with Helm install guide (quick install, values.yaml, upgrade/uninstall) - Keep k8s/cloudflare-ddns.yml as legacy raw-manifest fallback --- .github/workflows/helm.yml | 54 +++++++++ README.md | 74 +++++++++++- charts/cloudflare-ddns/.helmignore | 4 + charts/cloudflare-ddns/Chart.yaml | 16 +++ charts/cloudflare-ddns/templates/_helpers.tpl | 34 ++++++ .../cloudflare-ddns/templates/deployment.yaml | 106 ++++++++++++++++++ charts/cloudflare-ddns/templates/secret.yaml | 11 ++ charts/cloudflare-ddns/values.yaml | 80 +++++++++++++ 8 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/helm.yml create mode 100644 charts/cloudflare-ddns/.helmignore create mode 100644 charts/cloudflare-ddns/Chart.yaml create mode 100644 charts/cloudflare-ddns/templates/_helpers.tpl create mode 100644 charts/cloudflare-ddns/templates/deployment.yaml create mode 100644 charts/cloudflare-ddns/templates/secret.yaml create mode 100644 charts/cloudflare-ddns/values.yaml diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml new file mode 100644 index 0000000..ee6b248 --- /dev/null +++ b/.github/workflows/helm.yml @@ -0,0 +1,54 @@ +name: Helm Chart + +on: + push: + branches: + - master + tags: + - "v*" + pull_request: + branches: + - master + +permissions: + contents: read + packages: write + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Helm + uses: azure/setup-helm@v4 + + - name: Lint chart + run: helm lint charts/cloudflare-ddns + + - name: Package chart + run: | + mkdir -p /tmp/helm-charts + helm package charts/cloudflare-ddns --destination /tmp/helm-charts + + - name: Extract chart version + id: chart_version + run: | + VERSION=$(grep '^version:' charts/cloudflare-ddns/Chart.yaml | awk '{print $2}') + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Login to GHCR + if: github.event_name != 'pull_request' + run: | + echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io \ + --username "${{ github.actor }}" \ + --password-stdin + + - name: Push chart to GHCR + if: github.event_name != 'pull_request' + run: | + helm push /tmp/helm-charts/cloudflare-ddns-${{ steps.chart_version.outputs.version }}.tgz \ + oci://ghcr.io/timothymiller diff --git a/README.md b/README.md index 2d9d2b6..2cbb0b2 100755 --- a/README.md +++ b/README.md @@ -261,7 +261,77 @@ services: ### ☸️ Kubernetes -The included manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply: +#### Helm (recommended) + +The chart is published to GitHub Container Registry as an OCI artifact. + +**1. Quick install (single domain):** + +```bash +helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \ + --namespace ddns --create-namespace \ + --set cloudflare.apiToken=your-api-token \ + --set domains=example.com +``` + +> For multiple domains, use a `values.yaml` file — Helm's `--set` treats commas as value-list separators. + +**2. Or use a `values.yaml` for a full configuration:** + +```yaml +cloudflare: + apiToken: your-api-token # or use existingSecret + +domains: example.com,www.example.com +ip4Provider: cloudflare.trace +ip6Provider: cloudflare.trace # set to none if IPv6 is not needed + +proxied: "true" +updateCron: "@every 5m" + +healthchecks: https://hc-ping.com/your-uuid # optional +``` + +```bash +helm install cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \ + --namespace ddns --create-namespace \ + -f values.yaml +``` + +**Upgrade:** + +```bash +helm upgrade cloudflare-ddns oci://ghcr.io/timothymiller/cloudflare-ddns \ + --namespace ddns -f values.yaml +``` + +**Uninstall:** + +```bash +helm uninstall cloudflare-ddns --namespace ddns +``` + +> ⚠️ `hostNetwork: true` is set by default so the pod can detect IPv6 addresses. Disable it with `--set hostNetwork=false` if you only need IPv4. + +**Key values:** + +| Value | Default | Description | +|---|---|---| +| `cloudflare.apiToken` | `""` | API token (required unless `existingSecret` is set) | +| `cloudflare.existingSecret` | `""` | Use a pre-existing Secret instead | +| `domains` | `""` | Comma-separated domains for A+AAAA records | +| `ip4Domains` / `ip6Domains` | `""` | IPv4-only or IPv6-only domains | +| `ip4Provider` / `ip6Provider` | `cloudflare.trace` | IP detection provider | +| `proxied` | `"false"` | Proxy through Cloudflare (boolean expression) | +| `updateCron` | `@every 5m` | Update schedule | +| `hostNetwork` | `true` | Required for local IPv6 detection | +| `extraEnv` | `[]` | Additional env vars for advanced settings | + +See [`charts/cloudflare-ddns/values.yaml`](charts/cloudflare-ddns/values.yaml) for all options. + +#### Raw manifest (legacy) + +The `k8s/cloudflare-ddns.yml` manifest uses the legacy JSON config mode. Create a secret containing your `config.json` and apply: ```bash kubectl create secret generic config-cloudflare-ddns --from-file=config.json -n ddns @@ -316,7 +386,7 @@ The binary is at `target/release/cloudflare-ddns`. - 🐳 [Docker](https://docs.docker.com/get-docker/) (amd64, arm64, ppc64le) - 🐙 [Docker Compose](https://docs.docker.com/compose/install/) -- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/) +- ☸️ [Kubernetes](https://kubernetes.io/docs/tasks/tools/) + [Helm](https://helm.sh) (OCI chart at `ghcr.io/timothymiller/cloudflare-ddns`) - 🐧 [Systemd](https://www.freedesktop.org/wiki/Software/systemd/) - 🍎 macOS, 🪟 Windows, 🐧 Linux — anywhere Rust compiles diff --git a/charts/cloudflare-ddns/.helmignore b/charts/cloudflare-ddns/.helmignore new file mode 100644 index 0000000..3d35910 --- /dev/null +++ b/charts/cloudflare-ddns/.helmignore @@ -0,0 +1,4 @@ +.DS_Store +.git +.gitignore +*.orig diff --git a/charts/cloudflare-ddns/Chart.yaml b/charts/cloudflare-ddns/Chart.yaml new file mode 100644 index 0000000..645fe54 --- /dev/null +++ b/charts/cloudflare-ddns/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: cloudflare-ddns +description: Dynamic DNS client for Cloudflare — keeps A/AAAA records in sync with your public IP +type: application +version: 0.1.0 +appVersion: "2.1.2" +home: https://github.com/timothymiller/cloudflare-ddns +sources: + - https://github.com/timothymiller/cloudflare-ddns +keywords: + - ddns + - cloudflare + - dns +maintainers: + - name: timothymiller + url: https://github.com/timothymiller diff --git a/charts/cloudflare-ddns/templates/_helpers.tpl b/charts/cloudflare-ddns/templates/_helpers.tpl new file mode 100644 index 0000000..9c1164e --- /dev/null +++ b/charts/cloudflare-ddns/templates/_helpers.tpl @@ -0,0 +1,34 @@ +{{- define "cloudflare-ddns.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cloudflare-ddns.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "cloudflare-ddns.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "cloudflare-ddns.labels" -}} +helm.sh/chart: {{ include "cloudflare-ddns.chart" . }} +{{ include "cloudflare-ddns.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "cloudflare-ddns.selectorLabels" -}} +app.kubernetes.io/name: {{ include "cloudflare-ddns.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/charts/cloudflare-ddns/templates/deployment.yaml b/charts/cloudflare-ddns/templates/deployment.yaml new file mode 100644 index 0000000..b2087df --- /dev/null +++ b/charts/cloudflare-ddns/templates/deployment.yaml @@ -0,0 +1,106 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "cloudflare-ddns.fullname" . }} + labels: + {{- include "cloudflare-ddns.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "cloudflare-ddns.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "cloudflare-ddns.selectorLabels" . | nindent 8 }} + spec: + hostNetwork: {{ .Values.hostNetwork }} + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: CLOUDFLARE_API_TOKEN + valueFrom: + secretKeyRef: + name: {{ default (include "cloudflare-ddns.fullname" .) .Values.cloudflare.existingSecret }} + key: {{ .Values.cloudflare.existingSecretKey }} + {{- if .Values.domains }} + - name: DOMAINS + value: {{ .Values.domains | quote }} + {{- end }} + {{- if .Values.ip4Domains }} + - name: IP4_DOMAINS + value: {{ .Values.ip4Domains | quote }} + {{- end }} + {{- if .Values.ip6Domains }} + - name: IP6_DOMAINS + value: {{ .Values.ip6Domains | quote }} + {{- end }} + - name: IP4_PROVIDER + value: {{ .Values.ip4Provider | quote }} + - name: IP6_PROVIDER + value: {{ .Values.ip6Provider | quote }} + - name: UPDATE_CRON + value: {{ .Values.updateCron | quote }} + - name: UPDATE_ON_START + value: {{ .Values.updateOnStart | quote }} + - name: DELETE_ON_STOP + value: {{ .Values.deleteOnStop | quote }} + - name: TTL + value: {{ .Values.ttl | quote }} + - name: PROXIED + value: {{ .Values.proxied | quote }} + {{- if .Values.recordComment }} + - name: RECORD_COMMENT + value: {{ .Values.recordComment | quote }} + {{- end }} + {{- if .Values.managedRecordsCommentRegex }} + - name: MANAGED_RECORDS_COMMENT_REGEX + value: {{ .Values.managedRecordsCommentRegex | quote }} + {{- end }} + {{- if .Values.wafLists }} + - name: WAF_LISTS + value: {{ .Values.wafLists | quote }} + {{- end }} + {{- if .Values.shoutrrr }} + - name: SHOUTRRR + value: {{ .Values.shoutrrr | quote }} + {{- end }} + {{- if .Values.healthchecks }} + - name: HEALTHCHECKS + value: {{ .Values.healthchecks | quote }} + {{- end }} + {{- if .Values.uptimeKuma }} + - name: UPTIMEKUMA + value: {{ .Values.uptimeKuma | quote }} + {{- end }} + - name: DETECTION_TIMEOUT + value: {{ .Values.detectionTimeout | quote }} + - name: UPDATE_TIMEOUT + value: {{ .Values.updateTimeout | quote }} + - name: EMOJI + value: {{ .Values.emoji | quote }} + - name: QUIET + value: {{ .Values.quiet | quote }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/cloudflare-ddns/templates/secret.yaml b/charts/cloudflare-ddns/templates/secret.yaml new file mode 100644 index 0000000..548c81e --- /dev/null +++ b/charts/cloudflare-ddns/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if not .Values.cloudflare.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "cloudflare-ddns.fullname" . }} + labels: + {{- include "cloudflare-ddns.labels" . | nindent 4 }} +type: Opaque +stringData: + CLOUDFLARE_API_TOKEN: {{ required "cloudflare.apiToken is required when cloudflare.existingSecret is not set" .Values.cloudflare.apiToken | quote }} +{{- end }} diff --git a/charts/cloudflare-ddns/values.yaml b/charts/cloudflare-ddns/values.yaml new file mode 100644 index 0000000..c1bd820 --- /dev/null +++ b/charts/cloudflare-ddns/values.yaml @@ -0,0 +1,80 @@ +image: + repository: timothyjmiller/cloudflare-ddns + pullPolicy: IfNotPresent + # Overrides the image tag — defaults to chart appVersion + tag: "" + +# Must stay at 1. Multiple replicas cause duplicate DNS updates. +replicaCount: 1 + +# Required for IPv6 detection via local interface. +# Safe to disable if you only need IPv4 (IP6_PROVIDER=none). +hostNetwork: true + +# --- Authentication --- +# Supply apiToken directly (creates a Secret) OR reference an existing one. +cloudflare: + apiToken: "" + existingSecret: "" + existingSecretKey: "CLOUDFLARE_API_TOKEN" + +# --- Domains --- +# Comma-separated. At least one of domains / ip4Domains / ip6Domains must be set. +domains: "" +ip4Domains: "" +ip6Domains: "" + +# --- IP Detection --- +# Options: cloudflare.trace, cloudflare.doh, ipify, local, +# local.iface:, local.iface.stable:, +# url:, literal:, none +ip4Provider: "cloudflare.trace" +ip6Provider: "cloudflare.trace" + +# --- Scheduling --- +updateCron: "@every 5m" +updateOnStart: true +deleteOnStop: false + +# --- DNS Record Settings --- +ttl: 1 +# Boolean expression: true, false, is(domain), sub(domain), and combos +proxied: "false" +recordComment: "" +managedRecordsCommentRegex: "" + +# --- WAF Lists --- +# Comma-separated, format: account-id/list-name +wafLists: "" + +# --- Notifications (Shoutrrr) --- +# Newline-separated URLs: discord://, slack://, telegram://, generic+https://... +shoutrrr: "" + +# --- Heartbeat Monitoring --- +healthchecks: "" +uptimeKuma: "" + +# --- Timeouts --- +detectionTimeout: "5s" +updateTimeout: "30s" + +# --- Output --- +emoji: true +quiet: false + +# --- Resources --- +resources: + limits: + memory: 32Mi + cpu: 50m + +# Additional env vars for any setting not exposed above +extraEnv: [] +# - name: REJECT_CLOUDFLARE_IPS +# value: "false" + +podAnnotations: {} +nodeSelector: {} +tolerations: [] +affinity: {}