Deploying Next.js on Cloudflare Workers with Terraform-First Approach

Conceptual image of serverless deployment on Cloudflare's edge network

At AirHelp, our classic approach to deploying web applications is battle-tested: we build a Docker image and deploy it to our infrastructure on AWS EKS. For most of our applications, this model is reliable and robust.

However, this approach presents a significant challenge for our main website, airhelp.com: deployment time. A standard deployment takes roughly 20 minutes to complete. When a critical hotfix is needed, the process of deploying to a staging environment and then promoting to production can extend this to nearly 30 minutes. If a bug slips past our caching layer, a simple mistake could translate into a 30-minute outage.

Beyond deployment speed, our traditional architecture introduces inherent latency for our global user base. Serving content from a centralized region is no longer enough to meet the performance expectations of a modern web application, and we began to notice the impact.

The question became clear: Can we do this simpler, faster, and more performant?

Finding the Right Path

I've been watching Cloudflare's solutions evolve for the past couple of years, and they always seemed promising for smaller projects. However, our main website at AirHelp is far from a simple static page that you can deploy to Cloudflare Pages and forget about.

A key challenge for us is ensuring a smooth integration with our headless CMS, allowing content editors to publish and update content effortlessly. Triggering a full new deployment for every content change is not an option. Furthermore, those changes must be reflected on the live site almost instantly. The clear solution to this is Next.js's Incremental Static Regeneration (ISR) feature.

This requirement led us directly to Cloudflare Workers. While Pages are excellent for static sites, Workers provide the powerful, flexible serverless runtime needed to handle dynamic, on-demand functions like ISR. It gives us the raw compute at the edge to build a truly modern, high-performance web application.

Our Non-Negotiable Requirements

To make this vision a reality, we first needed to find a way to run a full-featured Next.js application on the Workers runtime. Then, we had to ensure the solution could meet our organization's strict engineering standards.

A critical component of this project is OpenNext, an open-source adapter that makes it possible to run standard Next.js applications on various serverless platforms, including Cloudflare Workers. It's a community-driven project, with the Cloudflare adapter being actively maintained by the Cloudflare team. The adapter works by taking the standard next build output and transforming it into a format compatible with the Cloudflare Workers runtime.

With the right tool identified, we defined three non-negotiable principles for the project:

Managing Infrastructure as Code

The cornerstone of our entire solution is managing the worker and its resources strictly through Terraform. In a typical Cloudflare project, the wrangler.toml is often responsible for creating resources like the script itself, routes, R2 buckets, and service bindings. This is convenient for small projects but incompatible with enterprise governance.

We wanted to avoid this at all costs. Our solution enforces strict separation:

The magic happens through name matching: the name field in wrangler.toml must exactly match the worker_script_name defined in our Terraform module. As long as the names, Account ID, and Zone ID align, Wrangler can successfully deploy new code to the infrastructure provisioned by Terraform.

Understanding the Required Resources for Next.js App

To run a Next.js application with fully functional ISR, we determined that our Terraform module needed to provision three core resources beyond the worker script itself:

Why a Dedicated, Public R2 Bucket for Assets?

OpenNext's built-in assets binding creates resources automatically at deployment time. This creates a conflict with Terraform, which sees this "magical," unmanaged resource as state drift and will try to remove it on the next apply. This leads to a constant battle between our deployment tool (Wrangler) and our infrastructure manager (Terraform).

Our explicit R2 bucket approach maintains single source of truth: Terraform owns infrastructure, CI/CD fills it with content.

Terraform Module in Action

With these requirements in mind, we built a flexible module that provisions a worker with a simple "Hello World" script. This acts as a placeholder for the infrastructure, which is later updated by our CI/CD pipeline.

The core of the module is the cloudflare_workers_script resource, which dynamically configures all the necessary bindings based on input variables.

# Core routing configuration
resource "cloudflare_workers_route" "this" {
  for_each = var.enable_route ? toset(var.domains) : toset([])

  zone_id = module.zone_ids.domains[each.value]
  pattern = "${join(".", compact([var.subdomain, each.value]))}/${var.path_pattern}"
  script = cloudflare_workers_script.this.script_name
}

# Private bucket for ISR cache
resource "cloudflare_r2_bucket" "data_bucket" {
  count      = var.enable_r2_data ? 1 : 0
  account_id = var.account_id
  name       = "${replace(var.worker_script_name, "_", "-")}-data"  # Underscores not allowed
}

# Public bucket for static assets
resource "cloudflare_r2_bucket" "assets_bucket" {
  count      = var.enable_r2_assets ? 1 : 0
  account_id = var.account_id
  name       = "${replace(var.worker_script_name, "_", "-")}-assets"
}

# Makes assets bucket publicly accessible
resource "cloudflare_r2_managed_domain" "assets_bucket_managed_domain" {
  account_id  = var.account_id
  bucket_name = cloudflare_r2_bucket.assets_bucket[0].name
  enabled     = true
}

# The worker itself with all bindings
resource "cloudflare_workers_script" "this" {
  account_id  = var.account_id
  script_name = var.worker_script_name

  # Placeholder content - will be replaced by CI/CD
  content = <<-EOT
    addEventListener('fetch', event => {
      event.respondWith(new Response('Hello from Terraform!'))
    })
  EOT

  # Dynamic bindings based on configuration
  bindings = concat(
    # ISR cache bucket
    var.enable_r2_data ? [{
      name        = var.r2_data_binding_name  # Can override to "NEXT_INC_CACHE_R2_BUCKET"
      type        = "r2_bucket"
      bucket_name = cloudflare_r2_bucket.data_bucket[0].name
      service     = null
    }] : [],

    # Static assets bucket
    var.enable_r2_assets ? [{
      name        = var.r2_assets_binding_name  # Default: "ASSETS"
      type        = "r2_bucket"
      bucket_name = cloudflare_r2_bucket.assets_bucket[0].name
      service     = null
    }] : [],

    # Self-reference for ISR
    var.enable_self_binding ? [{
      name        = var.self_binding_name  # Default: "WORKER_SELF_REFERENCE"
      type        = "service"
      service     = var.worker_script_name
      bucket_name = null
    }] : []
  )

  # Critical: prevents Terraform from reverting code
  lifecycle {
    ignore_changes = [
      content,
      content_file,
      compatibility_date,
    ]
  }

The most critical part of this resource is the lifecycle block. The ignore_changes directive is the magic that makes our entire workflow possible. It tells Terraform:

"You are responsible for everything except the script's content. Never touch it."

This prevents Terraform from reverting our application code back to "Hello World" on the next apply.

It's important to understand a key limitation of this approach. While ignore_changes prevents Terraform from reverting the script on a typical terraform plan when only the live code has changed, it does not protect the script content if you modify another argument within the same cloudflare_workers_script resource in your code.

For instance, if you add a new R2 binding in your Terraform file and run terraform apply, Terraform needs to update the entire resource. During this update, it will use the content defined in your .tf file (the "Hello World" placeholder), overwriting the application code deployed by your CI/CD pipeline.

This means that after making any Terraform changes to the worker resource itself, you must immediately re-run your CI/CD deployment pipeline to restore the correct application code.

Using the Module

Using the module to provision the entire infrastructure for a new Next.js application is now straightforward.

module "simple_worker_example" {
  source             = "<module-path>/cloudflare/workers"
  account_id         = "<account-id>"
  application        = "simple-app"
  subdomain          = "simple-worker"
  domains            = ["airhelp.dev"]
  worker_script_name = "example_worker"

  enable_route        = true
  enable_r2_data      = true
  enable_self_binding = true
  enable_r2_assets    = true

  r2_data_binding_name = "NEXT_INC_CACHE_R2_BUCKET"
}

As a result, in the Cloudflare dashboard we can see the created example_worker with the 3 bindings that we set in our module

Cloudflare Worker Bindings
The final state in the Cloudflare dashboard: our Terraform-provisioned worker with the three required bindings for ISR and static assets.

Bridging Terraform and the Application

With the infrastructure provisioned by Terraform, the final step is to ensure our deployment pipeline knows how to connect the application code to these resources. This is handled by the wrangler.toml file, a manifest that instructs the Wrangler CLI on what to deploy and how to bind it.

An example wrangler.toml for our application looks like this:

name = "example_worker"
main = ".open-next/worker.js"
compatibility_date = "2025-03-01"

compatibility_flags = [
	"nodejs_compat",
	"global_fetch_strictly_public",
]

[[r2_buckets]]
binding = "ASSETS"
bucket_name = "example-worker-assets"

[[r2_buckets]]
binding = "NEXT_INC_CACHE_R2_BUCKET"
bucket_name = "example-worker-data"

[[services]]
binding = "WORKER_SELF_REFERENCE"
service = "example_worker"

The critical principle here is name consistency, which enforces our IaC-first policy.

If any of these don't match, you'll get deployment errors. The worker won't find its buckets, ISR won't work, or worse - Wrangler might try to create new resources, breaking our IaC principle.

This approach is also a necessary safeguard due to a current limitation in Cloudflare’s permissions. The required Workers Script | Edit permission is broad enough to allow changes to a worker's bindings, not just its code. Our custom CI/CD validation is therefore essential to truly enforce our IaC-first policy, bridging a gap in the platform's native RBAC.

This configuration deliberately excludes:

This setup ensures a clean separation of concerns: developers only need to ensure names match. They can't accidentally modify infrastructure, create public endpoints, or bypass our governance.

Automating Deployments with GitHub Actions

The next step was to automate our deployments and bridge the gap between our Terraform-managed infrastructure and the application code. A standard approach might involve using the official cloudflare/wrangler-action, but our use case with OpenNext required a more tailored solution. The OpenNext adapter uses its own distinct commands for building and deploying (opennextjs-cloudflare build and opennextjs-cloudflare deploy).

Furthermore, we needed to inject static assets into our public R2 bucket and enforce our strict IaC validation rules. This led us to develop a custom, reusable GitHub Actions workflow.

Our workflow consists of several key stages:

Steps 1 & 2: Pre-Deployment Validation

Before any code is built, the workflow performs two critical validation checks to enforce our "Terraform-first" philosophy.

Step 3: Building the Application with the Correct Asset Path

With validation passed, the workflow proceeds to install dependencies and build the Next.js application. A crucial part of this step is configuring the application to serve static assets from the correct location.

As the Next.js application is built, it needs to know the public URL of the R2 bucket that we provisioned with Terraform. We pass this URL into the build step via the NEXT_PUBLIC_ASSET_PREFIX environment variable. The value for this variable is sourced from our Terraform output and stored securely as a GitHub Actions variable or secret. This instructs Next.js to generate all static asset links pointing to our dedicated R2 storage.

const nextConfig: NextConfig = {
  // ...
  assetPrefix: process.env.NODE_ENV === "production" ? process.env.NEXT_PUBLIC_ASSET_PREFIX : undefined,
};

Step 4: Synchronizing Static Assets with R2

After the OpenNext build process completes, it places all static assets in a local .open-next/assets directory. The next step in our workflow is to upload the contents of this directory to our public R2 bucket. A custom script iterates through the directory and uses the wrangler r2 object put command to upload each file, effectively populating the empty bucket that Terraform created.

It is critical that this command is run with the --remote flag. Without it, Wrangler defaults to interacting with a local simulation of R2 storage, rather than the live bucket on Cloudflare’s network.

Step 5: Deploying the Worker Code

With the validation passed, the application built with the correct asset prefix, and the static files synchronized to R2, the final step is a single command: opennextjs-cloudflare deploy. This pushes the transformed worker code to Cloudflare, completing the deployment.

This multi-step process, codified in a single workflow, gives us the perfect balance of automation and control. Here is what the complete workflow looks like in practice:

name: Deploy Next.js application to Cloudflare Workers

on:
  workflow_call:
    inputs:
      environment:
        description: "Environment to deploy to"
        type: string
        default: sta
      node-version:
        type: string
        description: Node.js version to use
        required: false
        default: 22.12.0
      assets-bucket-name:
        description: "The name of the R2 bucket for static assets"
        required: true
        type: string
      assets-bucket-url:
        description: "URL of the R2 assets bucket"
        required: false
        type: string

env:
  BUILD_ENV: ${{ inputs.environment  }}

jobs:
  deploy-to-cloudflare:
    name: Build and publish application to cloudflare
    runs-on: [ubuntu-latest]
    steps:
      - name: Checkout code
        uses: actions/checkout@v5

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: ${{ inputs.node-version }}

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 8

      - name: Validate wrangler.toml
        run: |
          if [ ! -f wrangler.toml ]; then
            echo "❌ Error: wrangler.toml not found!"
            exit 1
          fi

          if ! grep -q "^workers_dev" wrangler.toml; then
            echo "'workers_dev' setting not found. Prepending 'workers_dev = false' to wrangler.toml..."
            # Prepend the setting to the first line of the file to ensure it's a top-level key.
            # The '\n' adds a blank line after for better readability.
            sed -i '1i workers_dev = false\n' wrangler.toml
            echo "✅ wrangler.toml has been updated for this deployment."
          else
            echo "👍 'workers_dev' setting is already present."
          fi

      - name: Verify worker script exists in Cloudflare
        run: |
          WORKER_NAME=$(grep "^name =" wrangler.toml | awk -F '"' '{print $2}')
          if [ -z "$WORKER_NAME" ]; then
            echo "❌ Error: Could not read 'name' from wrangler.toml"
            exit 1
          fi

          echo "Verifying that worker script '$WORKER_NAME' exists..."
          HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
            -X GET "https://api.cloudflare.com/client/v4/accounts/${{ vars.CLOUDFLARE_ACCOUNT_ID }}/workers/scripts/$WORKER_NAME" \
            -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }}" \
            -H "Content-Type: application/json")

          if [ "$HTTP_STATUS" -eq 200 ]; then
            echo "Worker script found (HTTP 200). Proceeding with deployment."
          else
            echo "❌ Error: Worker script '$WORKER_NAME' not found (HTTP $HTTP_STATUS)."
            echo "Please ensure it has been created with Terraform first."
            exit 1
          fi

      - name: Install dependencies
        run: pnpm install

      - name: Build Next.js app with OpenNext
        run: pnpm exec opennextjs-cloudflare build
        env:
          NODE_ENV: production
          NEXT_PUBLIC_ASSET_PREFIX: ${{ inputs.assets-bucket-url }}

      - name: Sync Static Assets to R2
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }}
          BUCKET_NAME: ${{ inputs.assets-bucket-name }}
        run: |
          set -e

          SOURCE_DIR=".open-next/assets"

          if [ ! -d "$SOURCE_DIR" ]; then
            echo "❌ Error: Directory $SOURCE_DIR not found. Did the build step run correctly?"
            exit 1
          fi

          echo "Synchronizing $SOURCE_DIR with R2 bucket: $BUCKET_NAME"

          find "$SOURCE_DIR" -type f | while read -r FULL_PATH; do
            KEY="${FULL_PATH#"$SOURCE_DIR/"}"

            echo "  Uploading: $FULL_PATH as $KEY"

            pnpm exec wrangler r2 object put "$BUCKET_NAME/$KEY" --file="$FULL_PATH" --remote
          done

          echo "✅ Asset synchronization complete."

      - name: Deploy to Cloudflare Workers
        run: pnpm exec opennextjs-cloudflare deploy
        env:
          CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_API_TOKEN }}

Our workflow successfully enabled the deployment of the application to the existing worker!

GitHub Actions Workflow
GitHub Actions workflow for deploying Next.js to Cloudflare Workers

ISR in Action

With the infrastructure provisioned and the deployment pipeline configured, the final step was to prove that it all works. We successfully deployed the Next.js application, and the most satisfying proof came directly from the browser's Network tab: the response for a pre-rendered page now includes the x-nextjs-cache: HIT header. This confirmed that content was being served directly from the Cloudflare cache instead of being server-rendered on every request.

This validated our entire setup, delivering a fully functional site with both time-based and on-demand ISR.

Next.js Application with ISR
Next.js application running on Cloudflare Workers with ISR enabled

Time-Based Revalidation

To demonstrate the core ISR functionality, we created a simple homepage using the Pages Router. The page displays a timestamp that is generated at build time.

// pages/index.tsx
export default function Home({ timestamp }: { timestamp: number }) {
  const dateFromTimestamp = new Date(timestamp);

  return (
    <div>
      <p>Current timestamp: {timestamp}</p>
      <p>Date from timestamp: {dateFromTimestamp.toString()}</p>
    </div>
  );
}

export async function getStaticProps() {
  return {
    props: {
      timestamp: Date.now(),
    },
    // ISR revalidate every 24 hours (will be overridden by on-demand webhook)
    revalidate: 86400,
  };
}

The key here is the revalidate: 86400 property. This tells Next.js to serve the statically generated page from the cache, and after 24 hours, the next request will trigger a revalidation in the background. The user gets a fast, cached response, while the content is kept fresh.

On-Demand Revalidation via Webhook

Waiting for a timer isn't enough for a modern CMS. So, we need to update content the moment an editor hits "publish." To achieve this, we created a simple on-demand revalidation API endpoint. This endpoint, which would be secured and triggered by a webhook from our CMS, allows us to instantly purge and regenerate specific pages.

// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from "next";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // For a real-world app, add security checks here (e.g., a secret token)
  if (req.method !== "POST") {
    return res.status(405).json({ message: "Method not allowed" });
  }

  try {
    const path = req.body.path;
    if (!path) {
      return res.status(400).json({ message: "Missing path in payload" });
    }

    await res.revalidate(path);
    return res.status(200).json({ revalidated: true, path });

  } catch (err) {
    return res.status(500).json({ message: "Error revalidating" });
  }
}

To test this endpoint, we can send a simple curl request:

curl -X POST https://simple-worker.airhelp.dev/api/revalidate \
  -H "Content-Type: application/json" \
  -d '{"path":"/"}'

Why did this work? This simple command proves that our entire architecture is functioning in harmony. The request hits the Cloudflare network and is routed to our Worker. The OpenNext adapter correctly forwards the API call to the Next.js handler. The handler then uses Next.js's built-in res.revalidate() function, which (under the hood) leverages the WORKER_SELF_REFERENCE and NEXT_INC_CACHE_R2_BUCKET bindings we configured in Terraform to purge the stale content from the R2 cache.

The next user visiting the homepage gets a freshly rendered version with a new timestamp. Subsequent visitors are served this new, cached version, confirmed by the x-nextjs-cache: HIT header in the browser's network tab. This is the full ISR loop in action, powered entirely by our IaC-defined serverless infrastructure.

Initial Findings and Future Challenges

While this project was a Proof-of-Concept, it provided invaluable insights into the benefits of moving our Next.js applications to a serverless architecture on Cloudflare.

A Simpler, Faster Future

The most immediate win was the simplified deployment process. We anticipate our deployment times will drop from over 20 minutes to less than 5, replacing a complex Docker-based workflow with a direct deployment to the edge. We also expect a significant performance boost for our global users. Serving content and assets directly from Cloudflare's network is a clear path to reducing latency and improving our Core Web Vitals.

Lessons Learned

Our strict "Terraform-first" approach created a fascinating challenge: how do you prevent Terraform from reverting application code deployed by a separate CI/CD pipeline? We discovered that a terraform apply modifying a worker's configuration would overwrite the live code with the "Hello World" placeholder from our module.

The solution was the lifecycle block within our cloudflare_workers_script resource. By adding ignore_changes = [content], we explicitly told Terraform that it is responsible for every aspect of the worker except for the application code itself. This created a clean and robust separation of concerns, allowing our infrastructure and deployment tools to work in harmony.

However, this solution has a critical caveat: changing other resource arguments (like a new binding) will cause Terraform to revert the script content. While our current workaround is an immediate CI/CD re-run, we recognize this manual step is a risk for production. Our goal is to fully automate this recovery step, for instance by having the Terraform pipeline trigger the application deployment, before this pattern is used in a live environment.

From Proof of Concept to Production

While this PoC was a resounding success, several key challenges must be addressed before this can move to production. The next steps on our journey are:


Our journey from a traditional Docker setup to a modern serverless stack on Cloudflare has been a resounding success. This Proof-of-Concept proved that we can build a faster, simpler, and more performant frontend architecture without sacrificing our core engineering principles of control and automation.

We're excited by these results and look forward to building on this foundation to redefine how we deliver web applications at AirHelp.

Dominik Woźniak's Profile

Dominik Woźniak

Software Engineer