iTranslated by AI
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
destroytotrueafter confirming stability - Intent is easily conveyed in PR reviews (no manual operations like
terraform state rmrequired)
Choosing the Cloudflare Provider Version
I initially specified ~> 5.0, but discovered the following issues:
-
cloudflare_zoneresource attributes differ between v4 and v5 (zone→name,planbecame read-only) -
cloudflare_dns_recordis the name in v5, while it wascloudflare_recordin v4 -
cloudflare_workers_custom_domainis the name in v5, while it wascloudflare_workers_domainin 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 stg → prd.
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
- Retrieve NS via
terraform output CLOUDFLARE_ZONE_NS - Go to Onamae.com → Domain Navi → Name Server Settings → "Other" tab
- 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.
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
removedblocks + DNS switching - Gradual Rollback: Recovery possible in 3 stages: Workers / DNS / Terraform
Here are the lessons learned:
-
The
removedblock is the ultimate safety device ── Declaratively detach resources withoutterraform state rm. Rollbacks are easy. - 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.
-
Identify all required API Token permissions beforehand ── If they are missing, it fails mid-
terraform apply, leaving the state in an inconsistent condition. - Workers Domain can only be created after Worker deployment ── There is an execution dependency between Terraform and Wrangler deployment.
-
Don't forget
terraform importfor resources created via CLI ── I almost lost data because an R2 bucket region mismatch triggered a re-creation proposal. -
Set CloudFront to
enabled = falsebefore 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! 🥺
Discussion