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/*).- Build runs a post-step patch (
scripts/patch-prefetch-hints-loader.mjs) so generated Workers accept new Next.js 16.2 manifest lookups (for exampleprefetch-hintsandsubresource-integrity).
- Build runs a post-step patch (
- GitHub Action runs
wrangler deploy --dry-runto produce a bundledworker.js. - Worker bundle + assets are copied into
terraform/.open-next. - Terraform Cloud applies (workspace execution mode is enforced to
agent):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=trueonprod(R2 owner) andfalseondevto keep first-run bucket creation and ongoing ownership consistent - 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)
Set this at repository or organization scope:
TFE_AGENT_POOL_ID(required; Terraform Deploy and Initial Deploy enforce workspaceexecution-mode=agentand this pool id)KEYCLOAK_REALM(required; must beinternalorexternal; workflows fail fast if missing/invalid and propagate this into Terraformkeycloak_realm)KEYCLOAK_AUTH_HOST(optional; defaults toauth-origin.suncoast.systems; used by deploy workflows when seedingdirectus_auth_token_url,cache_refresh_auth_issuer, andcache_refresh_auth_jwks_url)APP_AUTH_GATEWAY_URL(optional; defaults tohttps://login.suncoast.systems; browser login gateway URL injected into the Worker)APP_AUTH_GATEWAY_ADMIN_URL(optional; defaults tohttps://login.suncoast.systems; passed to Terraform so the Terraform agent can register/update/v1/apps)APP_BASE_DOMAIN(optional; defaults tosuncoast.systems; used to derive auth-gateway app base URLs)APP_AUTH_BASE_URL_PRODUCTION(optional; explicit production app URL for auth-gateway allowlist; defaulthttps://<repo-name>.<APP_BASE_DOMAIN>)APP_AUTH_BASE_URL_PREVIEW(optional; explicit preview app URL for auth-gateway allowlist; defaulthttps://<repo-name>-preview.<APP_BASE_DOMAIN>)
Required GitHub secret:
AUTH_GATEWAY_ADMIN_TOKEN(required; workflows pass this to Terraform asTF_VAR_app_auth_gateway_admin_tokenfor authenticated/v1/appswrites)
If workspace execution mode still shows remote, check HCP Terraform project defaults/policies:
workspace-level overrides for execution mode and agent pool must be allowed (or project defaults must already point to the same agent pool).
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_idkeycloak_realm(required; canonical realm selector for auth/content/bearer defaults; must beinternalorexternal)directus_client_lookup_from_vault(defaulttrue; when enabled, Vault values are always used for worker Directus auth)directus_client_id(only used whendirectus_client_lookup_from_vault=false)directus_client_secret(only used whendirectus_client_lookup_from_vault=false)directus_client_id_vault_name_template(defaultkeycloak-client-id-cms-site-{site_key})directus_client_secret_vault_name_template(defaultkeycloak-client-secret-cms-site-{site_key})directus_auth_token_url(optional override; leave empty to usehttps://<keycloak_auth_dns_name>.<domain><keycloak_auth_token_path>)keycloak_auth_token_path(optional override; defaults to/realms/<keycloak_realm>/protocol/openid-connect/token)keycloak_auth_jwks_path(optional override; defaults to/realms/<keycloak_realm>/protocol/openid-connect/certs)directus_content_site_key(optional override; defaults to<keycloak_realm>)cache_refresh_auth_allowed_clients(optional override; defaults tocms-site-<keycloak_realm>)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_token_request_max_attempts(default2)directus_token_request_retry_base_ms(default250)directus_token_request_timeout_ms(default6000)cache_refresh_auth_audience_lookup_from_vault(defaulttrue; when enabled, Vault value is used for refresh + Directus token audience)directus_auth_audience(only used whencache_refresh_auth_audience_lookup_from_vault=false)cache_refresh_auth_audience_vault_name(defaultkeycloak-client-id-graphql-api)vault_addr/vault_token/vault_namespace(required when Vault lookups are enabled)directus_allow_client_header_auth_fallback(defaultfalse; set true only if you want non-bearer fallback)app_auth_enabled(optional; defaults to true when gateway or OIDC browser auth config is present)app_auth_gateway_url(optional; defaulthttps://login.suncoast.systems)app_auth_gateway_admin_url(optional; defaulthttps://login.suncoast.systems; used by Terraform agent for auth-gateway app registration)app_auth_gateway_admin_token(sensitive; required whenmanage_auth_gateway_app_registrations=true; bearer token used by Terraform agent for auth-gateway app registration)manage_auth_gateway_app_registrations(defaulttrue; when enabled, Terraform agent keeps auth-gateway app slugs/base URLs up to date via/v1/apps)app_auth_gateway_request_timeout_seconds(default30; timeout for Terraform agent auth-gateway API requests)app_auth_base_url_production(optional; defaulthttps://<project_name>.<domain>for auth-gateway registration)app_auth_base_url_preview(optional; defaulthttps://<project_name>-preview.<domain>for auth-gateway registration)app_auth_app_slug_production(optional; default slugifiedproject_name; must exist in auth-gateway allowed apps)app_auth_app_slug_preview(optional; default<slugified project_name>-preview; must exist in auth-gateway allowed apps)app_auth_code_param(optional; defaultgateway_code)app_auth_start_path(optional; default/start)app_auth_exchange_path(optional; default/v1/auth/exchange)app_auth_client_id(optional override; in gateway mode defaults toauth-gateway-public, otherwise falls back toDIRECTUS_CLIENT_ID)app_auth_authorization_url(optional; defaults from token URL by replacing/tokenwith/auth)app_auth_token_url(optional; defaults toDIRECTUS_AUTH_TOKEN_URL)app_auth_userinfo_url(optional; defaults from token URL by replacing/tokenwith/userinfo)app_auth_logout_url(optional; defaults from token URL by replacing/tokenwith/logout)app_auth_scope(optional; defaultopenid profile email)app_auth_audience(optional)app_auth_account_referrer(optional fallback/override for Keycloak accountreferrer; defaultauth-gateway-public)app_auth_account_referrer_uri_external(optional fallback/override for Keycloak accountreferrer_uri; popup defaults to current full page URL)app_auth_account_referrer_uri_internal(optional fallback/override for Keycloak accountreferrer_uri; popup defaults to current full page URL)app_auth_redirect_uri(optional; defaults to<origin>/auth/callback)app_auth_post_logout_redirect_uri(optional; defaults to<origin>)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, default empty; supports one or more comma-separated mutation names, and the Worker auto-discovers/falls back when not set)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_issuer(optional override; defaults tohttps://<keycloak_auth_hostname>/realms/<keycloak_realm>)cache_refresh_auth_jwks_url(sensitive recommended)GCP_LOGGING_CREDENTIALS(sensitive)- plus existing non-secret app vars (
domain,project_name, etc.)
By default, the template derives D1/R2 names from project_name:
d1_dev_cache_name-><project_name>-dev-cached1_prod_cache_name-><project_name>-prod-cacher2_dev_media_cache_name-><project_name>-dev-media-cacher2_prod_media_cache_name-><project_name>-prod-media-cache
During initial-deploy.yml, these are explicitly written to Terraform workspace
variables from a sanitized repository-name slug.
You can override any of the four names with explicit Terraform workspace variables.
Set d1_dev_read_replication_mode / d1_prod_read_replication_mode to match
your current D1 configuration (disabled or auto).
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-origin.suncoast.systems/realms/<keycloak_realm>/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:
KEYCLOAK_REALMis the canonical selector forinternalvsexternalbehavior across Terraform and worker runtime defaults.DIRECTUS_AUTH_TOKEN_URLresolves to the managed Keycloak tunnel URL whendirectus_auth_token_urlis empty.CACHE_REFRESH_AUTH_ISSUERdefaults tohttps://<keycloak_auth_hostname>/realms/<keycloak_realm>whencache_refresh_auth_issueris 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.
The shell now exposes a browser auth runtime for both in-shell components and MFEs:
- Floating profile button is rendered beside the widget dock.
- Guest state uses a default profile silhouette icon.
- Signed-in state uses profile
picture/avatar (with deterministic fallback avatar URL when picture is missing). - Browser login uses auth-gateway by default (
https://login.suncoast.systems/start+/v1/auth/exchange). - Login callback route remains
/auth/callbackand exchangesgateway_code(or configured code param). - OIDC direct auth (
authorization_url+token_url) remains available as fallback. - Build stamp (
Build <version+sha@timestamp>) is shown in the auth profile popup; if commit/timestamp are unavailable, it falls back to package version and Next build ID. - Shell runtime schedules automatic session refresh about 1 minute before token expiry (max cadence 14 minutes).
- Shell (not MFE) shows a session-expiry modal starting ~2m30s before token expiry with refresh and log-out actions.
- Shell runtime enforces token-expiry logout at
exptime (same auth logout flow as profile-menu sign-out), even if the modal is not visible. - Browser auth writes a shared hint cookie (
suncoast_auth_present) on*.suncoast.systemsso sibling apps can auto-bootstrap sign-in and local sign-out sync when users move between apps.
Global API for MFEs:
window.__SUNCOAST_AUTH__.isLoggedIn()window.__SUNCOAST_AUTH__.getState()window.__SUNCOAST_AUTH__.getAccessToken()window.__SUNCOAST_AUTH__.getRefreshToken()window.__SUNCOAST_AUTH__.getProfile()window.__SUNCOAST_AUTH__.login()/logout()window.__SUNCOAST_AUTH__.refreshSession(force?)window.__SUNCOAST_AUTH__.ensureFreshToken(minValiditySeconds?)window.__SUNCOAST_AUTH__.updateToken(minValiditySeconds?)window.__SUNCOAST_AUTH__.refreshAccessToken(minValiditySeconds?)window.__SUNCOAST_AUTH__.refreshToken(minValiditySeconds?)window.__SUNCOAST_AUTH__.subscribe(listener)
Runtime snapshot also lives at window.__SUNCOAST_RUNTIME__.auth.
- 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