GitHub Action that registers a custom domain in Netlify and creates/updates a matching Cloudflare CNAME record.
- Idempotent: safe to run on every push. Does nothing if the state is already correct.
- Single responsibility: attach domain in Netlify + upsert DNS in Cloudflare. No deploy logic — your existing Netlify git integration (or a separate deploy step) handles that.
- Scoped-token friendly: Cloudflare token only needs
Zone -> DNS -> Editon the target zone.
cf-dns-action is a deployment-support project for connecting Netlify sites to managed Cloudflare DNS without repeating the same manual dashboard steps for every portfolio or client build. It takes a subdomain, a Netlify site ID, and scoped Cloudflare/Netlify credentials, then makes the custom-domain and CNAME state match the requested configuration.
The useful part is the safety contract around the automation: dry-run mode, create-only mode, idempotent API calls, explicit outputs, optional SSL polling, retry handling, and scoped Cloudflare tokens. The action is designed to sit beside an existing Netlify deployment workflow, not replace the build or deployment process itself.
In practice, this is the kind of infrastructure helper I use so project launches stay repeatable: the app deploys through Netlify, this action wires the public domain, and the workflow can be re-run without breaking DNS that is already correct.
Minimal workflow in a consuming repo:
name: DNS sync
on:
push:
branches: [main]
jobs:
dns:
runs-on: ubuntu-latest
steps:
- uses: meidielo/cf-dns-action@v1
with:
subdomain: myapp
netlify-site-id: ${{ secrets.NETLIFY_SITE_ID }}
netlify-auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
cf-zone-id: ${{ secrets.CF_ZONE_ID }}
cf-api-token: ${{ secrets.CF_API_TOKEN }}Result: myapp.mdpstudio.com.au is attached as a custom domain on the Netlify site, a CNAME is created in Cloudflare pointing to <site-name>.netlify.app, and Let's Encrypt provisions a cert within a few minutes.
See examples/project-workflow.yml for a more complete example.
| Input | Required | Default | Description |
|---|---|---|---|
subdomain |
yes | — | Subdomain label. Must match ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$. |
domain |
no | mdpstudio.com.au |
Apex domain. |
netlify-site-id |
yes | — | Netlify site API ID (Site settings -> General -> Site details). |
netlify-auth-token |
yes | — | Netlify personal access token. |
cf-zone-id |
yes | — | Cloudflare Zone ID (32 hex chars). |
cf-api-token |
yes | — | Cloudflare API token scoped to Zone -> DNS -> Edit on the target zone. |
proxied |
no | false |
Enable Cloudflare orange-cloud proxy. Keep false for Netlify. |
create-only |
no | false |
Fail instead of updating an existing record with a different target. |
dry-run |
no | false |
Log intended changes without writing. |
wait-for-cert |
no | false |
Poll Netlify until the SSL cert is provisioned before returning. Use when a downstream step must hit the HTTPS URL. |
cert-timeout-seconds |
no | 300 |
Max seconds to wait for cert (range 10–900). Ignored unless wait-for-cert: true. A timeout is logged as a warning, not a failure. |
| Output | Description |
|---|---|
fqdn |
The full domain, e.g. myapp.mdpstudio.com.au. |
cf-record-id |
Cloudflare DNS record ID. |
cf-action |
One of: created, updated, noop, skipped. |
netlify-action |
One of: attached, already-attached, skipped. |
netlify-url |
<site-name>.netlify.app. |
ssl-state |
Final SSL cert state (e.g., provisioned). Empty unless wait-for-cert: true. |
ssl-provisioned |
true / false. Empty unless wait-for-cert: true. |
- Go to My Profile -> API Tokens -> Create Token -> Custom token.
- Permissions:
Zone -> DNS -> Edit. - Zone resources:
Include -> Specific zone -> <your apex domain>. - Client IP filtering (optional but recommended): limit to GitHub Actions runners' IP ranges if you want belt-and-braces. Less practical because the ranges are large and change.
- TTL: leave unset (non-expiring) or set to 1 year and rotate.
Do NOT use the Global API Key. If the token leaks, scoped access limits blast radius.
- User settings -> OAuth -> Personal access tokens -> New access token.
- Netlify does not yet offer scoped tokens — this token has full account access. Treat as highly sensitive: use a dedicated token for CI, rotate regularly.
- Optional: create this under a dedicated service/bot user if your Netlify team supports it.
Site settings -> General -> Site details -> Site ID (a UUID).
Right sidebar of the zone's overview page in the Cloudflare dashboard (32 hex chars).
In each consuming repo:
NETLIFY_AUTH_TOKENNETLIFY_SITE_IDCF_API_TOKENCF_ZONE_ID
If you'd rather centralise, put these in a GitHub organisation secret and restrict by repo.
Cloudflare's orange-cloud proxy terminates TLS at Cloudflare's edge. Netlify issues its own Let's Encrypt cert via the HTTP-01 challenge, which hits the origin directly. With the proxy on, the challenge traffic doesn't reach Netlify and cert issuance fails. Keep the record grey-cloud unless you have a specific reason and understand the tradeoffs (Full (strict) mode + self-configured origin cert, etc.).
| Initial state | After run |
|---|---|
| No DNS record, domain not attached | DNS created, domain attached as primary (if none) or alias |
| DNS correct, domain attached | no-op (no writes, no errors) |
| DNS wrong target, domain attached | DNS updated (unless create-only: true) |
| DNS correct, domain not attached | domain attached |
All API calls use explicit GET -> branch -> POST/PATCH/PUT rather than "create or fail", so replays on the same commit or manual re-runs never error on "already exists".
npm install
npm run all # lint + typecheck + vitest + ncc buildThe bundled output lives in dist/index.js and must be committed. The CI workflow (.github/workflows/ci.yml) fails the build if dist/ drifts from source. As a safety net, .github/workflows/auto-build-dist.yml rebuilds and commits dist/ automatically when source changes land on main without a corresponding bundle update.
Both Cloudflare and Netlify API calls retry up to 3 times with exponential backoff (500 ms → 1 s → 2 s) on 429, 502, 503, 504, and network errors. Non-retryable errors (4xx other than 429) fail immediately.
Push a semver tag:
git tag v1.0.0
git push origin v1.0.0The release workflow runs npm run all, creates a GitHub release, and moves the v1 major tag to the new release. Consumers referencing @v1 automatically pick up the latest patch/minor.
legacy/cf-dns-add.sh is the original bash script, kept for manual/local use. The GitHub Action supersedes it for CI flows.
MIT — see LICENSE.