I really enjoy many of Cloudflare’s services, especially considering that they provide excellent protection for free. You just enable proxying, Bot Fight Mode etc., and chill most of the time.
So let’s say you have some public URL which should be protected by Cloudflare, deny all bot traffic and allow only real browser-based traffic. In one of my cases, that was a self-hosted Grafana instance. However, there were a few things I wanted to automate as part of the CI/CD pipeline, including provisioning Grafana resources like data sources, dashboards, alert rules etc.
And there are two places where Cloudflare protection wouldn’t let me do it as I wanted:
- At the end of such a pipeline, it’s worth verifying that the Grafana instance is healthy via something like
curl -s --connect-timeout 5 --max-time 10 https://${MONITORING_DOMAIN}/api/health
- I wanted to avoid restarting Grafana after any provisioning changes, and there’s an officially recommended way to do that via the HTTP API endpoint, so in my GitHub Actions pipeline that would look like:
curl -X POST \
-u "${USER}:${PASSWORD}" \
-H "Content-Type: application/json" \
--fail --retry 5 --retry-delay 5 \
https://${MONITORING_DOMAIN}/api/admin/provisioning/dashboards/reload
Note: when double-checking the docs, I noticed that for Grafana 13, this API is considered legacy, but it’s still supported.
Both of these basic operations would fail with 403 Forbidden, treated as bot traffic by Cloudflare. There are several similar cases, especially around health checks via public URLs, and I wanted a clean solution for that.
Defining the solution
The simplest option seems to be allowlisting GitHub Actions IPs in Cloudflare. GitHub publishes a list of their IPs, however, as they directly mention:
We make changes to our IP addresses from time to time. We do not recommend allowing by IP address, but if you use these IP ranges we strongly encourage regular monitoring of our API.
So that requires ongoing maintenance, and it wouldn’t work at all if a self-hosted runner is used, which sometimes is the case for me as well.
Temporarily disabling Cloudflare protection at the zone level is even worse.
After some thinking, I defined a solution that seems to be the best for my use case:
- Access should be temporary, only for the duration of CI/CD pipeline execution, and should be automatically revoked after that (including pipeline failure scenarios).
- Access should be granted only to the runner’s public IP.
- If Bot Fight Mode is enabled, it should be disabled for the duration of the pipeline and re-enabled afterwards (not the most elegant solution, but the only one I found to avoid bot traffic detection).
It became clear that the best way to implement it would be a GitHub Action, responsible for granting and revoking access to the protected resource. I found an action xiaotianxt/bypass-cloudflare-for-github-action, which is doing the following:
- Gets the GitHub runner’s public IP and temporarily allowlists it in Cloudflare.
- Creates the required Cloudflare IP list and WAF skip rule automatically if they do not exist yet.
- Optionally disables Bot Fight Mode during the workflow, then restores it afterward.
- Cleans up after the job by removing the runner IP from the list.
The shape of this action was right, but after using it on a real zone I hit a couple of issues that I wanted to address: supporting both IP Access rules and rule lists, stricter cleanup, more careful handling of Bot Management settings, etc. So I ended up writing my own implementation: yurhasko/gha-bypass-cloudflare.
Implementation details
Preserving Bot Management settings on Pro/Business/Enterprise zones
The Cloudflare Bot Management endpoint requires a PUT - whatever you send in the body becomes the new state. The original action sends:
{ "fight_mode": false, "enable_js": false }
On a Free zone with only Bot Fight Mode enabled, that’s fine. On a Pro/Business/Enterprise zone with Super Bot Fight Mode active (sbfm_definitely_automated, sbfm_likely_automated, sbfm_verified_bots, optimize_wordpress etc.), this PUT silently drops every other field. After the job finishes, the post step restores fight_mode and enable_js from saved values, but the SBFM settings aren’t preserved. So the chosen approach was to:
GET /zones/{zoneId}/bot_managementto read the current full configuration.- Strip known read-only fields (currently
using_latest_model- Cloudflare returns it on GET but rejects it on PUT). - Save the full writable configuration to action state.
- PUT the saved configuration with
fight_mode: falseandenable_js: falseoverridden. - In the post step, PUT the saved configuration back as-is.
This way, every setting other than fight_mode and enable_js remains unchanged.
Multiple public IP providers with retries
The original action fetches the runner IP from a single endpoint:
ip=$(curl -s https://api64.ipify.org)
If api64.ipify.org is unreachable or returns invalid data (which sometimes happens), the whole job fails before it even gets to Cloudflare.
My action ships a list of providers (ipify v4 and v6, checkip.amazonaws.com, icanhazip.com, ident.me), rotates through them up to publicIpMaxAttempts times (default 6), validates the response with node:net.isIP(), and rejects any provider URL that isn’t HTTPS. If you don’t trust the defaults, you can pass your own list via publicIpProviderUrls.
Rolling back a failed first-time setup
The first run creates the IP list, then the WAF skip rule. If the list creation succeeds but the WAF rule call fails (token missing zone WAF permissions, ruleset quota hit, etc.), the original action leaves the orphan list behind. The next run sees the list, concludes that “setup is done”, skips WAF rule creation, and fails every protected request because there’s no rule referencing the list.
My action does a best-effort delete of the list it just created if the WAF rule creation fails, so the next run starts from a clean state. If that delete itself also fails, the action logs a warning with the list ID so it can be cleaned up manually.
Two strategies supported: rule list vs IP Access Rule
The biggest pain point of the rule-list flow isn’t a bug, it’s just the shape of the underlying API: the action manages one shared IP list per Cloudflare account. Two parallel jobs collide on it, the post step of one job clears it out from under the other, and there’s no clean way out without either creating per-run lists (which isn’t flexible, considering the Free plan’s 1-list-per-account / Pro plan’s 10-list-per-account quota).
My action supports a second strategy - bypass through Cloudflare IP Access Rules instead of a shared rule list:
POST /zones/{zoneId}/firewall/access_rules/rules
{
"mode": "whitelist",
"configuration": { "target": "ip", "value": "<runner ip>" },
"notes": "GitHub Actions runner temporary access"
}
Each call creates a new rule with its own ID, the post step deletes that specific ID, and there’s no shared state between runs. Parallel jobs each get their own rule and don’t collide.
Note: Cloudflare’s docs at the time of writing say that IP Access Rule’s are supported by Free plan, but for some reason they’re missing from the dashboard UI on my Free account until I create the first rule via API. If you’re on Free and don’t see the “Firewall Access Rules” tab in the dashboard, try creating a test rule via API and it should show up (and then you can manage them via dashboard if you want).
You pick between the two via a strategy input:
ruleList (default) | accessRule | |
|---|---|---|
| Cloudflare API surface | /accounts/{accountId}/rules/lists + WAF rulesets | /zones/{zoneId}/firewall/access_rules/rules |
| Resources created | One shared account-level IP list + one WAF skip rule | One zone-level IP Access Rule per run |
| First-run setup | Yes - creates the list and the WAF rule | None |
| Concurrency | Shared, must be serialized | Per-run, no shared state |
| Required token scope | Account > Account Rule Lists > Read/Write (+ Zone > Zone WAF > Read/Write for first setup) | Zone > Firewall Services > Read/Write |
accountId input | Required | Not required |
| Cleanup in post step | Clear shared list contents | Delete the per-run rule by ID |
| Bypass scope | WAF managed rules, rate limiting, SBFM phases | WAF custom rules, rate limiting rules, WAF Managed Rules, and deprecated firewall rules |
A few practical things worth noting about accessRule:
- IP Access Rules can bypass WAF custom rules, rate limiting rules, WAF Managed Rules, and deprecated firewall rules for the allowed IP. They do not necessarily solve Bot Fight Mode or Super Bot Fight Mode, so keep
disableBotFightModeavailable if your zone needs it. Test the chosen strategy against the real zone before relying on it. - Permissions are simpler: only
Zone > Firewall Services > Read/Write, scoped to a single zone. The Cloudflare dashboard sometimes calls this “IP Access Rules” or “Firewall Access Rules” - that’s essentially the same permission. - The
accessRulestrategy doesn’t create any persistent Cloudflare resources - it creates one rule, the rule lives for the duration of the GHA job, the post step deletes it, and the zone goes back to its previous state. - Each run creates its own rule with its own ID, so two parallel jobs each get their own bypass and don’t see each other. This is the main reason I added this as an alternative strategy.
Switching strategies is a one-line change in the workflow:
- name: Allow runner through Cloudflare
uses: yurhasko/gha-bypass-cloudflare@v1
with:
strategy: accessRule
zoneId: ${{ secrets.CLOUDFLARE_ZONE_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId is not required when strategy: accessRule - the API call is zone-scoped.
Using the action
The minimal usage looks identical to most setup actions:
- name: Allow runner through Cloudflare
uses: yurhasko/gha-bypass-cloudflare@v1 # Or pin via semver (immutable releases are enabled) / commit SHA
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
zoneId: ${{ secrets.CLOUDFLARE_ZONE_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Run protected request
run: curl --fail https://${{ vars.MONITORING_DOMAIN }}/api/health
If your zone has SBFM enabled, add disableBotFightMode: true:
- name: Allow runner through Cloudflare
uses: yurhasko/gha-bypass-cloudflare@v1
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
zoneId: ${{ secrets.CLOUDFLARE_ZONE_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
disableBotFightMode: true
Token permissions depend on the strategy you pick:
- For
strategy: ruleList(default), ongoing runs:Account > Account Rule Lists > Read/Write. - For
strategy: ruleList, first setup run only: alsoZone > Zone WAF > Read/Write. After the list and WAF rule exist, this can be removed. - For
strategy: accessRule:Zone > Firewall Services > Read/Writeonly - no account-level scope needed. - For
disableBotFightMode: true(either strategy): alsoZone > Bot Management > Read/Write.
Provisioning the token via Terraform
I prefer to manage Cloudflare (and all other infra stuff) via Terraform, so the API token for the action lives there too. Permission group IDs aren’t stable across accounts, so you need to lookup by permission name from the cloudflare_api_token_permission_groups_list data source instead of hardcoding any of them. Both strategies share the same data sources:
data "cloudflare_api_token_permission_groups_list" "cf_permissions_groups" {}
data "cloudflare_zone" "example_domain" {
filter = {
name = "example.com"
}
}
strategy: ruleList
The token needs account-scoped permissions for the IP list and zone-scoped permissions for the WAF rule (plus Bot Management when disableBotFightMode: true). The locals block below extracts the required permissions from the data source and makes them available via account-unique IDs for the token resource:
locals {
cloudflare_gha_actions_token_permissions_groups = {
account = { for x in data.cloudflare_api_token_permission_groups_list.cf_permissions_groups.result : x.name => x.id if contains([
"Account Rule Lists Read",
"Account Rule Lists Write",
], x.name) }
zone = { for x in data.cloudflare_api_token_permission_groups_list.cf_permissions_groups.result : x.name => x.id if contains([
"Zone WAF Read",
"Zone WAF Write",
"Bot Management Read",
"Bot Management Write",
], x.name) }
}
}
resource "cloudflare_account_token" "gha_actions_whitelist_token" {
account_id = var.cloudflare_account_id
name = "gha-actions-whitelist-token"
policies = [
{
effect = "allow"
permission_groups = [for x in local.cloudflare_gha_actions_token_permissions_groups.account : { id = x }]
resources = {
"com.cloudflare.api.account.${var.cloudflare_account_id}" = "*"
}
},
{
effect = "allow"
permission_groups = [for x in local.cloudflare_gha_actions_token_permissions_groups.zone : { id = x }]
resources = {
"com.cloudflare.api.account.zone.${data.cloudflare_zone.example_domain.zone_id}" = "*"
}
}
]
}
output "cloudflare_gha_actions_whitelist_token" {
description = "Token for GitHub Actions to update Cloudflare IP whitelist via API during GHA workflow execution"
value = cloudflare_account_token.gha_actions_whitelist_token.value
sensitive = true
}
strategy: accessRule
IP Access Rules are zone-scoped, so the token doesn’t need any account-level permissions and the policies block collapses to a single zone entry:
locals {
cloudflare_gha_actions_token_permissions_groups = {
zone = { for x in data.cloudflare_api_token_permission_groups_list.cf_permissions_groups.result : x.name => x.id if contains([
"Firewall Services Write",
"Firewall Services Read",
"Bot Management Read",
"Bot Management Write",
], x.name) }
}
}
resource "cloudflare_account_token" "gha_actions_whitelist_token" {
account_id = var.cloudflare_account_id
name = "gha-actions-whitelist-token"
policies = [
{
effect = "allow"
permission_groups = [for x in local.cloudflare_gha_actions_token_permissions_groups.zone : { id = x }]
resources = {
"com.cloudflare.api.account.zone.${data.cloudflare_zone.example_domain.zone_id}" = "*"
}
}
]
}
output "cloudflare_gha_actions_whitelist_token" {
description = "Token for GitHub Actions to allow the runner via Cloudflare IP Access Rule"
value = cloudflare_account_token.gha_actions_whitelist_token.value
sensitive = true
}
Drop the
Bot Management Read/Bot Management Writeentries from either example if you don’t usedisableBotFightMode: true.
After terraform apply, pull the token value via terraform output -raw cloudflare_gha_actions_whitelist_token and store it as the CLOUDFLARE_API_TOKEN GitHub Actions secret. The account ID and zone ID go into CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_ZONE_ID respectively.
Reminder:
CLOUDFLARE_ACCOUNT_IDisn’t strictly required foraccessRule
A note on concurrency
This issue is relevant to strategy: ruleList only. The action manages a single shared IP list per Cloudflare account, so two jobs running in parallel against the same account can cause a cleanup race condition. If you have matrix or parallel jobs that all need bypass against the same account, serialize them with a workflow concurrency group:
jobs:
test:
runs-on: ubuntu-latest
concurrency:
group: cloudflare-bypass-${{ github.workflow }}
cancel-in-progress: false
Don’t put the bypass step in a separate setup job that downstream jobs depend on - the post step runs when that setup job ends, which clears the list before the dependent jobs even start.
If you can’t serialize and need genuinely parallel bypass, switch to strategy: accessRule. Each run creates an independent rule with its own ID, so parallel jobs don’t step on each other.
Wrapping up
For my Grafana use case, the action does what I want: the runner IP is allowlisted only for the duration of the job, the bypass resource is removed at the end (even on cancellation, unless the runner dies mid-job), and Bot Management settings are restored exactly to what they were before. The credits go to xiaotianxt/bypass-cloudflare-for-github-action for the original idea and the overall flow - without it, I would’ve spent much longer figuring out the Cloudflare API details and edge cases. If you’re hitting 403s from Cloudflare in GitHub Actions and the existing actions don’t quite fit, give yurhasko/gha-bypass-cloudflare a try. Issues and PRs welcome.
