Skip to content

dotcomrow/cloudflare-shell-template-app

Repository files navigation

Suncoast Home App Shell

Mobile-first Next.js app shell deployed to Cloudflare Workers using OpenNext.

Runtime Architecture

  1. User requests read app-shell content from Cloudflare D1 (CACHE binding).
  2. Runtime GraphQL calls are made through the GRAPHQL service binding.
  3. Runtime GraphQL calls use OAuth client-credentials to obtain a bearer token.
  4. DIRECTUS_CACHE_ALLOW_RUNTIME_REFRESH controls whether user traffic can refresh stale cache entries from origin.
  5. DIRECTUS_CACHE_SEED_ON_MISS controls one-time bootstrap seeding when cache is empty (useful on first deploy).
  6. Cache refresh can also run out-of-band through POST /api/cache/refresh.
  7. HTML and static asset cache headers are set in src/middleware.ts.
  8. Browser clients call GET /api/cache/version and reload only when contentHash changes.
  9. Directus media is served from /assets/<file-id> by the shell Worker, backed by R2 (MEDIA) and fetched through a Hasura GraphQL action over the GraphQL proxy service binding.

Homepage origin query uses GraphQL read-items mutations generated from:

  • DIRECTUS_CONTENT_COLLECTION (default cms_pages)
  • DIRECTUS_LAYOUT_COLLECTION (default cms_layout_templates)

The shell fetches the page row, resolves layout_template_key, fetches the template HTML/CSS, renders slot placeholders ({{ slot:main }}, etc.), and serves CMS HTML directly.

Browser Cache Versioning

  • HTML responses default to Cache-Control: public, max-age=0, must-revalidate (override with app_html_cache_control).
  • Next build assets under /_next/static/* use Cache-Control: public, max-age=31536000, immutable via public/_headers.
  • Worker-handled static routes default to Cache-Control: public, max-age=31536000, immutable (override with app_static_cache_control).
  • API routes default to Cache-Control: no-store, no-cache, must-revalidate.
  • The page includes a content hash (data-content-hash) and checks /api/cache/version.
  • If the hash changes after a Directus refresh/deploy, the client reloads with ?cmsv=<hash>.
  • If the hash is unchanged, the browser keeps using cached content.

Deployment Model

Deployments are Terraform-first:

  1. GitHub Action builds OpenNext output (.open-next/*).
  2. GitHub Action runs wrangler deploy --dry-run to produce a bundled worker.js.
  3. Worker bundle + assets are copied into terraform/.open-next.
  4. Terraform Cloud applies:
    • cloudflare_worker (ensures the Worker service exists)
    • cloudflare_worker_version (Worker code + bindings + assets)
    • cloudflare_workers_deployment (ships latest version to 100%)
    • D1 database resources (cloudflare_d1_database)
    • optional cloudflare_workers_custom_domain mappings

Wrangler commands are still available for local preview/testing.

Branch mapping in .github/workflows/terraform-deploy.yml:

  • prod -> workspace <repo-name> -> deployment_environment=production
  • dev -> workspace <repo-name>-preview -> deployment_environment=preview
  • workflow also sets manage_d1_resources=true on prod and false on dev
  • workflow sets manage_r2_resources=true on dev (R2 owner) and false on prod to avoid duplicate bucket create conflicts
  • optional GitHub Repository Variables can override workspace names:
    • TFC_WORKSPACE_PRODUCTION
    • TFC_WORKSPACE_PREVIEW
  • if the target workspace does not exist, deploy workflow fails (workspace creation belongs to initial-deploy.yml)

Terraform Variables For Worker Domains

Terraform now maps custom domains to deployed Worker services using cloudflare_workers_custom_domain. Terraform also maps URL routes to Worker services using cloudflare_workers_route.

  • worker_service_name_production (optional; default <project_name>)
  • worker_service_name_preview (optional; default <project_name>-preview)
  • worker_preview_hostname (optional; default <project_name>-preview.<domain>)
  • manage_worker_domains (default true; set false to skip custom domain attachment)
  • manage_worker_routes (default true; set false to skip route management)
  • worker_production_route_pattern (optional; default <project_name>.<domain>/*)
  • worker_preview_route_pattern (optional; default <worker_preview_hostname>/*)
  • legacy worker_domain_environment_production / worker_domain_environment_preview are still used as route pattern fallbacks when the new route variables are empty
  • enable_workers_dev_subdomain / enable_workers_dev_previews (default true; controls workers.dev visibility and preview links)
  • enable_worker_observability (default true)
  • enable_worker_observability_logs (default true)
  • enable_worker_observability_invocation_logs (default true)
  • worker_observability_head_sampling_rate / worker_observability_logs_head_sampling_rate (default 1.0)

Required Terraform Variables

Set these in Terraform Cloud workspace variables (sensitive where applicable):

  • cloudflare_token
  • cloudflare_account_id
  • cloudflare_zone_id
  • directus_client_id (sensitive recommended)
  • directus_client_secret (sensitive)
  • directus_auth_token_url (optional override; leave empty to use https://<keycloak_auth_dns_name>.<domain><keycloak_auth_token_path>)
  • keycloak_auth_token_path (default /realms/external/protocol/openid-connect/token)
  • manage_keycloak_auth_dns_record (set true in the shared-owner workspace to manage auth DNS through tunnel)
  • manage_keycloak_tunnel_config (set true in the shared-owner workspace to manage tunnel ingress)
  • keycloak_tunnel_shared_owner_environment (default preview; only this environment manages shared auth DNS+tunnel config)
  • keycloak_auth_dns_name (default auth)
  • keycloak_tunnel_id (required when manage_keycloak_auth_dns_record=true)
  • keycloak_tunnel_service (required when manage_keycloak_tunnel_config=true, must be http:// or https://)
  • directus_auth_scope (optional)
  • directus_auth_audience (optional, set when your IdP requires audience for client_credentials)
  • directus_allow_client_header_auth_fallback (default false; set true only if you want non-bearer fallback)
  • directus_layout_collection (default cms_layout_templates)
  • directus_layout_fields (default id,template_key,status,site_key,html,css)
  • directus_page_layout_field (default layout_template_key)
  • directus_layout_template_key_field (default template_key)
  • directus_layout_html_field (default html)
  • directus_layout_css_field (default css)
  • directus_page_theme_field (default theme_package_key)
  • directus_page_theme_mode_field (default theme_mode)
  • directus_page_theme_switcher_position_field (default theme_switcher_position)
  • directus_page_theme_switcher_dock_direction_field (default theme_switcher_dock_direction)
  • directus_page_ga_measurement_id_field (default analytics_google_measurement_id)
  • directus_page_openobserve_rum_script_url_field (default analytics_openobserve_rum_script_url)
  • directus_page_openobserve_rum_config_field (default analytics_openobserve_rum_config)
  • app_monitoring_script_src (optional URL)
  • app_monitoring_script_inline (optional inline JS)
  • app_google_ads_client_id (optional ads client id)
  • app_html_cache_control (optional override for HTML cache header)
  • app_static_cache_control (optional override for static asset cache header)
  • app_content_version_check_enabled (optional, default true)
  • app_content_version_endpoint (optional, default /api/cache/version)
  • app_content_version_query_param (optional, default cmsv)
  • directus_cache_allow_runtime_refresh (optional, default false)
  • directus_cache_seed_on_miss (optional, default true; allows first-request cache bootstrap)
  • directus_media_proxy_endpoint (optional, default https://graphql.internal)
  • directus_media_fetch_image_action (optional, default gravitee_directus_image_action_api_fetch_image_fetch_image_post)
  • directus_media_fetch_timeout_seconds (optional, default 20)
  • directus_media_fetch_max_bytes (optional, default 52428800)
  • directus_media_proxy_asset_path_prefix (legacy passthrough setting; kept for compatibility)
  • directus_media_cache_control (optional, default public, max-age=31536000, immutable)
  • cache_refresh_auth_jwks_url (sensitive recommended)
  • GCP_LOGGING_CREDENTIALS (sensitive)
  • plus existing non-secret app vars (domain, project_name, etc.)

Ensure d1_dev_cache_name and d1_prod_cache_name are set to the D1 names you want Terraform to manage in the target Cloudflare account. Set d1_dev_read_replication_mode / d1_prod_read_replication_mode to match your current D1 configuration (disabled or auto). Set r2_dev_media_cache_name / r2_prod_media_cache_name to the R2 bucket names for media cache. Keep manage_r2_resources=true for the workspace that owns bucket creation.

If keycloak_auth_dns_name already exists in Cloudflare and is not in Terraform state yet, import that DNS record before enabling manage_keycloak_auth_dns_record=true. When manage_keycloak_tunnel_config=true, Terraform manages tunnel ingress config for that tunnel id. Use a dedicated tunnel (or one shared config owner) to avoid overwriting rules managed elsewhere.

For preview branch deploys, attach the same required secrets/variables (or variable sets) to the preview workspace as production.

Local Development

Run standard Next.js dev:

npm install
npm run dev

Run worker preview of the production build:

npm run cf:preview

Build And Deploy

Apply Terraform (D1 + Worker deploy + optional custom domains):

cd terraform
terraform apply

In GitHub Actions (.github/workflows/terraform-deploy.yml):

  • pushes to dev deploy preview Worker via Terraform workspace <repo-name>-preview
  • pushes to prod deploy production Worker via Terraform workspace <repo-name>

Cache Refresh Endpoint

curl -X POST "https://<app-domain>/api/cache/refresh" \
  -H "Authorization: Bearer <access_token>"

Modes:

  • Default mode is controlled by DIRECTUS_CACHE_REFRESH_MODE_DEFAULT (Terraform variable directus_cache_refresh_mode_default), default purge-and-refresh.
  • Override per call with query or JSON body:
    • POST /api/cache/refresh?mode=refresh
    • POST /api/cache/refresh?mode=purge
    • POST /api/cache/refresh?mode=purge-and-refresh
  • JSON body also supports purge_all: true|false when mode is omitted.
  • Cache rows are site-scoped by default (app-shell:<collection>:<site_key>:<slug-or-all>), so internal/external home pages do not collide.
  • Optional DIRECTUS_CACHE_KEY supports tokens: {collection}, {site_key} (or {site}), {slug}, {scope}.

Scheduled Refresh Worker (Optional)

Use:

  • workers/cache-refresher/worker.ts
  • workers/cache-refresher/wrangler.toml.example

Typical Keycloak config:

  • TOKEN_URL = "https://auth.suncoast.systems/realms/external/protocol/openid-connect/token"
  • TOKEN_AUDIENCE = "<graphql-api-client-id>"

Deploy refresher worker:

wrangler deploy --config workers/cache-refresher/wrangler.toml

Terraform Note

Terraform now provisions:

  • D1 cache databases (CACHE preview + production)
  • Production Worker service + version + deployment (cloudflare_worker + cloudflare_worker_version + cloudflare_workers_deployment)
  • Runtime bindings and secrets for the app Worker
  • Optional Worker custom domain mappings (cloudflare_workers_custom_domain when manage_worker_domains=true)
  • Worker route mappings (cloudflare_workers_route when manage_worker_routes=true)

Auth/JWKS defaults:

  • DIRECTUS_AUTH_TOKEN_URL resolves to the managed Keycloak tunnel URL when directus_auth_token_url is empty.
  • CACHE_REFRESH_AUTH_JWKS_URL resolves from cache_refresh_auth_issuer first (<issuer>/protocol/openid-connect/certs), then falls back to token URL derivation (.../token -> .../certs) when cache_refresh_auth_jwks_url is empty.
  • CACHE_REFRESH_AUTH_JWKS_TIMEOUT_MS is set from Terraform variable cache_refresh_auth_jwks_timeout_ms (default 12000).
  • CACHE_REFRESH_AUTH_JWKS_FETCH_MAX_ATTEMPTS and CACHE_REFRESH_AUTH_JWKS_FETCH_RETRY_BASE_MS control per-URL JWKS retry behavior.
  • CACHE_REFRESH_AUTH_JWKS_STALE_MAX_AGE_MS allows stale JWKS fallback when upstream cert endpoints are temporarily unavailable.
  • Runtime auth verification also tries issuer-derived JWKS from the incoming token (iss) to handle auth host aliasing (auth-origin vs auth).
  • Refresh endpoint retry controls are exposed via cache_refresh_max_attempts and cache_refresh_retry_base_ms.

iOS And Android Wrapping (Capacitor)

  1. Update capacitor.config.json server.url to your deployed domain.
  2. Add native platforms:
npm run mobile:add:ios
npm run mobile:add:android
  1. Sync and open native projects:
npm run mobile:sync
npm run mobile:open:ios
npm run mobile:open:android

About

Suncoast Systems WWW Home Cloudflare app

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors