iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🏗️

Migrating a Next.js Blog from AWS App Runner to Cloudflare Workers (Infrastructure)

に公開

Hello! I'm @Ryo54388667! ☺️

I work as an engineer in Tokyo, mainly dealing with technologies like TypeScript and Next.js.

In my previous article, Application Edition, I explained the changes on the application side. In this article, I will focus on infrastructure configuration changes using Terraform, Cloudflare account setup, and DNS migration!

📌 Infrastructure Architecture: Before and After

Before Migration (AWS)

Route53 → CloudFront → App Runner (Docker Container)

         WAF (REGIONAL)

      CloudWatch RUM + Cognito + SNS → Slack
Resource Purpose
App Runner Next.js container hosting (0.25 vCPU, 0.5 GB)
ECR Docker image management
CloudFront CDN + Caching (10 cache behaviors)
WAF Access restriction via CloudFront secret header + AWS Managed Rules
Route53 DNS (ryotablog.jp)
CloudWatch RUM Real User Monitoring (Cognito + IAM Guest Role)

Number of Terraform files: 9 files in prd/, 10 files in stg/

After Migration (Cloudflare)

Cloudflare DNS → Workers Custom Domain → Cloudflare Workers
                                            ├── R2 (ISR Cache)
                                            ├── D1 (Tag Cache)
                                            └── Durable Objects (Revalidation)
Resource Purpose
Cloudflare Zone DNS management + Automatic SSL
Workers Custom Domain Linking custom domain to Worker
R2 Bucket ISR incremental cache
D1 Database Tag cache (on-demand revalidation)

📌 Terraform Configuration Design Policy

Safe Detachment using the removed Block

Instead of deleting all AWS resources at once, I adopted a method of detaching them from Terraform management (keeping resources on AWS) using the removed block introduced in Terraform 1.7.

# Detach AWS resources from Terraform management (do not delete)
removed {
  from = aws_apprunner_service.apprunner
  lifecycle { destroy = false }
}

Why this method?

  • Maintains rollback capability
  • Makes differences clearly visible in terraform plan
  • Allows actual deletion simply by changing destroy to true after confirming stability
  • Intent is easily conveyed in PR reviews (no manual operations like terraform state rm required)

Choosing the Cloudflare Provider Version

I initially specified ~> 5.0, but discovered the following issues:

  • cloudflare_zone resource attributes differ between v4 and v5 (zonename, plan became read-only)
  • cloudflare_dns_record is the name in v5, while it was cloudflare_record in v4
  • cloudflare_workers_custom_domain is the name in v5, while it was cloudflare_workers_domain in v4

Since v5 is based on an auto-generated SDK with many breaking changes and the documentation is still in a transitional phase, I opted for ~> 4.0 (v4.52.7) to prioritize stability.

required_providers {
  cloudflare = {
    source  = "cloudflare/cloudflare"
    version = "~> 4.0"
  }
}

Environment Separation Design

I maintained completely separate directories for stg and prd, keeping Terraform states managed independently.

ryota-blog-infra/
├── stg/
│   ├── main.tf          # Group of removed blocks
│   ├── cloudflare.tf    # New Cloudflare resources
│   ├── config.tf        # AWS + Cloudflare provider
│   ├── variables.tf
│   └── output.tf
├── prd/
│   ├── main.tf          # Group of removed blocks
│   ├── cloudflare.tf    # New Cloudflare resources
│   ├── config.tf
│   ├── variables.tf
│   ├── output.tf
│   ├── route53.tf       # Holds Zone only + removed block
│   ├── acm.tf           # removed blocks only
│   ├── waf.tf           # removed blocks only
│   └── bot.tf           # removed blocks only

📌 Cloudflare Account Setup

Enabling the Workers Paid Plan

The Workers Free plan limits the bundle size to 3 MiB. Since the Next.js + OpenNext bundle exceeds this, the Paid plan ($5/month, up to 10 MiB) is mandatory.

Creating an API Token

Here is the list of permissions required eventually. Creating the token with this list from the start avoids the need for editing it later.

Scope Permission Access Purpose
Account Workers Scripts Edit Worker deployment
Account Workers R2 Storage Edit R2 bucket management
Account D1 Edit D1 database management
Account Cloudflare Pages Edit Pages project management
Account Account Settings Read Wrangler / Terraform
Zone Zone Edit Zone creation (Read is insufficient)
Zone DNS Edit DNS record management
Zone Workers Routes Edit Workers routing

Enabling R2

R2 must be enabled in advance via the Cloudflare Dashboard. The first time, you will be prompted to register payment information.

📌 Applying Terraform

Gradual Application from Staging

I proceeded with the migration in the order of stgprd.

cd stg
terraform init -upgrade          # Download Cloudflare provider
terraform plan -var-file=stg.tfvars   # Confirm differences (Mandatory)
terraform apply -var-file=stg.tfvars  # Apply

Workers Domain must be enabled after Worker deployment

The cloudflare_workers_domain resource will fail to create if the linked Worker does not exist yet.

Error: error attaching worker domain:
This environment does not exist on this Worker. (10092)

Solution: Commented out the Workers Domain in cloudflare.tf and created the Zone / R2 / D1 first. Uncommented and applied again after the Worker was deployed. Note that there is a dependency in the execution order between Terraform and Wrangler deployment.

Importing Resources Created via CLI

In the prd environment, since I had already created the D1 database and R2 bucket using the Wrangler CLI, I had to import them into Terraform.

# D1 import
terraform import -var-file=prd.tfvars \
  cloudflare_d1_database.tags \
  "<account_id>/<database_id>"

# R2 import
terraform import -var-file=prd.tfvars \
  cloudflare_r2_bucket.cache \
  "<account_id>/ryota-blog-cache-prd"

Trap of the awscc Provider

If you were managing AWS Chatbot (e.g., awscc_chatbot_slack_channel_configuration) in bot.tf, you must also declare the awscc provider in required_providers when detaching them with removed blocks. Forgetting to declare it will cause terraform init to fail.

required_providers {
  aws = {
    source  = "hashicorp/aws"
    version = ">= 4.9.0"
  }
  awscc = {
    source  = "hashicorp/awscc"
    version = ">= 1.0.0"  # Required for removed block processing
  }
  cloudflare = {
    source  = "cloudflare/cloudflare"
    version = "~> 4.0"
  }
}

Similarly, provider "aws" { alias = "virginia" } used for creating CloudFront ACM certificates in us-east-1 cannot be removed until all removed blocks are processed.

📌 DNS Migration (Onamae.com → Cloudflare)

Constraints of .jp Domains

Because ryotablog.jp is a .jp domain, it cannot be transferred to Cloudflare Registrar. You must keep the domain registration on Onamae.com and point only the name servers to Cloudflare.

NS Change Procedure

  1. Retrieve NS via terraform output CLOUDFLARE_ZONE_NS
  2. Go to Onamae.com → Domain Navi → Name Server Settings → "Other" tab
  3. Input the NS and save

Verifying DNS Propagation

dig NS ryotablog.jp +short

# Expected result:
# <ns1>.ns.cloudflare.com.
# <ns2>.ns.cloudflare.com.

Propagation usually takes a few minutes to several hours, up to 48 hours. Since the AWS infrastructure keeps running until propagation is complete, downtime is zero 🎉

📌 AWS Resource Cleanup

Simply detaching with removed { destroy = false } leaves the resources on AWS running and continuing to incur charges. After confirming stable operation, I took the following actions:

App Runner → Stopped (PAUSED)

You can stop computing charges while retaining the service definition using the Pause feature.

aws apprunner pause-service \
  --service-arn "arn:aws:apprunner:ap-northeast-1:..." \
  --region ap-northeast-1

WAF → Deleted

WAF does not have a stop feature, and simply having a WebACL incurs a monthly fee of $5+. I removed the associations before deleting the ACL.

Route53 Zone → Deleted

Changed destroy = false to true in the removed block and executed terraform apply.

📌 Migrating Storybook to Cloudflare Pages

In addition to the blog itself, I migrated the hosting of Storybook to Cloudflare Pages. Since Storybook is a static site, Pages (specialized for static hosting) is more optimal than Workers.

cloudflare.tf
resource "cloudflare_pages_project" "storybook" {
  account_id        = var.cloudflare_account_id
  name              = "${var.repo_name}-storybook"
  production_branch = "main"

  source {
    type = "github"
    config {
      owner                         = var.repo_org
      repo_name                     = var.repo_name
      production_branch             = "main"
      production_deployment_enabled = true
      pr_comments_enabled           = true
      preview_deployment_setting    = "custom"
      preview_branch_includes       = ["develop"]
    }
  }

  build_config {
    build_command   = "npm run build-storybook"
    destination_dir = "storybook-static"
  }
}

📌 Cost Comparison

Item AWS (Before) Cloudflare (After)
App Runner (prd+stg) ~$10-30/mo -
CloudFront ~$1-5/mo -
WAF ~$7/mo -
RUM + Cognito ~$1-5/mo -
ECR ~$1/mo -
Route53 Zone ~$1/mo -
Workers Paid - $5/mo
R2 / D1 / DNS / SSL - $0 (Free Tier)
Total ~$20-50/mo ~$5/mo

📌 Rollback Plan

I have a tiered rollback plan in place.

Level 1: Workers Deployment Rollback (Seconds)

wrangler rollback --name ryota-blog-prd

Level 2: DNS Rollback (Minutes to hours)

Simply change the NS back to Route53's NS on Onamae.com. Since the Route53 Zone and AWS resources are retained via removed { destroy = false }, traffic can be routed back to AWS immediately.

Level 3: Terraform Full Recovery

Remove the removed block to return to the original resource definition and re-register them into the state using terraform import.

📌 Summary

Through this infrastructure migration:

  • Significant Reduction in Resources: 29 AWS resources → 5 Cloudflare resources
  • Simplified Operations: No more container builds, ECR lifecycle management, WAF rule management, or ACM certificate renewals
  • Cost Savings: $20-50/mo → $5/mo
  • Zero-Downtime Migration: Achieved via removed blocks + DNS switching
  • Gradual Rollback: Recovery possible in 3 stages: Workers / DNS / Terraform

Here are the lessons learned:

  1. The removed block is the ultimate safety device ── Declaratively detach resources without terraform state rm. Rollbacks are easy.
  2. Choose the Cloudflare Provider v4 (as of April 2026) ── v5 is based on an auto-generated SDK, causing massive changes in resource and attribute names.
  3. Identify all required API Token permissions beforehand ── If they are missing, it fails mid-terraform apply, leaving the state in an inconsistent condition.
  4. Workers Domain can only be created after Worker deployment ── There is an execution dependency between Terraform and Wrangler deployment.
  5. Don't forget terraform import for resources created via CLI ── I almost lost data because an R2 bucket region mismatch triggered a re-creation proposal.
  6. Set CloudFront to enabled = false before deleting ── If you destroy it while enabled, Terraform will stop with an error.

I hope this serves as a helpful reference for anyone considering an infrastructure migration from AWS to Cloudflare!

Thank you for reading until the end!

Please feel free to follow me as I tweet casually! 🥺

GitHubで編集を提案

Discussion