← Back to list

Securing Every Service Using Cloudflare Zero Trust Without Touching The Dashboard

You have ten services running in your home lab. ArgoCD, Rancher, Vault, Grafana, and a handful of others. They are all exposed via…

Rajesh Kumar · 2026-06-17 11:53 · 0 claps · 31.0 min read paywalled
#kubernetes #cloudflare #zero-trust-security #google #cloudflare-tunnel
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud 🔒 · Cybersecurity 🎬 · Film & Television 🏃 · Running & Endurance

Securing Every Service Using Cloudflare Zero Trust Without Touching The Dashboard

You have ten services running in your home lab. ArgoCD, Rancher, Vault, Grafana, and a handful of others. They are all exposed via Cloudflare Tunnel and reachable from anywhere on the internet. The tunnel handles routing. But nothing stops someone from just opening the URL and hitting your service directly.

Cloudflare Zero Trust Access fixes this by putting an email-based allowlist in front of each service. You set it up once in the dashboard, pick which emails can access the application, and anyone else gets blocked at the Cloudflare edge before a single request reaches your cluster.

On a free medium plan? Read here for free.

This is the fourth article in the cf-tunnel-operator series. If you are new here, the earlier articles cover setting up Cloudflare Tunnel on Kubernetes, building the operator from scratch, and adding custom CRDs. The operator is available at https://github.com/rajeshkio/cf-tunnel-operator. Feel free to clone/fork and use. Leave a star if it helps you. Below are the other articles of the series:

A note before we start

Across all the articles in this series, we have been building the operator step by step. Writing the reconciler, defining CRDs, managing Cloudflare tunnel rules and DNS records. Through all of that, I noticed something about how I was thinking before writing any piece of code.

Before I touched the keyboard, I was always asking myself the same three questions. What does this function need? What should it return? What does it do in between? I did not always ask them consciously, but whenever I skipped them and started typing directly, I ended up rewriting the function halfway through because I had missed something obvious. This is the thinking that test-driven development tries to make explicit: define the contract before writing the implementation.

In this article I want to be explicit about that thought process. Not just show the finished code, but show the thinking that led to it. This comes from my own learning while building this operator and I think it will be useful for anyone who is starting to write Go or starting to think about how to structure code before writing it. I want to walk through that thinking with you as we go, so have a pen and paper ready if you find it useful.

Adding Zero Trust support to the operator

If you want to understand how Zero Trust works manually before looking at the code, the manual setup walkthrough is in the Cloudflare Tunnel setup article.

The operator will follow the same logic we do manually. Any HTTPRoute with the right annotation will get an Access Application and an email policy created automatically. The operator will manage the full lifecycle: create when the route is added, update when the annotation changes, and delete when the route is removed.

The annotations will look like this on any HTTPRoute:

annotations:
  cf-tunnel-operator/zero-trust: "true"
  cf-tunnel-operator/zero-trust-emails: "you@example.com,colleague@example.com"

Before we write any code, we need to understand what Cloudflare gives us to work with. The Cloudflare Zero Trust API has endpoints for managing Access Applications and Access Policies. These are the two things we need to create, update, and delete programmatically.

The base URL for all Zero Trust API calls is:

https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/access/

Access Applications live at /access/apps and policies live at /access/apps/<APP_ID>/policies. Every call to these endpoints requires an API token with the right permissions.

Updating the API token

The existing token from the earlier articles covers tunnel configuration and DNS. Zero Trust Access requires one additional permission at the account level. Go to dash.cloudflare.com, then My Profile, then API Tokens, and edit your existing token. Add Access: Apps and Policies: Edit at the account level.

After the update, the token permissions should look like this:

Account: Cloudflare Tunnel: Edit, 
Access:  Apps and Policies: Edit
Zone:    DNS: Read, DNS: Edit

Understanding what the operator needs to do

Looking at what we did manually, the operator needs to do four things for each HTTPRoute with the Zero Trust annotation.

  • First, check if an Access Application already exists for that hostname.
  • Second, create one if it does not exist.
  • Third, check if a policy exists for that application.
  • Fourth, create or update the policy with the email list from the annotation.
  • For deletion, when the HTTPRoute is removed, the operator needs to delete the Access Application, which also removes the associated policy.

Each of these maps to a Cloudflare API endpoint. Let us work through them one by one, starting with listing existing applications.

How the operator will find and create an Access Application

The first thing we need before creating an Access Application is to check if one already exists for that hostname. Before creating anything, we need to list all existing applications and then search for a match.

Cloudflare provides a List Access Applications endpoint for this. Let us look at what the API actually returns before writing anything.

https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/list

https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/list

Let us make a raw curl request to the endpoint and look at the real response:

curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps" -H "Authorization: Bearer $CF_API_TOKEN" | jq .
{
  "result": [
    {
      "id": "521d9a08-ea70-43ad-9623-3f765b8e09e7",
      "uid": "521d9a08-ea70-43ad-9623-3f765b8e09e7",
      "type": "self_hosted",
      "name": "rancher.rajesh-kumar.in",
      "aud": "641fcd496948ce2b2a4eaaa55db2001fa1675fe473a577881b1be0c6f45f6ea6",
      "created_at": "2026-06-05T13:59:27Z",
      "updated_at": "2026-06-05T13:59:27Z",
      "domain": "rancher.rajesh-kumar.in",
      "self_hosted_domains": [
        "rancher.rajesh-kumar.in"
      ],
      "destinations": [
        {
          "type": "public",
          "uri": "rancher.rajesh-kumar.in"
        }
      ],
      "app_launcher_visible": true,
      "allowed_idps": [],
      "tags": [],
      "auto_redirect_to_identity": false,
      "policies": [
        {
          "created_at": "2026-06-15T13:59:30Z",
          "decision": "allow",
          "exclude": [],
          "id": "8d6c27b5-380d-400e-ae67-b14615c156cb",
          "include": [
            {
              "email": {
                "email": "abc@cde.com"
              }
            }
          ],
          "name": "cto-rancher.rajesh-kumar.in",
          "require": [],
          "uid": "8d6c27b5-380d-400e-ae67-b14615c156cb",
          "updated_at": "2026-06-05T07:29:14Z",
          "reusable": false,
          "precedence": 1
        }
      ],
      "session_duration": "24h",
      "enable_binding_cookie": false,
      "http_only_cookie_attribute": true,
      "options_preflight_bypass": false
    }
  ],
  "success": true,
  "errors": [],
  "messages": [],
  "result_info": {
    "page": 1,
    "per_page": 1000,
    "count": 1,
    "total_count": 1,
    "total_pages": 1
  }
}

Looking at the response, the top level always has this shape regardless of which Cloudflare endpoint we call:

{
  "success": true,
  "errors": [],
  "result": [...]
}

And each application inside the result array looks like this:

{
  "id": "bf48fd71-9143-4bcf-b785-800e86b5bbaa",
  "name": "rancher",
  "domain": "rancher.rajesh-kumar.in",
  "type": "self_hosted",
  "destinations": [
    {
      "type": "public",
      "uri": "rancher.rajesh-kumar.in"
    }
  ]
}

Now we have what we need to define the structs. We define these in pkg/cloudflare/types.go alongside all the other types in the project.

The top level response shape is shared across every Cloudflare API call we make. We already defined this in the earlier work, but let us see it here for context:

type apiResponse struct {
    Success bool       `json:"success"`
    Errors  []APIError `json:"errors"`
}

type APIError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

Now for the Access Application itself. Looking at the curl response, we need id to carry forward to the policy step, domain to match against our hostname, name to set when creating, type to identify it as self-hosted, and destinations which the Cloudflare API requires when creating an application:

type AccessApplication struct {
    ID          string              `json:"id,omitempty"`
    Name        string              `json:"name"`
    Domain      string              `json:"domain"`
    Type        string              `json:"type"`
    Destination []AccessDestination `json:"destinations"`
}

type AccessDestination struct {
    Type string `json:"type"`
    URI  string `json:"uri"`
}

The omitempty on ID means when we send this struct to create a new application, the empty ID field will not be included in the JSON body. Cloudflare generates the ID server-side. When we read an existing application back, the ID will be populated from the response.

Now we are ready to write a function to list all Access Applications. We could name it anything, but function naming matters. A good function name tells the reader exactly what it does without needing to read the body. We follow the Go convention of starting with a verb and being specific. ListAccessApplications tells us it lists, it is scoped to Access Applications, and it maps directly to the Cloudflare API endpoint we are calling. I hope you have your pen and paper out. Start writing what we need from this function.

Any function we write needs three things answered first. What does it need to do its job? What do we expect back? And what happens in between?

What does the function need to do its job?

The function needs to make an HTTP call to Cloudflare. To make that call, we need to know which account we are targeting, the API token to authenticate the request, and a way to control the lifecycle of the HTTP call itself.

The account ID and API token are credentials that never change during the lifetime of the operator. When the operator starts up, we store them in the Client struct. Every method on the client can reach them as c.accountID and c.apiToken. So the function already has them. The caller does not need to pass them.

In Go, context is a signal carrier that travels with every function call. It carries three things: a cancellation signal, an optional deadline or timeout, and any request-scoped values. You can see this in the diagram above.

When the operator starts up, the controller manager creates a root context. It passes that context down to the reconcile loop. The reconcile loop passes it further down to every function that does work. Every function that makes a network call receives that same context and passes it into the HTTP request using http.NewRequestWithContext. The HTTP call holds the context and watches it.

Now look at the right side of the diagram. When the operator pod is terminated, the root context is cancelled. That cancellation signal fires instantly down the entire chain. The HTTP call that is holding the context receives the signal and stops immediately. No partial state is left behind in Cloudflare.

This is why any function that makes a network call and wants to support cancellation should accept a context as its first argument. The function itself does not decide when to stop. The caller does. Context is how that decision travels all the way down from the controller manager to the HTTP call.

If you want to go deeper on context in Go, these two articles cover it in detail:

So the only thing the caller needs to pass is a context. Everything else the function already has through the client struct.

Input: a context.

What do we expect to get back?

The function is going to ask Cloudflare for all existing Access Applications. The caller needs the full list to search through it. We already defined AccessApplication as the struct that maps to each item in the response. The return type is a slice of those, []AccessApplication.

We also return an error. This function makes an HTTP call, reads a response body, and parses JSON. Any of those steps can fail. A failed network call, a timeout, a malformed response, any of these will produce an error. Returning it gives the caller the information to decide what to do next, whether that is logging it, retrying, or stopping the reconcile loop entirely.

Return: []AccessApplication and an error.

What is the process?

The only job of this function is to call the Cloudflare endpoint and hand back whatever it returns. We do not filter, we do not search, we do not make any decisions about the data. The caller will do that. Keeping this function focused on one job makes it easier to test and easier to reuse elsewhere.

Process: call the List Access Applications endpoint and return the raw result.

Now we need to make an HTTP call to the Cloudflare API. Before writing the function, let us understand what every API call in Go looks like. Most REST API calls follow the same pattern regardless of which API you are calling. Learn it once and you can write any API call.

Here are the steps:

11 stages of any API call

11 stages of any API call

We will follow these exact steps in every function we write in this article. The comments in the code below map each line to its step number.

Now the function:

func (c *Client) ListAccessApplications(ctx context.Context) ([]AccessApplication, error) {
    // Step 1: build the URL
    url := fmt.Sprintf("%s/accounts/%s/access/apps", apiBase, c.accountID)

    // Step 2: create the request object with context and HTTP method
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to list access apps: %w", err)
    }

    // Step 3: set the authorization header
    req.Header.Set("Authorization", "Bearer "+c.apiToken)

    // Step 4: no query parameters needed here, we want all applications back

    // Step 5: execute the request
    resp, err := c.http.Do(req)
    if err != nil {
        return nil, fmt.Errorf("GET access apps: %w", err)
    }

    // Step 6: always defer close immediately after checking the error
    defer resp.Body.Close()

    // Step 7: read the raw bytes before unmarshalling
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("reading response: %w", err)
    }

    // Step 8: define a struct matching the response shape
    var result struct {
        apiResponse
        Records []AccessApplication `json:"result"`
    }

    // Step 9: unmarshal the bytes into the struct
    if err := json.Unmarshal(body, &result); err != nil {
        return nil, fmt.Errorf("parsing response: %w", err)
    }

    // Step 10: check the API success flag, not the HTTP status code
    if !result.Success {
        return nil, fmt.Errorf("cloudflare API error: %v", result.Errors)
    }

    // Step 11: return the data to the caller
    return result.Records, nil
}

The response struct is defined inline inside the function. It embeds apiResponse which gives us the success and errors fields automatically from the shared struct we defined earlier. The Records field captures the application list under the result key. We saw exactly this structure in the curl response above.

Testing ListAccessApplications

Before wiring any function into the reconciler, we write a small test in cmd/test/main.go to verify it works in isolation. We call the real Cloudflare API, check the output, and fix anything wrong before it ever touches the reconciler. This saves a lot of debugging later because you always know exactly which function is broken. External APIs are especially worth testing this way since the struct might not match the response shape, a field name might differ, or the token might be missing a permission. A quick isolated test catches all of these early.

So after every function we write in this article, we test it. Here is the test for ListAccessApplications:

apps, err := client.ListAccessApplications(ctx)
if err != nil {
    fmt.Println("Error:", err)
    return
}
for _, app := range apps {
    fmt.Println(app.ID, app.Domain)
}
Listing access application
521d9a08-ea70-43ad-9623-3f765b8e09e7 rancher.rajesh-kumar.in

The output shows the application we created manually in Part 1. The struct fields are populated correctly. The function is working.

CreateAccessApplication

Now we have a way to list existing applications. The next thing we need is a way to create one when it does not exist. Let us look at what the Cloudflare Create Access Application endpoint expects.

https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/create

https://developers.cloudflare.com/api/resources/zero_trust/subresources/access/subresources/applications/methods/create

Let us make a curl request to see what a successful creation looks like:

curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "test-app",
    "domain": "test2.rajesh-kumar.in",
    "type": "self_hosted",
    "destinations": [{"type": "public", "uri": "test.rajesh-kumar.in"}]
  }' | jq .

This came back with an error:

{
  "result": null,
  "success": false,
  "errors": [
    {
      "code": 12130,
      "message": "access.api.error.invalid_request: domain not included in destinations"
    }
  ]
}

The error message is clear once you read it carefully. The domain field and the uri inside destinations must be the same value. We had used test2.rajesh-kumar.in as the domain but test.rajesh-kumar.in in the destination URI. Cloudflare requires these to match because the destination is where traffic for that domain gets routed. If they are different, Cloudflare does not know what to do with the request.

This is also something to keep in mind when writing the Go function. The domain and the destination uri must always be set to the same hostname. We will pass the same name variable to both fields.

Fixed curl:

curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "test-app",
    "domain": "test2.rajesh-kumar.in",
    "type": "self_hosted",
    "destinations": [{"type": "public", "uri": "test2.rajesh-kumar.in"}]
  }' | jq .
{
  "result": {
    "id": "d7c594d5-6070-4fb9-8299-285cb61af90c",
    "type": "self_hosted",
    "name": "test-app",
    "domain": "test2.rajesh-kumar.in",
    "destinations": [
      {
        "type": "public",
        "uri": "test2.rajesh-kumar.in"
      }
    ],
    "policies": [],
    "session_duration": "24h"
  },
  "success": true,
  "errors": []
}

The response gives us back the created application with the server-generated id. That ID is what we need to return so the caller can attach a policy to this application.

We already have the AccessApplication struct that covers all these fields. We do not need a new struct. The same struct works for both reading and writing because we set omitempty on the id field. When we send it as a request body, the empty ID is omitted. When we read it back from the response, the ID is populated.

Three questions.

What does the function need to do its job?

To create an application, we need the hostname we are protecting. That is the domain we want to register in Cloudflare Access. Everything else, the account ID and token, are already in the client struct. We also need a context for the same reason as before. Every network call needs it.

Input: a context and a hostname string.

What do we expect to get back?

After creation, we need the application ID. The policy creation step requires it. So we return the ID as a string and an error.

Return: a string and an error.

What is the process?

We build the request body using the AccessApplication struct, make a POST request to the endpoint, read the response, unmarshal it, check success, and return the generated ID.

func (c *Client) CreateAccessApplication(ctx context.Context, name string) (string, error) {
    // Step 1: build the URL
    url := fmt.Sprintf("%s/accounts/%s/access/apps", apiBase, c.accountID)

    // Step 2: build the request body before creating the request object
    // POST calls need a body, we marshal our struct into JSON bytes first
    payload := &AccessApplication{
        Name:   name,
        Domain: name,
        Type:   "self_hosted",
        Destination: []AccessDestination{
            {
                Type: "public",
                URI:  name,
            },
        },
    }

    payloadByte, err := json.Marshal(payload)
    if err != nil {
        return "", fmt.Errorf("building application access request: %w", err)
    }

    // Step 2 continued: create the request object, passing the body as a reader
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadByte))
    if err != nil {
        return "", fmt.Errorf("building request for access application: %w", err)
    }

    // Step 3: set the authorization header
    // POST calls also need Content-Type so Cloudflare knows the body is JSON
    req.Header.Set("Authorization", "Bearer "+c.apiToken)
    req.Header.Set("Content-Type", "application/json")

    // Step 4: no query parameters needed for POST

    // Step 5: execute the request
    resp, err := c.http.Do(req)
    if err != nil {
        return "", fmt.Errorf("POST access application creation: %w", err)
    }

    // Step 6: always defer close immediately after checking the execute error
    defer resp.Body.Close()

    // Step 7: read raw bytes before unmarshalling
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("reading response: %w", err)
    }

    // Step 8: define a struct matching the response shape
    // Create returns a single object not a list, so Result is AccessApplication not a slice
    var apiResult struct {
        apiResponse
        Result AccessApplication `json:"result"`
    }

    // Step 9: unmarshal the bytes into the struct
    if err := json.Unmarshal(body, &apiResult); err != nil {
        return "", fmt.Errorf("failed to unmarshal the api response: %w", err)
    }

    // Step 10: check the API success flag
    if !apiResult.Success {
        if isRateLimited(apiResult.Errors) {
            return "", ErrRateLimited
        }
        return "", fmt.Errorf("cloudflare API error: %v", apiResult.Errors)
    }

    // Step 11: return the generated ID to the caller
    return apiResult.Result.ID, nil
}

Notice that the response struct here uses Result AccessApplication not Records []AccessApplication. That is because the Create endpoint returns a single object, not a list. We embed the same apiResponse for the success and errors fields, and the Result field captures the created application.

Testing CreateAccessApplication

appId, err := client.CreateAccessApplication(ctx, "test.rajesh-kumar.in")
if err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Created app ID:", appId)
Creating access application
2026/06/10 16:22:34 INFO access application created hostname=test3.rajesh-kumar.in appID=7f7eb625-5ec7-4760-9bc8-0cb1b210442c

The function returns the ID. We verified in the Cloudflare dashboard that the application was created.

EnsureAccessApplication

Now we have both building blocks. We can list applications and we can create them. We need one function that combines these two to give us the ensure behaviour we use throughout the operator.

Three questions.

What does the function need to do its job?

It needs a context and a hostname. It will call ListAccessApplications internally so it does not need credentials from the caller.

Input: a context and a hostname string.

What do we expect to get back?

Whatever path we take, whether we found an existing application or just created a new one, we need to return the application ID so the caller can use it in the policy step.

Return: an application ID as a string and an error.

What is the process?

Call ListAccessApplications, loop through the result and match on the Domain field, return the existing ID if found, call CreateAccessApplication and return the new ID if not found.

We do not create blindly. If we did, every reconcile loop would create a duplicate application in Cloudflare. The ensure pattern checks first and only creates when necessary.

func (c *Client) EnsureAccessApplication(ctx context.Context, hostname string) (string, error) {
    // This function does not make a direct API call itself
    // It uses ListAccessApplications and CreateAccessApplication which follow the 11 steps internally
    // The job here is to decide which one to call based on what already exists in Cloudflare

    // call ListAccessApplications which follows the 11 API call steps
    apps, err := c.ListAccessApplications(ctx)
    if err != nil {
        return "", fmt.Errorf("failed to list access applications: %w", err)
    }

    // search the result for a matching domain
    // if found, return the existing ID without creating anything
    for _, app := range apps {
        if app.Domain == hostname {
            slog.Info("access application already exists", "hostname", hostname, "appID", app.ID)
            return app.ID, nil
        }
    }

    // not found, call CreateAccessApplication which follows the 11 API call steps
    appId, err := c.CreateAccessApplication(ctx, hostname)
    if err != nil {
        return "", fmt.Errorf("failed to create access application: %w", err)
    }
    slog.Info("access application created", "hostname", hostname, "appID", appId)
    return appId, nil
}

Testing EnsureAccessApplication

We ran the test twice on purpose. The first run should create the application. The second run should find the existing application and return its ID without creating a duplicate.

// first run
appId, err := client.EnsureAccessApplication(ctx, "test4.rajesh-kumar.in")
fmt.Println("Run 1:", appId, err)

// second run - should return same ID, not create a new one
appId, err = client.EnsureAccessApplication(ctx, "test4.rajesh-kumar.in")
fmt.Println("Run 2:", appId, err)
2026/06/10 17:25:38 INFO access application created hostname=test4.rajesh-kumar.in appID=24074d1c-0f4e-46e4-a8d0-eba58eef9c25
Run 1: 24074d1c-0f4e-46e4-a8d0-eba58eef9c25 <nil>

2026/06/10 17:25:38 INFO Access application already exists hostname=test4.rajesh-kumar.in appID=24074d1c-0f4e-46e4-a8d0-eba58eef9c25
Run 2: 24074d1c-0f4e-46e4-a8d0-eba58eef9c25 <nil>

Both runs returned the same ID. No duplicate was created in the Cloudflare dashboard. The ensure pattern is working correctly.

Policies: checking, creating, and updating

Now that we can ensure an Access Application exists and have its ID, the next step is attaching an email policy to it. A policy is what actually controls who can access the application. Without a policy, the Access Application exists but lets no one through.

Let us look at what the policy API gives us. First, a curl to list existing policies for an application:

curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/access/apps/7617fe0d-c1ea-4d8d-8a94-226dfef7ff6c/policies" -H "Authorization: Bearer $CF_API_TOKEN" | jq .  
{
  "result": [
    {
      "created_at": "2026-06-11T12:10:13Z",
      "decision": "allow",
      "exclude": [],
      "id": "128b3a58-288d-401f-80e0-1e3ab47448ea",
      "include": [
        {
          "email": {
            "email": "rk90229@gmail.com"
          }
        }
      ],
      "name": "cto-test.rajesh-kumar.in",
      "require": [],
      "uid": "128b3a58-288d-401f-80e0-1e3ab47448ea",
      "updated_at": "2026-06-11T05:40:44Z",
      "reusable": false,
      "precedence": 1
    }
  ],
  "success": true,
  "errors": [],
  "messages": [],
  "result_info": {
    "page": 1,
    "per_page": 200,
    "count": 1,
    "total_count": 1,
    "total_pages": 1
  }
}

We need two structs. One for reading policies back from the API and one for sending a policy creation or update request. Looking at the curl response above, the read struct only needs the fields we actually use: the id to carry forward to update calls, and the name to find our policy by name and the decision. The write struct needs the full shape the API expects when creating or updating.

type AccessPolicyLists struct {
    ID       string `json:"id"`
    Name     string `json:"name"`
    Decision string `json:"decision"`
}

type AccessPolicyRequest struct {
    Name     string            `json:"name"`
    Decision string            `json:"decision"`
    Include  []AccessEmailRule `json:"include"`
}

We name our policies using the prefix cto, short for cf-tunnel-operator. It is short enough to be recognisable at a glance and distinct enough that it will not collide with any manually created policies.

Now we need to define AccessEmailRule. Looking at the include field in the policy response, each email is not just a plain string. It is a nested object:

{
  "email": {
    "email": "you@example.com"
  }
}

The outer key is email, and inside it there is another object with an email key that holds the actual address. The include field is built to support many different rule types, not just emails. Each rule type is a different key. For email rules, the key is email and the value is an object containing the email address. This nesting is why we need a dedicated struct for it:

type AccessEmailRule struct {
    Email struct {
        Email string `json:"email"`
    } `json:"email"`
}

The outer field named Email maps to the outer email key in the JSON. The inner field named Email maps to the inner email key.

ListAccessPolicies

Three questions first.

What goes in? A context and the application ID. We need the application ID because policies live under a specific application in the Cloudflare API. The URL for listing policies is /access/apps/<APP_ID>/policies, so without the app ID we cannot build the right URL.

What comes out? A slice of []AccessPolicyLists and an error.

What is the process? Call the list policies endpoint for the given application ID and return the raw result.

func (c *Client) ListAccessPolicies(ctx context.Context, appId string) ([]AccessPolicyLists, error) {
    // Step 1: build the URL, policies live under a specific application
    url := fmt.Sprintf("%s/accounts/%s/access/apps/%s/policies", apiBase, c.accountID, appId)

    // Step 2: create the request object, nil body for GET
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to list policies: %w", err)
    }

    // Step 3: set the authorization header
    req.Header.Set("Authorization", "Bearer "+c.apiToken)

    // Step 4: no query parameters needed

    // Step 5: execute the request
    resp, err := c.http.Do(req)
    if err != nil {
        return nil, fmt.Errorf("list policies: %w", err)
    }

    // Step 6: always defer close immediately after checking the execute error
    defer resp.Body.Close()

    // Step 7: read raw bytes before unmarshalling
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return nil, fmt.Errorf("reading response: %w", err)
    }

    // Step 8: define a struct matching the response shape
    var result struct {
        apiResponse
        Records []AccessPolicyLists `json:"result"`
    }

    // Step 9: unmarshal the bytes into the struct
    if err := json.Unmarshal(body, &result); err != nil {
        return nil, fmt.Errorf("parsing response: %w", err)
    }

    // Step 10: check the API success flag
    if !result.Success {
        if isRateLimited(result.Errors) {
            return nil, ErrRateLimited
        }
        return nil, fmt.Errorf("cloudflare API error: %v", result.Errors)
    }

    // Step 11: return the policy list to the caller
    return result.Records, nil
}

Quick Test to ensure everything is working:

fmt.Println("Listing policies")
 policies, err := client.ListAccessPolicies(ctx, "370cb61b-918b-4dcb-8ca3-0ed09521d813")
 if err != nil {
  fmt.Println("Error:", err)
 }
 fmt.Println(policies)
 for _, policy := range policies {
  fmt.Println(policy.Decision)
 }
Listing policies
[{128b3a58-288d-401f-80e0-1e3ab47448ea cto-test.rajesh-kumar.in allow}]
allow

CreateAccessPolicies

Three questions first.

What goes in? A context, the application ID to attach the policy to, the hostname to name the policy, the decision string which will be "allow", and a slice of email strings.

What comes out? The created policy ID as a string and an error.

What is the process? Build the AccessPolicyRequest struct from the inputs, POST to the policies endpoint, return the generated policy ID.

Before writing the function, we need to figure out one thing. The emails are coming in as a []string slice, for example ["you@example.com", "colleague@example.com"]. But the Cloudflare API does not want a slice of strings. It wants a slice of AccessEmailRule structs. We need to convert one into the other.

The emails are also originally coming from the HTTPRoute annotation as a single comma-separated string like "you@example.com,colleague@example.com". We will handle that conversion in the reconciler, but for now the function receives a clean slice of strings.

To convert each string into an AccessEmailRule, we create an empty struct and assign the email to the inner field:

rule := AccessEmailRule{}
rule.Email.Email = "you@example.com"

We do this in a loop for every email in the input slice:

var accessEmails []AccessEmailRule
for _, email := range emails {
    rule := AccessEmailRule{}
    rule.Email.Email = email
    accessEmails = append(accessEmails, rule)
}

Now accessEmails is the slice of properly nested structs that AccessPolicyRequest expects in its Include field. With that understood, the full function:

func (c *Client) CreateAccessPolicies(ctx context.Context, appId, hostname, decision string, emails []string) (string, error) {
    // Step 1: build the URL, policies are created under a specific application
    url := fmt.Sprintf("%s/accounts/%s/access/apps/%s/policies", apiBase, c.accountID, appId)

    // Step 2: build the request body
    // convert each email string into the nested AccessEmailRule struct the API expects
    var accessEmails []AccessEmailRule
    for _, email := range emails {
        rule := AccessEmailRule{}
        rule.Email.Email = email
        accessEmails = append(accessEmails, rule)
    }

    payload := &AccessPolicyRequest{
        Name:     "cto-" + hostname,
        Decision: decision,
        Include:  accessEmails,
    }

    payloadByte, err := json.Marshal(payload)
    if err != nil {
        return "", fmt.Errorf("building access policy request: %w", err)
    }

    // Step 2 continued: create the request object with the body
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payloadByte))
    if err != nil {
        return "", fmt.Errorf("creating http request for access policy: %w", err)
    }

    // Step 3: set authorization and content type headers
    req.Header.Set("Authorization", "Bearer "+c.apiToken)
    req.Header.Set("Content-Type", "application/json")

    // Step 4: no query parameters needed

    // Step 5: execute the request
    resp, err := c.http.Do(req)
    if err != nil {
        return "", fmt.Errorf("POST access policy creation: %w", err)
    }

    // Step 6: always defer close immediately after checking the execute error
    defer resp.Body.Close()

    // Step 7: read raw bytes before unmarshalling
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("reading response: %w", err)
    }

    // Step 8: define a struct matching the response shape
    // Create returns a single policy object not a list
    var apiResult struct {
        apiResponse
        Result AccessPolicyLists `json:"result"`
    }

    // Step 9: unmarshal the bytes into the struct
    if err := json.Unmarshal(body, &apiResult); err != nil {
        return "", fmt.Errorf("failed to unmarshal the api response: %w", err)
    }

    // Step 10: check the API success flag
    if !apiResult.Success {
        if isRateLimited(apiResult.Errors) {
            return "", ErrRateLimited
        }
        return "", fmt.Errorf("cloudflare API error: %v", apiResult.Errors)
    }

    // Step 11: return the generated policy ID to the caller
    return apiResult.Result.ID, nil
}

UpdateAccessPolicies

The update follows the exact same pattern as create but uses a PUT request and includes the policy ID in the URL. When we update, we always overwrite the full email list. We do not diff. Whatever is in the annotation at reconcile time is what gets written to Cloudflare. The annotation is the source of truth.

    func (c *Client) UpdateAccessPolicies(ctx context.Context, appId, hostname, decision, policyId string, emails []string) error {
    // Step 1: build the URL, update requires the policy ID in the path
    url := fmt.Sprintf("%s/accounts/%s/access/apps/%s/policies/%s", apiBase, c.accountID, appId, policyId)

    // Step 2: build the request body, same shape as create
    var accessEmails []AccessEmailRule
    for _, email := range emails {
        rule := AccessEmailRule{}
        rule.Email.Email = email
        accessEmails = append(accessEmails, rule)
    }

    payload := &AccessPolicyRequest{
        Name:     "cto-" + hostname,
        Decision: decision,
        Include:  accessEmails,
    }

    payloadByte, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("building access policy request: %w", err)
    }

    // Step 2 continued: create the request object with PUT method and the body
    // PUT replaces the entire policy, we always overwrite the full email list
    req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(payloadByte))
    if err != nil {
        return fmt.Errorf("creating http request for access policy update: %w", err)
    }

    // Step 3: set authorization and content type headers
    req.Header.Set("Authorization", "Bearer "+c.apiToken)
    req.Header.Set("Content-Type", "application/json")

    // Step 4: no query parameters needed

    // Step 5: execute the request
    resp, err := c.http.Do(req)
    if err != nil {
        return fmt.Errorf("PUT access policy update: %w", err)
    }

    // Step 6: always defer close immediately after checking the execute error
    defer resp.Body.Close()

    // Step 7: read raw bytes before unmarshalling
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return fmt.Errorf("reading response: %w", err)
    }

    // Step 8: define a struct matching the response shape
    var apiResult struct {
        apiResponse
        Result AccessPolicyLists `json:"result"`
    }

    // Step 9: unmarshal the bytes into the struct
    if err := json.Unmarshal(body, &apiResult); err != nil {
        return fmt.Errorf("failed to unmarshal the api response: %w", err)
    }

    // Step 10: check the API success flag
    if !apiResult.Success {
        if isRateLimited(apiResult.Errors) {
            return ErrRateLimited
        }
        return fmt.Errorf("cloudflare API error: %v", apiResult.Errors)
    }

    // Step 11: no data to return, just nil for success
    return nil
}

EnsureAccessPolicy: starting simple and discovering what is missing

Now we have ListAccessPolicies and CreateAccessPolicies. Let us write an ensure function the same way we wrote EnsureAccessApplication. The logic looks identical at first: list, search, create if not found.

So the first version of EnsureAccessPolicy looks like this:

func (c *Client) EnsureAccessPolicy(ctx context.Context, appId, hostname, decision string, emails []string) (string, error) {
    // This function does not make a direct API call itself
    // It uses ListAccessPolicies, UpdateAccessPolicies and CreateAccessPolicies
    // which follow the 11 steps internally
    // The job here is to decide which one to call based on what already exists in Cloudflare

    // call ListAccessPolicies which follows the 11 API call steps
    policies, err := c.ListAccessPolicies(ctx, appId)
    if err != nil {
        return "", fmt.Errorf("failed to list access policies: %w", err)
    }

    // search the result for a policy matching our naming convention
    for _, policy := range policies {
        if policy.Name == "cto-"+hostname {
           return policy.ID, nil
        }
    }

    // not found, call CreateAccessPolicies which follows the 11 API call steps
    policyId, err := c.CreateAccessPolicies(ctx, appId, hostname, decision, emails)
    if err != nil {
        return "", fmt.Errorf("failed to create access policy: %w", err)
    }
    slog.Info("access policy created", "hostname", hostname, "appID", appId, "policyID", policyId)
    return policyId, nil
}

We test this. First run creates the policy. Second run finds it and returns the existing ID. Looks good so far.

policyId, err := client.EnsureAccessPolicy(ctx, appId, "test.rajesh-kumar.in", "allow", []string{"you@example.com"})
fmt.Println("Run 1:", policyId, err)

policyId, err = client.EnsureAccessPolicy(ctx, appId, "test.rajesh-kumar.in", "allow", []string{"you@example.com"})
fmt.Println("Run 2:", policyId, err)
Ensure policies
2026/06/13 21:22:19 INFO access policy created hostname=test.rajesh-kumar.in appID=24074d1c-0f4e-46e4-a8d0-eba58eef9c25 policyID=64ab8893-2436-40d0-96d2-5cd8c10bd283
Run 1: 64ab8893-2436-40d0-96d2-5cd8c10bd283 <nil>
2026/06/13 21:22:22 INFO access policy updated hostname=test.rajesh-kumar.in appID=24074d1c-0f4e-46e4-a8d0-eba58eef9c25 policyID=64ab8893-2436-40d0-96d2-5cd8c10bd283
Run 2: 64ab8893-2436-40d0-96d2-5cd8c10bd283 <nil>

Now we simulate what happens in real use. Someone adds a second email to the HTTPRoute annotation. The reconciler calls EnsureAccessPolicy again with the updated email list.

policyId, err = client.EnsureAccessPolicy(ctx, appId, "test.rajesh-kumar.in", "allow", []string{"you@example.com", "colleague@example.com"})
fmt.Println("After email update:", policyId, err)

The function returns the existing policy ID and no error. Looks fine. But when we check the Cloudflare dashboard, the policy still only has the original email. The second email was never added.

The problem is we return immediately without doing anything with the new email list, when the policy already exists.

for _, policy := range policies {
        if policy.Name == "cto-"+hostname {
           return policy.ID, nil
        }
    }

We are completely ignoring the updated emails the caller passed in. The function only creates. It never updates.

For EnsureAccessApplication that was fine because an application does not have fields that change after creation. But a policy has an email list that can change any time someone edits the annotation. So we need to handle the update case here.

When we find the existing policy, instead of returning immediately, we need to call UpdateAccessPolicies with the new email list and then return the policy ID:

func (c *Client) EnsureAccessPolicy(ctx context.Context, appId, hostname, decision string, emails []string) (string, error) {
    policies, err := c.ListAccessPolicies(ctx, appId)
    if err != nil {
        return "", fmt.Errorf("failed to list access policies: %w", err)
    }
    for _, policy := range policies {
            if policy.Name == "cto-"+hostname {
                // FIX : When we find the existing policy, instead of returning 
                // immediately, we need to call UpdateAccessPolicies with the 
                // new email list and then return the policy ID:
                err = c.UpdateAccessPolicies(ctx, appId, hostname, decision, policy.ID, emails)
                if err != nil {
                    return "", fmt.Errorf("failed to update the policy %s: %w", policy.ID, err)
                }
                slog.Info("access policy updated", "hostname", hostname, "appID", appId, "policyID", policy.ID)
                return policy.ID, nil
            }
        }
        policyId, err := c.CreateAccessPolicies(ctx, appId, hostname, decision, emails)
        if err != nil {
            return "", fmt.Errorf("failed to create access policy: %w", err)
        }
        slog.Info("access policy created", "hostname", hostname, "appID", appId, "policyID", policyId)
        return policyId, nil
    }

We always overwrite the email list. We do not check whether anything changed. Whatever is in the annotation is what gets written to Cloudflare on every reconcile. This is the correct behaviour for an operator. The annotation is the desired state. The function’s job is to make reality match it.

Testing EnsureAccessPolicy after the fix

// first run - creates the policy
policyId, err := client.EnsureAccessPolicy(ctx, appId, "test.rajesh-kumar.in", "allow", []string{"you@example.com"})
fmt.Println("Created:", policyId, err)

// update - adds a second email
policyId, err = client.EnsureAccessPolicy(ctx, appId, "test.rajesh-kumar.in", "allow", []string{"you@example.com", "colleague@example.com"})
fmt.Println("Updated:", policyId, err)
2026/06/14 01:41:06 INFO access policy updated hostname=test.rajesh-kumar.in appID=24074d1c-0f4e-46e4-a8d0-eba58eef9c25 policyID=64ab8893-2436-40d0-96d2-5cd8c10bd283
After email update: 64ab8893-2436-40d0-96d2-5cd8c10bd283 <nil>

Both emails are now present in the policy. The update path is working.

DeleteAccessApplication

With all the create and update logic in place, the last thing we need is deletion. When an HTTPRoute is deleted, the operator removes the tunnel rule and the DNS record. We also need to delete the Access Application so we do not leave orphaned applications sitting in Cloudflare. An orphaned application may cause confusion later if you try to create a new one for the same hostname, since Cloudflare will already have one registered for that domain.

The deletion runs independently of the DNS deletion. If DNS deletion fails, the Access Application cleanup still runs, so you do not end up with orphaned resources blocking future reconciles.

The function follows the same list-then-act pattern we have used throughout. We list all applications, find the one matching our hostname by domain, and delete it by ID. If no application is found for the hostname we return nil because there is nothing to delete and that is a valid state.

func (c *Client) DeleteAccessApplication(ctx context.Context, hostname string) error {
    // Step 1: we need the application ID to build the delete URL
    // Cloudflare has no delete-by-domain endpoint so we list first and search
    appIds, err := c.ListAccessApplications(ctx)
    if err != nil {
        return fmt.Errorf("failed to list access applications for hostname %s: %w", hostname, err)
    }

    for _, appId := range appIds {
        if appId.Domain == hostname {
            // Step 1 continued: now we can build the URL with the application ID
            url := fmt.Sprintf("%s/accounts/%s/access/apps/%s", apiBase, c.accountID, appId.ID)

            // Step 2: create the request object, DELETE has no body
            req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
            if err != nil {
                return fmt.Errorf("failed to build delete request: %w", err)
            }

            // Step 3: set the authorization header
            req.Header.Set("Authorization", "Bearer "+c.apiToken)

            // Step 4: no query parameters needed

            // Step 5: execute the request
            resp, err := c.http.Do(req)
            if err != nil {
                return fmt.Errorf("DELETE access application: %w", err)
            }

            // Step 6: always defer close immediately after checking the execute error
            defer resp.Body.Close()

            // Steps 7 to 9: DELETE responses have minimal bodies, we skip unmarshalling
            // and check the HTTP status code directly instead

            // Step 10: accept any 2xx as success
            // Cloudflare returns 202 Accepted for DELETE, not 200 OK
            // checking != 200 would treat a successful delete as a failure
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                return fmt.Errorf("delete failed with status: %d", resp.StatusCode)
            }

            // Step 11: return nil, deletion succeeded
            return nil
        }
    }

    // no application found for this hostname, nothing to delete
    return nil
}

Testing deletion and hitting a bug

We tested deletion by deleting the HTTPRoute and watching the operator logs. The deletion ran and we saw this error:

"error":"delete failed with status: 202"

The function was reporting failure. But when we checked the Cloudflare dashboard, the Access Application was gone. The deletion had actually succeeded.

So what happened? The status code the function received was 202, not 200. Our check was resp.StatusCode != 200, which meant any response that was not exactly 200 was treated as a failure. 202 failed that check even though the operation succeeded.

202 Accepted is a standard HTTP success response. It means the server received the request and accepted it for processing. It is perfectly valid and it is what Cloudflare returns for DELETE operations on Access Applications. The Cloudflare API docs show 200 as the example response, but the real API returns 202. The docs and the actual behaviour are different here.

We looked at how production Go HTTP clients handle this. The answer was clear: any status code in the 2xx range should be treated as success. Pinning to exactly 200 is too strict and breaks on valid responses from APIs that use 201, 202, or 204.

We changed the check from:

if resp.StatusCode != 200 {
    return fmt.Errorf("delete failed with status: %d", resp.StatusCode)
}

To:

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    return fmt.Errorf("delete failed with status: %d", resp.StatusCode)
}

After this fix, deletion worked cleanly on the first attempt with no error in the logs.

Wiring it into the reconciler

Now that all the client functions are ready and tested individually, we need to wire them into the reconciler. The reconciler reads the annotations from the HTTPRoute and calls the right functions based on what it finds.

We extracted all of this into a single helper function called ensureZeroTrust. This keeps the reconciler clean and avoids duplicating the annotation-reading logic across the two places in the reconcile loop where it is needed.

func (r *HTTPRouteReconciler) ensureZeroTrust(ctx context.Context, route gatewayv1.HTTPRoute, hostname string) (string, string, error) {
    log := ctrl.LoggerFrom(ctx)
    zeroTrustAnnotation := false
    var err error
    if route.Annotations["cf-tunnel-operator/zero-trust"] != "" {
        zeroTrustAnnotation, err = strconv.ParseBool(route.Annotations["cf-tunnel-operator/zero-trust"])
        if err != nil {
            log.Error(err, "failed to parse zero-trust annotation")
            return "", "", err
        }
    }
    if !zeroTrustAnnotation {
        return "", "", nil
    }
    var zeroTrustEmails []string
    if route.Annotations["cf-tunnel-operator/zero-trust-emails"] != "" {
        emailsRaw := route.Annotations["cf-tunnel-operator/zero-trust-emails"]
        rawEmails := strings.Split(emailsRaw, ",")
        for _, email := range rawEmails {
            zeroTrustEmails = append(zeroTrustEmails, strings.TrimSpace(email))
        }
    }
    appId, err := r.CF.EnsureAccessApplication(ctx, hostname)
    if err != nil {
        log.Error(err, "failed to ensure access application", "hostname", hostname)
        return "", "", err
    }
    policyId, err := r.CF.EnsureAccessPolicy(ctx, appId, hostname, "allow", zeroTrustEmails)
    if err != nil {
        log.Error(err, "failed to ensure access policy", "appID", appId)
        return "", "", err
    }
    return appId, policyId, nil
}

The function reads the cf-tunnel-operator/zero-trust annotation first. If it is absent or not "true", it returns empty strings and no error. The reconciler moves on as if Zero Trust does not exist for this route. If the annotation is "true", it reads the email list, splits it by comma, trims any spaces around each email address, and calls EnsureAccessApplication followed by EnsureAccessPolicy.

The function returns the application ID and policy ID because the reconciler writes these into the TunnelStatus CRD. This means you can check the Zero Trust status for any route directly from kubectl. The TunnelStatus CRD is covered in detail in the third article in this series.

The reconciler calls ensureZeroTrust in two places

The reconciler calls ensureZeroTrust in the early return path when both tunnel and DNS are already up to date, and again in the main path after DNS is ensured when something changed. This guarantees Zero Trust is always synced regardless of whether the rest of the state changed.

Verifying the full flow end to end

With everything wired in, let us apply an HTTPRoute with the Zero Trust annotation and verify the operator creates the Access Application and policy.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: rancher
  namespace: cattle-system
  annotations:
    cf-tunnel-operator/zero-trust: "true"
    cf-tunnel-operator/zero-trust-emails: "you@example.com"
spec:
  hostnames:
    - rancher.rajesh-kumar.in
  rules:
    - backendRefs:
        - name: rancher
          port: 443

After applying, check the operator logs:

kubectl logs -n cf-tunnel-operator-system -l app=cf-tunnel-operator -f

You should see log lines like:

access application created  hostname=rancher.rajesh-kumar.in  appID=bf48fd71-...
access policy created       hostname=rancher.rajesh-kumar.in  appID=bf48fd71-...  policyID=ae2040ed-...

Check the TunnelStatus CRD to see the IDs recorded:

kubectl get tunnelstatus -n cf-tunnel-operator-system -o yaml
 status:
    appid: 7617fe0d-c1ea-4d8d-8a94-226dfef7ff6c
    backendService: http://test.default.svc.cluster.local:8200
    hostname: tes1.rajesh-kumar.in
    lastSyncTime: "2026-06-14T18:00:33Z"
    message: ""
    notlsverify: false
    policyid: 57426d72-1001-48ac-84de-527be92ea6de
    scheme: http
    syncStatus: Success

And in the Cloudflare dashboard, verify the application and policy were created by the operator:

What we built

The operator now handles the full lifecycle of a service exposure without any manual Cloudflare dashboard work. You annotate an HTTPRoute, and the operator creates the tunnel rule, the DNS record, the Access Application, and the email policy. When the annotation changes, the policy is updated. When the HTTPRoute is deleted, everything is cleaned up.

Before this article, exposing a new service meant a trip to the dashboard to set up Zero Trust manually. For a home lab with a handful of services that is manageable. For anything larger, or for anyone running the operator in a team environment where new services are added regularly, that manual step becomes a real bottleneck. It is now gone.

The code is in the repository at https://github.com/rajeshkio/cf-tunnel-operator. The Helm chart is at https://rajeshkio.github.io/cf-tunnel-operator. If you are running a home lab and want to secure your services the same way, the README has the full setup instructions. Issues and pull requests are welcome.

If you are running something similar in your home lab, I would love to hear how you are handling service exposure and access control. Drop a comment below. And if you found this useful, let us connect on LinkedIn. I write about infrastructure engineering, Kubernetes, and building things from scratch.


메타데이터
post_id
f80c6b28fff2
slug
securing-every-service-using-cloudflare-zero-trust-without-touching-the-dashboard-f80c6b28fff2
url
https://medium.com/@rk90229/securing-every-service-using-cloudflare-zero-trust-without-touching-the-dashboard-f80c6b28fff2
canonical_url
https://medium.com/@rk90229/securing-every-service-using-cloudflare-zero-trust-without-touching-the-dashboard-f80c6b28fff2
author_url
https://medium.com/@rk90229
status
ok
fetched_at
2026-06-22 12:55:45