Mobile-first Next.js app shell deployed to Cloudflare Workers using OpenNext.
- User requests read app-shell content from Cloudflare D1 (
CACHEbinding). - Runtime GraphQL calls are made through the
GRAPHQLservice binding. - Runtime GraphQL calls use OAuth client-credentials to obtain a bearer token.
DIRECTUS_CACHE_ALLOW_RUNTIME_REFRESHcontrols whether user traffic can refresh stale cache entries from origin.DIRECTUS_CACHE_SEED_ON_MISScontrols one-time bootstrap seeding when cache is empty (useful on first deploy).- Cache refresh can also run out-of-band through
POST /api/cache/refresh. - HTML and static asset cache headers are set in
src/middleware.ts. - Browser clients call
GET /api/cache/versionand reload only whencontentHashchanges. - 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(defaultcms_pages)DIRECTUS_LAYOUT_COLLECTION(defaultcms_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.
- HTML responses default to
Cache-Control: public, max-age=0, must-revalidate(override withapp_html_cache_control). - Next build assets under
/_next/static/*useCache-Control: public, max-age=31536000, immutableviapublic/_headers. - Worker-handled static routes default to
Cache-Control: public, max-age=31536000, immutable(override withapp_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.
Deployments are Terraform-first:
- GitHub Action builds OpenNext output (
.open-next/*). - GitHub Action runs
wrangler deploy --dry-runto produce a bundledworker.js. - Worker bundle + assets are copied into
terraform/.open-next. - 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_domainmappings
Wrangler commands are still available for local preview/testing.
Branch mapping in .github/workflows/terraform-deploy.yml:
prod-> workspace<repo-name>->deployment_environment=productiondev-> workspace<repo-name>-preview->deployment_environment=preview- workflow also sets
manage_d1_resources=trueonprodandfalseondev - workflow sets
manage_r2_resources=trueondev(R2 owner) andfalseonprodto avoid duplicate bucket create conflicts - optional GitHub Repository Variables can override workspace names:
TFC_WORKSPACE_PRODUCTIONTFC_WORKSPACE_PREVIEW
- if the target workspace does not exist, deploy workflow fails (workspace creation belongs to
initial-deploy.yml)
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(defaulttrue; setfalseto skip custom domain attachment)manage_worker_routes(defaulttrue; setfalseto 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_previeware still used as route pattern fallbacks when the new route variables are empty enable_workers_dev_subdomain/enable_workers_dev_previews(defaulttrue; controls workers.dev visibility and preview links)enable_worker_observability(defaulttrue)enable_worker_observability_logs(defaulttrue)enable_worker_observability_invocation_logs(defaulttrue)worker_observability_head_sampling_rate/worker_observability_logs_head_sampling_rate(default1.0)
Set these in Terraform Cloud workspace variables (sensitive where applicable):
cloudflare_tokencloudflare_account_idcloudflare_zone_iddirectus_client_id(sensitive recommended)directus_client_secret(sensitive)directus_auth_token_url(optional override; leave empty to usehttps://<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(settruein the shared-owner workspace to manage auth DNS through tunnel)manage_keycloak_tunnel_config(settruein the shared-owner workspace to manage tunnel ingress)keycloak_tunnel_shared_owner_environment(defaultpreview; only this environment manages shared auth DNS+tunnel config)keycloak_auth_dns_name(defaultauth)keycloak_tunnel_id(required whenmanage_keycloak_auth_dns_record=true)keycloak_tunnel_service(required whenmanage_keycloak_tunnel_config=true, must behttp://orhttps://)directus_auth_scope(optional)directus_auth_audience(optional, set when your IdP requires audience for client_credentials)directus_allow_client_header_auth_fallback(defaultfalse; set true only if you want non-bearer fallback)directus_layout_collection(defaultcms_layout_templates)directus_layout_fields(defaultid,template_key,status,site_key,html,css)directus_page_layout_field(defaultlayout_template_key)directus_layout_template_key_field(defaulttemplate_key)directus_layout_html_field(defaulthtml)directus_layout_css_field(defaultcss)directus_page_theme_field(defaulttheme_package_key)directus_page_theme_mode_field(defaulttheme_mode)directus_page_theme_switcher_position_field(defaulttheme_switcher_position)directus_page_theme_switcher_dock_direction_field(defaulttheme_switcher_dock_direction)directus_page_ga_measurement_id_field(defaultanalytics_google_measurement_id)directus_page_openobserve_rum_script_url_field(defaultanalytics_openobserve_rum_script_url)directus_page_openobserve_rum_config_field(defaultanalytics_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, defaulttrue)app_content_version_endpoint(optional, default/api/cache/version)app_content_version_query_param(optional, defaultcmsv)directus_cache_allow_runtime_refresh(optional, defaultfalse)directus_cache_seed_on_miss(optional, defaulttrue; allows first-request cache bootstrap)directus_media_proxy_endpoint(optional, defaulthttps://graphql.internal)directus_media_fetch_image_action(optional, defaultgravitee_directus_image_action_api_fetch_image_fetch_image_post)directus_media_fetch_timeout_seconds(optional, default20)directus_media_fetch_max_bytes(optional, default52428800)directus_media_proxy_asset_path_prefix(legacy passthrough setting; kept for compatibility)directus_media_cache_control(optional, defaultpublic, 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.
Run standard Next.js dev:
npm install
npm run devRun worker preview of the production build:
npm run cf:previewApply Terraform (D1 + Worker deploy + optional custom domains):
cd terraform
terraform applyIn GitHub Actions (.github/workflows/terraform-deploy.yml):
- pushes to
devdeploy preview Worker via Terraform workspace<repo-name>-preview - pushes to
proddeploy production Worker via Terraform workspace<repo-name>
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 variabledirectus_cache_refresh_mode_default), defaultpurge-and-refresh. - Override per call with query or JSON body:
POST /api/cache/refresh?mode=refreshPOST /api/cache/refresh?mode=purgePOST /api/cache/refresh?mode=purge-and-refresh
- JSON body also supports
purge_all: true|falsewhenmodeis omitted. - Cache rows are site-scoped by default (
app-shell:<collection>:<site_key>:<slug-or-all>), so internal/externalhomepages do not collide. - Optional
DIRECTUS_CACHE_KEYsupports tokens:{collection},{site_key}(or{site}),{slug},{scope}.
Use:
workers/cache-refresher/worker.tsworkers/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.tomlTerraform now provisions:
- D1 cache databases (
CACHEpreview + 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_domainwhenmanage_worker_domains=true) - Worker route mappings (
cloudflare_workers_routewhenmanage_worker_routes=true)
Auth/JWKS defaults:
DIRECTUS_AUTH_TOKEN_URLresolves to the managed Keycloak tunnel URL whendirectus_auth_token_urlis empty.CACHE_REFRESH_AUTH_JWKS_URLresolves fromcache_refresh_auth_issuerfirst (<issuer>/protocol/openid-connect/certs), then falls back to token URL derivation (.../token->.../certs) whencache_refresh_auth_jwks_urlis empty.CACHE_REFRESH_AUTH_JWKS_TIMEOUT_MSis set from Terraform variablecache_refresh_auth_jwks_timeout_ms(default12000).CACHE_REFRESH_AUTH_JWKS_FETCH_MAX_ATTEMPTSandCACHE_REFRESH_AUTH_JWKS_FETCH_RETRY_BASE_MScontrol per-URL JWKS retry behavior.CACHE_REFRESH_AUTH_JWKS_STALE_MAX_AGE_MSallows 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-originvsauth). - Refresh endpoint retry controls are exposed via
cache_refresh_max_attemptsandcache_refresh_retry_base_ms.
- Update
capacitor.config.jsonserver.urlto your deployed domain. - Add native platforms:
npm run mobile:add:ios
npm run mobile:add:android- Sync and open native projects:
npm run mobile:sync
npm run mobile:open:ios
npm run mobile:open:android