Skip to content

feat: Cloudflare staging migration — TF bootstrap + wrangler scaffold + per-env apply#32

Merged
mastermanas805 merged 6 commits into
masterfrom
cf/staging-bootstrap
May 30, 2026
Merged

feat: Cloudflare staging migration — TF bootstrap + wrangler scaffold + per-env apply#32
mastermanas805 merged 6 commits into
masterfrom
cf/staging-bootstrap

Conversation

@mastermanas805
Copy link
Copy Markdown
Member

@mastermanas805 mastermanas805 commented May 30, 2026

Summary

Stand up the staging.instanode.dev Cloudflare environment, per user direction 2026-05-30:
staging = CF-only, ephemeral state acceptable, manual interruption before any production promotion.

Three logical commits:

  1. feat(terraform): bootstrap Cloudflare resources — 13 files under terraform/cloudflare/. CF provider v5.19.1, R2 state backend, two scoped account_tokens (deploy + admin/tunnel), R2 bucket with 24h-TTL on anon/, DNS records at TTL=60 per the cutover ramp ritual, Pages project for instanode-web. Workspaces split staging/production. Verified locally with terraform fmt -check -recursive + terraform validate.

  2. feat(workflows): terraform plan-on-PR + per-env apply (manual-only) — 3 GHA workflows:

    • terraform.yml — plan-on-PR for both envs (read-only, posts diff as PR comment)
    • terraform-apply-staging.yml — workflow_dispatch only, confirm phrase APPLY-STAGING, GH Environment staging
    • terraform-apply-production.yml — workflow_dispatch only, confirm phrase APPLY-PRODUCTION, requires numeric staging_run_id validated as digits-only AND gh run view confirms that staging run actually succeeded, GH Environment production gates on required reviewers. NO auto-promotion path from staging to production.
  3. feat(wrangler): CF Containers scaffold for staging environment — 8 services under wrangler/, plus 2 GHA workflows. cloudflare_container is NOT in CF TF provider v5.19.1, so Containers are managed via wrangler; TF handles everything else (DNS/R2/Pages/Hyperdrive/Queues/tokens). pg-platform is a custom image that bakes the 63 platform migrations from the api repo into /docker-entrypoint-initdb.d/; CF Containers' ephemeral disk means migrations re-apply on every cold start, which is the explicit user-blessed tradeoff documented in wrangler/README.md.

D-N decisions implemented

D-1 / D-2 / D-3 / D-5 / D-7 / D-8 from /tmp/cf-migration/shared/DECISIONS.md.

Companion PRs

Test plan

  • CI: terraform-fmt-check + terraform-validate jobs green
  • CI: terraform-plan job for both envs surfaces expected resource creation (no destroy)
  • CI: yamllint + kubeconform (existing validate.yml) unaffected
  • Operator runs wrangler r2 bucket create instanode-tf-state out-of-band before any terraform init
  • Operator triggers terraform-apply-staging.yml with confirm phrase, reviews resources created
  • Operator triggers wrangler-build-staging-images.yml to bake the 4 custom images
  • Operator triggers wrangler-deploy-staging.yml service=all to deploy 8 Containers
  • curl https://api.staging.instanode.dev/healthz returns 200 with matching commit_id
  • Production apply gated correctly (cannot run without successful staging run id)

Known not-done in this PR

  • Production target k8s host still unsettled (not in scope here)
  • Per-service secrets (DATABASE_URL etc.) — operator-side after first deploy
  • Synthetic prober for staging container warmth — follow-up

🤖 Generated with Claude Code

mastermanas805 and others added 3 commits May 30, 2026 11:21
First Terraform module for managing Cloudflare resources. Mirrors the
pattern of infra/newrelic/terraform/ (CF provider v5, env-driven creds,
explicit `terraform apply` per CLAUDE.md rule 15).

Resources:
- Two cloudflare_account_token resources (deploy + admin/tunnel) with
  scoped permission_groups, account-bound to CF for Startups account
  613a9e74136364c781a8e258326019f9.
- cloudflare_r2_bucket "instant-shared" (or "-staging") with 24h-TTL
  lifecycle on the anon/ prefix (matches platform anon-resource TTL).
- DNS records for apex/www/api/staging at TTL=60 per the cutover ramp
  ritual (CF-migration DECISIONS.md D-3).
- cloudflare_pages_project for instanode-web (Phase 2). Dashboard-on-Pages
  is explicitly NOT here (D-5 kill).
- Sensitive token outputs for the `make install-secrets` helper.

State backend: R2 (S3-compatible), bucket "instanode-tf-state". Operator
must create the bucket + HMAC creds out-of-band before `terraform init`
(see README §Bootstrap).

Per-env workspaces (staging/production) selected via terraform workspace.
production.auto.tfvars and staging.auto.tfvars commit-safe (no secrets).

D-N decisions implemented:
- D-1 (scope), D-2 (staging on full CF stack), D-3 (per-svc DNS-weighted
  cutover), D-5 (no dashboard-on-Pages), D-7 (NS delegation confirmed CF),
  D-8 (canonical R2 env-var names)

Verified locally with `terraform fmt -check -recursive` + `terraform
validate` against provider v5.19.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three workflows for the CF-migration TF module:

1. terraform.yml — plan-on-PR for BOTH staging and production. Matrix
   over the two envs, posts plan diff as a PR comment, never applies.
2. terraform-apply-staging.yml — workflow_dispatch only. Confirm phrase
   `APPLY-STAGING`. GH Environment binding "staging".
3. terraform-apply-production.yml — workflow_dispatch only. STRICTER:
   confirm phrase `APPLY-PRODUCTION` + numeric `staging_run_id` validated
   as digits-only + `gh run view` confirms the matching staging apply
   actually succeeded. GH Environment binding "production" gates on
   required reviewers.

There is NO auto-promotion path from staging to production. Each prod
apply is a separate human-triggered run that must clear all three gates.

Security: every GHA expression consumed in run: blocks is wrapped
through env: to prevent script injection. No client_payload usage.
No `ref:` parameter on any checkout.

Mirrors infra's existing validate.yml/apply.yml split for k8s manifests
(rule 15: state-changing operations require deliberate human trigger).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the 2026-05-30 user direction: staging is CF-only, ephemeral state
acceptable. The cloudflare/cloudflare TF provider does NOT yet expose
cloudflare_container (verified: provider v5.19.1), so Containers are
managed by wrangler while TF handles everything else.

8 services scaffolded under wrangler/:
- api / worker / provisioner: wraps GHCR images (built by their repos'
  CI with the new :staging tag) in a CF Container via a tiny Worker
  shell (src/worker.ts) that forwards requests to the Container DO.
- pg-platform: custom image (Dockerfile here) that wraps
  pgvector/pgvector:pg16 and bakes all 63 platform migrations from
  api/internal/db/migrations/ into /docker-entrypoint-initdb.d/. Cold
  starts re-apply migrations because CF Containers wipe disk on sleep.
- pg-customers / mongodb / redis-provision / nats: per-tenant
  Containers (idFromName(tenant)) backing /db/new, /nosql/new,
  /cache/new, /queue/new in staging.
- mongodb / redis-provision / nats: custom images that wrap the
  upstream image with healthcheck + staging-suitable config (Redis
  with auth + LRU eviction + RDB/AOF disabled, NATS with core-only
  no-JetStream legacy_open auth).

The ephemeral-state acceptance criterion is documented in
wrangler/README.md. E2E tests must seed their own fixtures; no
"deploy then verify 2h later" tests survive Container sleep.

Two new workflows:
- wrangler-build-staging-images.yml: builds the 4 custom images
  (pg-platform with cross-repo api checkout for migrations; mongodb/
  redis-provision/nats single-repo). Triggers: workflow_dispatch,
  push to wrangler/<svc>/**, daily cron 09:00 UTC, repository_dispatch
  "migrations-changed" event from the api repo.
- wrangler-deploy-staging.yml: workflow_dispatch only. Service input
  validated against a whitelist before any shell use. Confirm phrase
  `DEPLOY-STAGING`.

Production does NOT use this dir — production target is unsettled and
will get a separate workflow when chosen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mastermanas805 and others added 3 commits May 30, 2026 11:26
Before: terraform init failed with a cryptic AWS-IAM/IMDS stack trace
when CLOUDFLARE_API_TOKEN / TF_STATE_R2_* / CF_ACCOUNT_ID secrets were
empty (the bootstrap chicken-and-egg documented in
terraform/cloudflare/README.md §Bootstrap).

After: a pre-flight step on the plan + apply jobs detects the empty
secrets and fails with a one-line message naming the missing variables
and linking to the README. PR still goes red (correct — operator
action IS required) but the cause is now obvious from the CI log
instead of buried inside terraform's S3 backend init.

Applied identically to terraform.yml (plan), terraform-apply-staging.yml,
terraform-apply-production.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to f8afb4c — the previous commit added the guard only to
terraform.yml (the plan-on-PR job); the two apply workflows (staging
and production) had the same chicken-and-egg failure mode and need the
same guard so operator gets a clear message instead of a cryptic AWS
IAM stack trace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the per-env DNS records and CF cache rules needed for the
staging.instanode.dev environment that the wrangler scaffold deploys
into. Both files count-gate on var.environment so the production
workspace plan shows no changes from this commit.

staging.tf — new file, 5 DNS records + 4 Workers routes + 1 Pages
custom-domain:

  - *.staging.instanode.dev          (wildcard CNAME, proxied) — per-tenant
                                      service catchall; routed below
  - *.deployment.staging.instanode.dev — mirror of prod *.deployment.*
                                      for /deploy/new staging apps
  - deployment.staging.instanode.dev — anchor for the wildcard above
  - webhook.staging.instanode.dev    — /webhook/new receiver subdomain
                                      (separate host so customers can
                                      filter outbound by destination)
  - dashboard.staging.instanode.dev  — QA-only dashboard (D-5 still
                                      keeps prod dashboard off Pages)

Plus 4 cloudflare_workers_route resources binding the per-tenant
wildcards to the right Worker:
  pg-customer-*.staging.* → instanode-pg-customers-staging
  mongo-*.staging.*       → instanode-mongodb-staging
  redis-*.staging.*       → instanode-redis-provision-staging
  nats-*.staging.*        → instanode-nats-staging

Plus a cloudflare_pages_domain attaching staging.instanode.dev to the
instanode-web-staging Pages project (the project itself is in pages.tf).

api.staging.instanode.dev is NOT in this file — wrangler claims it via
`custom_domain = true` in infra/wrangler/api/wrangler.toml. TF and
wrangler must not both manage the same DNS or wrangler deploy fails
with "DNS record already exists".

cache.tf — new file, implements D-12 (LOCKED) cache scope. Single
cloudflare_ruleset on http_request_cache_settings phase with 4 rules:

  Rule 1: bypass cache for everything on api*.instanode.dev by default
  Rule 2: cache /healthz at edge for 30s (SHA same across instances)
  Rule 3: cache /openapi.json for 5min (frequent re-fetch by tooling)
  Rule 4: cache /llms.txt for 1h (static, manual sync from content)

NO Authorization-header bypass (the original design — primitive doesn't
exist on our zone tier per D-12 and is a footgun anyway). Explicit
path allowlist is safer + simpler. The handler-side
`instant_unexpected_cached_response_total` P0 metric (added in api code,
NOT here) trips an alert if a request OUTSIDE the allowlist ever
responds with cache-hit semantics — defense in depth.

terraform fmt + validate both clean against cloudflare/cloudflare
v5.19.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

Terraform plan — staging

✅ no changes

plan output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # cloudflare_account_token.admin_tunnel will be created
  + resource "cloudflare_account_token" "admin_tunnel" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + expires_on   = "2026-08-28T23:59:59Z"
      + id           = (known after apply)
      + issued_on    = (known after apply)
      + last_used_on = (known after apply)
      + modified_on  = (known after apply)
      + name         = "instanode-migration-admin-tunnel-staging"
      + policies     = [
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "ad7a6f88896d498f98eb30592abfbbf4"
                    },
                  + {
                      + id = "77efc2c0724d4c4eb94bfd9656247130"
                    },
                  + {
                      + id = "db37e5f1cb1a4e1aabaef8deaea43575"
                    },
                  + {
                      + id = "a1c0fec57cf94af79479a6d827fa518c"
                    },
                  + {
                      + id = "1e13c5124ca64b72b1969a67e8829049"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.613a9e74136364c781a8e258326019f9" = "*"
                    }
                )
            },
        ]
      + status       = (known after apply)
      + value        = (sensitive value)
    }

  # cloudflare_account_token.deploy will be created
  + resource "cloudflare_account_token" "deploy" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + expires_on   = "2026-11-26T23:59:59Z"
      + id           = (known after apply)
      + issued_on    = (known after apply)
      + last_used_on = (known after apply)
      + modified_on  = (known after apply)
      + name         = "instanode-migration-deploy-staging"
      + policies     = [
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "c4df38be41c247b3b4b7702e76eadae0"
                    },
                  + {
                      + id = "3030687196b94b638145a3953da2b699"
                    },
                  + {
                      + id = "c8fed203ed3043cba015a93ad1616f1f"
                    },
                  + {
                      + id = "c03055bc037c4ea9afb9a9f104b7b721"
                    },
                  + {
                      + id = "e17beae8b8cb423a99b1730f21238bed"
                    },
                  + {
                      + id = "ed07f6c337da4195b4e72a1fb2c6bcae"
                    },
                  + {
                      + id = "6d7f2f5f5b1d4a0e9081fdc98d432fd1"
                    },
                  + {
                      + id = "4755a26eedb94da69e1066d98aa820be"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.zone.08a1a569d2d6f9a713dc6d62103c5dc6" = "*"
                    }
                )
            },
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "dc44f27f48ab405392a5f69fe822bd01"
                    },
                  + {
                      + id = "8d28297797f24fb8a0c332fe0866ec89"
                    },
                  + {
                      + id = "bf7481a1826f439697cb59a20b22293e"
                    },
                  + {
                      + id = "f7f0eda5697f475c90846e879bab8666"
                    },
                  + {
                      + id = "e086da7e2179491d91ee5f35b3ca210a"
                    },
                  + {
                      + id = "d2a1802cc9a34e30852f8b33869b2f3c"
                    },
                  + {
                      + id = "c1fde68c7bcc44588cbb6ddbc16d6480"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.613a9e74136364c781a8e258326019f9" = "*"
                    }
                )
            },
        ]
      + status       = (known after apply)
      + value        = (sensitive value)
    }

  # cloudflare_dns_record.apex will be created
  + resource "cloudflare_dns_record" "apex" {
      + comment             = "marketing apex; CNAME-flattened to Pages project"
      + comment_modified_on = (known after apply)
      + content             = "instanode-web.pages.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.api will be created
  + resource "cloudflare_dns_record" "api" {
      + comment             = "api; grey-cloud today, orange-cloud per Phase 4 cut (D-3)"
      + comment_modified_on = (known after apply)
      + content             = "152.42.154.144"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "api.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = false
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "A"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging[0] will be created
  + resource "cloudflare_dns_record" "staging" {
      + comment             = "staging mirror per D-2"
      + comment_modified_on = (known after apply)
      + content             = "instant-staging.instanode.dev.cdn.cloudflare.net"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging_dashboard[0] will be created
  + resource "cloudflare_dns_record" "staging_dashboard" {
      + comment             = "staging dashboard — QA-only; D-5 keeps prod dashboard off Pages"
      + comment_modified_on = (known after apply)
      + content             = "instanode-dashboard-staging.pages.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "dashboard.staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging_deployment_anchor[0] will be created
  + resource "cloudflare_dns_record" "staging_deployment_anchor" {
      + comment             = "anchor for deployment wildcard CNAME (CF requires a real record at the parent)"
      + comment_modified_on = (known after apply)
      + content             = "100::"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "deployment.staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "AAAA"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging_deployment_wildcard[0] will be created
  + resource "cloudflare_dns_record" "staging_deployment_wildcard" {
      + comment             = "wildcard for /deploy/new staging apps (mirrors prod *.deployment.instanode.dev)"
      + comment_modified_on = (known after apply)
      + content             = "deployment.staging.instanode.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "*.deployment.staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging_webhook[0] will be created
  + resource "cloudflare_dns_record" "staging_webhook" {
      + comment             = "staging /webhook/new receiver subdomain"
      + comment_modified_on = (known after apply)
      + content             = "100::"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "webhook.staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "AAAA"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.staging_wildcard[0] will be created
  + resource "cloudflare_dns_record" "staging_wildcard" {
      + comment             = "wildcard for per-tenant CF Container services in staging; routed via cloudflare_workers_route below"
      + comment_modified_on = (known after apply)
      + content             = "staging.instanode.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "*.staging.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.www will be created
  + resource "cloudflare_dns_record" "www" {
      + comment             = "www → apex redirect handled by CF page rule"
      + comment_modified_on = (known after apply)
      + content             = "instanode.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "www.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_pages_domain.instanode_web will be created
  + resource "cloudflare_pages_domain" "instanode_web" {
      + account_id            = "613a9e74136364c781a8e258326019f9"
      + certificate_authority = (known after apply)
      + created_on            = (known after apply)
      + domain_id             = (known after apply)
      + id                    = (known after apply)
      + name                  = "staging.instanode.dev"
      + project_name          = "instanode-web-staging"
      + status                = (known after apply)
      + validation_data       = (known after apply)
      + verification_data     = (known after apply)
      + zone_tag              = (known after apply)
    }

  # cloudflare_pages_domain.staging_marketing[0] will be created
  + resource "cloudflare_pages_domain" "staging_marketing" {
      + account_id            = "613a9e74136364c781a8e258326019f9"
      + certificate_authority = (known after apply)
      + created_on            = (known after apply)
      + domain_id             = (known after apply)
      + id                    = (known after apply)
      + name                  = "staging.instanode.dev"
      + project_name          = "instanode-web-staging"
      + status                = (known after apply)
      + validation_data       = (known after apply)
      + verification_data     = (known after apply)
      + zone_tag              = (known after apply)
    }

  # cloudflare_pages_project.instanode_web will be created
  + resource "cloudflare_pages_project" "instanode_web" {
      + account_id             = "613a9e74136364c781a8e258326019f9"
      + build_config           = {
          + build_caching       = (known after apply)
          + build_command       = "npm run build"
          + destination_dir     = "dist"
          + root_dir            = ""
          + web_analytics_tag   = (known after apply)
          + web_analytics_token = (sensitive value)
        }
      + canonical_deployment   = (known after apply)
      + created_on             = (known after apply)
      + deployment_configs     = {
          + preview    = {
              + always_use_latest_compatibility_date = false
              + build_image_major_version            = 3
              + compatibility_date                   = "2026-05-30"
              + compatibility_flags                  = []
              + fail_open                            = true
              + usage_model                          = (known after apply)
            }
          + production = {
              + always_use_latest_compatibility_date = false
              + build_image_major_version            = 3
              + compatibility_date                   = "2026-05-30"
              + compatibility_flags                  = []
              + env_vars                             = {
                  + "VITE_API_URL" = {
                      + type  = "plain_text"
                      + value = (sensitive value)
                    },
                  + "VITE_ENV" = {
                      + type  = "plain_text"
                      + value = (sensitive value)
                    },
                }
              + fail_open                            = true
              + usage_model                          = (known after apply)
            }
        }
      + domains                = (known after apply)
      + framework              = (known after apply)
      + framework_version      = (known after apply)
      + id                     = (known after apply)
      + latest_deployment      = (known after apply)
      + name                   = "instanode-web-staging"
      + preview_script_name    = (known after apply)
      + production_branch      = "main"
      + production_script_name = (known after apply)
      + source                 = {
          + config = {
              + deployments_enabled            = (known after apply)
              + owner                          = "instanodedev"
              + owner_id                       = (known after apply)
              + path_excludes                  = (known after apply)
              + path_includes                  = (known after apply)
              + pr_comments_enabled            = true
              + preview_branch_excludes        = []
              + preview_branch_includes        = [
                  + "*",
                ]
              + preview_deployment_setting     = "all"
              + production_branch              = "main"
              + production_deployments_enabled = (known after apply)
              + repo_id                        = (known after apply)
              + repo_name                      = "instanode-web"
            }
          + type   = "github"
        }
      + subdomain              = (known after apply)
      + uses_functions         = (known after apply)
    }

  # cloudflare_r2_bucket.shared will be created
  + resource "cloudflare_r2_bucket" "shared" {
      + account_id    = "613a9e74136364c781a8e258326019f9"
      + creation_date = (known after apply)
      + id            = (known after apply)
      + jurisdiction  = "default"
      + location      = "WNAM"
      + name          = "instant-shared-staging"
      + storage_class = "Standard"
    }

  # cloudflare_r2_bucket_lifecycle.shared_anon_24h will be created
  + resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + bucket_name  = "instant-shared-staging"
      + jurisdiction = "default"
      + rules        = [
          + {
              + conditions                = {
                  + prefix = "anon/"
                }
              + delete_objects_transition = {
                  + condition = {
                      + max_age = 86400
                      + type    = "Age"
                    }
                }
              + enabled                   = true
              + id                        = "anon-24h"
            },
        ]
    }

  # cloudflare_ruleset.api_cache_rules will be created
  + resource "cloudflare_ruleset" "api_cache_rules" {
      + description  = "D-12 explicit-path allowlist for api.staging.instanode.dev"
      + id           = (known after apply)
      + kind         = "zone"
      + last_updated = (known after apply)
      + name         = "api-cache-rules"
      + phase        = "http_request_cache_settings"
      + rules        = [
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + cache = false
                }
              + description       = "bypass cache for all api.* paths by default"
              + enabled           = true
              + expression        = "(http.host eq \"api.staging.instanode.dev\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 0
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 30
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /healthz at edge for 30s — same SHA across instances"
              + enabled           = true
              + expression        = "(http.host eq \"api.staging.instanode.dev\") and (http.request.uri.path eq \"/healthz\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 60
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 300
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /openapi.json at edge for 5min"
              + enabled           = true
              + expression        = "(http.host eq \"api.staging.instanode.dev\") and (http.request.uri.path eq \"/openapi.json\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 600
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 3600
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /llms.txt at edge for 1h"
              + enabled           = true
              + expression        = "(http.host eq \"api.staging.instanode.dev\") and (http.request.uri.path eq \"/llms.txt\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
        ]
      + version      = (known after apply)
      + zone_id      = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_workers_route.staging_mongodb[0] will be created
  + resource "cloudflare_workers_route" "staging_mongodb" {
      + id      = (known after apply)
      + pattern = "mongo-*.staging.instanode.dev/*"
      + script  = "instanode-mongodb-staging"
      + zone_id = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_workers_route.staging_nats[0] will be created
  + resource "cloudflare_workers_route" "staging_nats" {
      + id      = (known after apply)
      + pattern = "nats-*.staging.instanode.dev/*"
      + script  = "instanode-nats-staging"
      + zone_id = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_workers_route.staging_pg_customers[0] will be created
  + resource "cloudflare_workers_route" "staging_pg_customers" {
      + id      = (known after apply)
      + pattern = "pg-customer-*.staging.instanode.dev/*"
      + script  = "instanode-pg-customers-staging"
      + zone_id = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_workers_route.staging_redis[0] will be created
  + resource "cloudflare_workers_route" "staging_redis" {
      + id      = (known after apply)
      + pattern = "redis-*.staging.instanode.dev/*"
      + script  = "instanode-redis-provision-staging"
      + zone_id = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

Plan: 21 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + account_id            = "613a9e74136364c781a8e258326019f9"
  + admin_tunnel_token    = (sensitive value)
  + admin_tunnel_token_id = (known after apply)
  + deploy_token          = (sensitive value)
  + deploy_token_id       = (known after apply)
  + zone_id               = "08a1a569d2d6f9a713dc6d62103c5dc6"

Warning: Resource Destruction Considerations

  with cloudflare_r2_bucket_lifecycle.shared_anon_24h,
  on r2.tf line 21, in resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h":
  21: resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h" {

This resource cannot be destroyed from Terraform. If you create this
resource, it will be present in the API until manually deleted.

Posted by terraform.yml run 26676562022. Apply requires manual trigger of terraform-apply-staging.yml.

@github-actions
Copy link
Copy Markdown

Terraform plan — production

✅ no changes

plan output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # cloudflare_account_token.admin_tunnel will be created
  + resource "cloudflare_account_token" "admin_tunnel" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + expires_on   = "2026-08-28T23:59:59Z"
      + id           = (known after apply)
      + issued_on    = (known after apply)
      + last_used_on = (known after apply)
      + modified_on  = (known after apply)
      + name         = "instanode-migration-admin-tunnel-production"
      + policies     = [
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "ad7a6f88896d498f98eb30592abfbbf4"
                    },
                  + {
                      + id = "77efc2c0724d4c4eb94bfd9656247130"
                    },
                  + {
                      + id = "db37e5f1cb1a4e1aabaef8deaea43575"
                    },
                  + {
                      + id = "a1c0fec57cf94af79479a6d827fa518c"
                    },
                  + {
                      + id = "1e13c5124ca64b72b1969a67e8829049"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.613a9e74136364c781a8e258326019f9" = "*"
                    }
                )
            },
        ]
      + status       = (known after apply)
      + value        = (sensitive value)
    }

  # cloudflare_account_token.deploy will be created
  + resource "cloudflare_account_token" "deploy" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + expires_on   = "2026-11-26T23:59:59Z"
      + id           = (known after apply)
      + issued_on    = (known after apply)
      + last_used_on = (known after apply)
      + modified_on  = (known after apply)
      + name         = "instanode-migration-deploy-production"
      + policies     = [
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "c4df38be41c247b3b4b7702e76eadae0"
                    },
                  + {
                      + id = "3030687196b94b638145a3953da2b699"
                    },
                  + {
                      + id = "c8fed203ed3043cba015a93ad1616f1f"
                    },
                  + {
                      + id = "c03055bc037c4ea9afb9a9f104b7b721"
                    },
                  + {
                      + id = "e17beae8b8cb423a99b1730f21238bed"
                    },
                  + {
                      + id = "ed07f6c337da4195b4e72a1fb2c6bcae"
                    },
                  + {
                      + id = "6d7f2f5f5b1d4a0e9081fdc98d432fd1"
                    },
                  + {
                      + id = "4755a26eedb94da69e1066d98aa820be"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.zone.08a1a569d2d6f9a713dc6d62103c5dc6" = "*"
                    }
                )
            },
          + {
              + effect            = "allow"
              + permission_groups = [
                  + {
                      + id = "dc44f27f48ab405392a5f69fe822bd01"
                    },
                  + {
                      + id = "8d28297797f24fb8a0c332fe0866ec89"
                    },
                  + {
                      + id = "bf7481a1826f439697cb59a20b22293e"
                    },
                  + {
                      + id = "f7f0eda5697f475c90846e879bab8666"
                    },
                  + {
                      + id = "e086da7e2179491d91ee5f35b3ca210a"
                    },
                  + {
                      + id = "d2a1802cc9a34e30852f8b33869b2f3c"
                    },
                  + {
                      + id = "c1fde68c7bcc44588cbb6ddbc16d6480"
                    },
                ]
              + resources         = jsonencode(
                    {
                      + "com.cloudflare.api.account.613a9e74136364c781a8e258326019f9" = "*"
                    }
                )
            },
        ]
      + status       = (known after apply)
      + value        = (sensitive value)
    }

  # cloudflare_dns_record.apex will be created
  + resource "cloudflare_dns_record" "apex" {
      + comment             = "marketing apex; CNAME-flattened to Pages project"
      + comment_modified_on = (known after apply)
      + content             = "instanode-web.pages.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.api will be created
  + resource "cloudflare_dns_record" "api" {
      + comment             = "api; grey-cloud today, orange-cloud per Phase 4 cut (D-3)"
      + comment_modified_on = (known after apply)
      + content             = "152.42.154.144"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "api.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = false
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "A"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_dns_record.www will be created
  + resource "cloudflare_dns_record" "www" {
      + comment             = "www → apex redirect handled by CF page rule"
      + comment_modified_on = (known after apply)
      + content             = "instanode.dev"
      + created_on          = (known after apply)
      + id                  = (known after apply)
      + meta                = (known after apply)
      + modified_on         = (known after apply)
      + name                = "www.instanode.dev"
      + proxiable           = (known after apply)
      + proxied             = true
      + settings            = (known after apply)
      + tags                = (known after apply)
      + tags_modified_on    = (known after apply)
      + ttl                 = 60
      + type                = "CNAME"
      + zone_id             = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

  # cloudflare_pages_domain.instanode_web will be created
  + resource "cloudflare_pages_domain" "instanode_web" {
      + account_id            = "613a9e74136364c781a8e258326019f9"
      + certificate_authority = (known after apply)
      + created_on            = (known after apply)
      + domain_id             = (known after apply)
      + id                    = (known after apply)
      + name                  = "instanode.dev"
      + project_name          = "instanode-web"
      + status                = (known after apply)
      + validation_data       = (known after apply)
      + verification_data     = (known after apply)
      + zone_tag              = (known after apply)
    }

  # cloudflare_pages_project.instanode_web will be created
  + resource "cloudflare_pages_project" "instanode_web" {
      + account_id             = "613a9e74136364c781a8e258326019f9"
      + build_config           = {
          + build_caching       = (known after apply)
          + build_command       = "npm run build"
          + destination_dir     = "dist"
          + root_dir            = ""
          + web_analytics_tag   = (known after apply)
          + web_analytics_token = (sensitive value)
        }
      + canonical_deployment   = (known after apply)
      + created_on             = (known after apply)
      + deployment_configs     = {
          + preview    = {
              + always_use_latest_compatibility_date = false
              + build_image_major_version            = 3
              + compatibility_date                   = "2026-05-30"
              + compatibility_flags                  = []
              + fail_open                            = true
              + usage_model                          = (known after apply)
            }
          + production = {
              + always_use_latest_compatibility_date = false
              + build_image_major_version            = 3
              + compatibility_date                   = "2026-05-30"
              + compatibility_flags                  = []
              + env_vars                             = {
                  + "VITE_API_URL" = {
                      + type  = "plain_text"
                      + value = (sensitive value)
                    },
                  + "VITE_ENV" = {
                      + type  = "plain_text"
                      + value = (sensitive value)
                    },
                }
              + fail_open                            = true
              + usage_model                          = (known after apply)
            }
        }
      + domains                = (known after apply)
      + framework              = (known after apply)
      + framework_version      = (known after apply)
      + id                     = (known after apply)
      + latest_deployment      = (known after apply)
      + name                   = "instanode-web"
      + preview_script_name    = (known after apply)
      + production_branch      = "main"
      + production_script_name = (known after apply)
      + source                 = {
          + config = {
              + deployments_enabled            = (known after apply)
              + owner                          = "instanodedev"
              + owner_id                       = (known after apply)
              + path_excludes                  = (known after apply)
              + path_includes                  = (known after apply)
              + pr_comments_enabled            = true
              + preview_branch_excludes        = []
              + preview_branch_includes        = [
                  + "*",
                ]
              + preview_deployment_setting     = "all"
              + production_branch              = "main"
              + production_deployments_enabled = (known after apply)
              + repo_id                        = (known after apply)
              + repo_name                      = "instanode-web"
            }
          + type   = "github"
        }
      + subdomain              = (known after apply)
      + uses_functions         = (known after apply)
    }

  # cloudflare_r2_bucket.shared will be created
  + resource "cloudflare_r2_bucket" "shared" {
      + account_id    = "613a9e74136364c781a8e258326019f9"
      + creation_date = (known after apply)
      + id            = (known after apply)
      + jurisdiction  = "default"
      + location      = "WNAM"
      + name          = "instant-shared"
      + storage_class = "Standard"
    }

  # cloudflare_r2_bucket_lifecycle.shared_anon_24h will be created
  + resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h" {
      + account_id   = "613a9e74136364c781a8e258326019f9"
      + bucket_name  = "instant-shared"
      + jurisdiction = "default"
      + rules        = [
          + {
              + conditions                = {
                  + prefix = "anon/"
                }
              + delete_objects_transition = {
                  + condition = {
                      + max_age = 86400
                      + type    = "Age"
                    }
                }
              + enabled                   = true
              + id                        = "anon-24h"
            },
        ]
    }

  # cloudflare_ruleset.api_cache_rules will be created
  + resource "cloudflare_ruleset" "api_cache_rules" {
      + description  = "D-12 explicit-path allowlist for api.instanode.dev"
      + id           = (known after apply)
      + kind         = "zone"
      + last_updated = (known after apply)
      + name         = "api-cache-rules"
      + phase        = "http_request_cache_settings"
      + rules        = [
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + cache = false
                }
              + description       = "bypass cache for all api.* paths by default"
              + enabled           = true
              + expression        = "(http.host eq \"api.instanode.dev\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 0
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 30
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /healthz at edge for 30s — same SHA across instances"
              + enabled           = true
              + expression        = "(http.host eq \"api.instanode.dev\") and (http.request.uri.path eq \"/healthz\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 60
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 300
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /openapi.json at edge for 5min"
              + enabled           = true
              + expression        = "(http.host eq \"api.instanode.dev\") and (http.request.uri.path eq \"/openapi.json\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
          + {
              + action            = "set_cache_settings"
              + action_parameters = {
                  + browser_ttl = {
                      + default = 600
                      + mode    = "override_origin"
                    }
                  + cache       = true
                  + edge_ttl    = {
                      + default = 3600
                      + mode    = "override_origin"
                    }
                }
              + description       = "cache /llms.txt at edge for 1h"
              + enabled           = true
              + expression        = "(http.host eq \"api.instanode.dev\") and (http.request.uri.path eq \"/llms.txt\")"
              + id                = (known after apply)
              + logging           = (known after apply)
              + ref               = (known after apply)
            },
        ]
      + version      = (known after apply)
      + zone_id      = "08a1a569d2d6f9a713dc6d62103c5dc6"
    }

Plan: 10 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + account_id            = "613a9e74136364c781a8e258326019f9"
  + admin_tunnel_token    = (sensitive value)
  + admin_tunnel_token_id = (known after apply)
  + deploy_token          = (sensitive value)
  + deploy_token_id       = (known after apply)
  + zone_id               = "08a1a569d2d6f9a713dc6d62103c5dc6"

Warning: Resource Destruction Considerations

  with cloudflare_r2_bucket_lifecycle.shared_anon_24h,
  on r2.tf line 21, in resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h":
  21: resource "cloudflare_r2_bucket_lifecycle" "shared_anon_24h" {

This resource cannot be destroyed from Terraform. If you create this
resource, it will be present in the API until manually deleted.

Posted by terraform.yml run 26676562022. Apply requires manual trigger of terraform-apply-production.yml.

@mastermanas805 mastermanas805 merged commit 9448e80 into master May 30, 2026
7 of 9 checks passed
@mastermanas805 mastermanas805 deleted the cf/staging-bootstrap branch May 30, 2026 06:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant