This document provides complete instructions for deploying Application Security and Delivery Portfolio (ADSP) Use-Case 4 in Google Cloud Platform.
Use-Case 4 deploys NGINX Gateway Fabric (NGF) on the Kubernetes Gateway API. WAF and API protection are provided by F5 Distributed Cloud at the edge.
This repository deploys a Kubernetes-based application delivery demonstration consisting of:
- Network Infrastructure - VPC with a dedicated
k8ssubnet (with secondary ranges for pods and services), management subnet, and NAT for private nodes - GKE Standard Cluster - zonal cluster with private nodes, public control plane locked down by authorized networks, Dataplane V2, Workload Identity, shielded nodes
- F5 NGINX Gateway Fabric (NGF) running NGINX Plus, installed via the
oci://ghcr.io/nginx/charts/nginx-gateway-fabricchart. The control plane provisions an NGINX data planeDeploymentand aServiceof typeLoadBalancerwhen theGatewayis created. - Application Workload -
comfy-capybaradeployed via theoci://ghcr.io/knowbase/charts/comfy-capybaraHelm chart, exposed through a Gateway APIHTTPRouteattached to the NGFGateway - F5 Distributed Cloud (XC) - HTTPS LoadBalancer with WAF and API protection (validation report, fall-through report) fronting the NGF data plane; origin auto-discovered from the data plane LoadBalancer IP via remote state
The deployment is orchestrated entirely through GitHub Actions using Terraform with GCS remote state. Local execution is not supported.
The GitHub Actions workflow deploys modules sequentially with dependencies:
1. Bootstrap State Bucket (GCS)
↓
2. Terraform: Infra (VPC, k8s subnet with secondary ranges, NAT, firewall)
↓
3. Terraform: GKE (Standard zonal cluster, private nodes, authorized networks)
↓
4. Terraform: NGF (Gateway API CRDs, NGF control plane Helm release, Gateway)
↓
5. Terraform: App (helm_release of comfy-capybara; emits a Gateway API HTTPRoute)
↓
6. Terraform: F5 XC (HTTP LB + WAF + api_definition from your OAS; origin = NGF data plane LB IP)
Routing Flow:
- The NGF
Gateway(namespacenginx-gateway) has a singlehttplistener on port 80 withallowedRoutes.namespaces.from: All - Creating the Gateway makes the NGF control plane provision a data plane
Deploymentand aServicenamed<gateway-name>-nginxof typeLoadBalancerin thenginx-gatewaynamespace - The app
HTTPRoute(namespacecomfy-capybara) attaches to that Gateway viaparentRefsand routes:/api→apiservice on8000, with aURLRewritefilter (ReplacePrefixMatch: /) that strips the/apiprefix before forwarding/→frontendservice on8080
API Protection Flow:
- OpenAPI spec at
config/uc4/app/oas/openapi.jsonis uploaded by the workflow to the XC object store via the stored-objects API and referenced by thevolterra_api_definitionresource - The
volterra_http_loadbalancerattaches the api_definition with validation active in report mode and fall-through in report mode by default
Destroy operations run in reverse order: XC → App → NGF → GKE → Infra → State Bucket. The infra destroy job sweeps any GKE-managed k8s-* / gke-* LoadBalancer firewall rules left attached to the VPC before deleting the network.
- GCP Project with billing enabled
- Required APIs enabled:
- Compute Engine API
- Kubernetes Engine API
- Cloud Resource Manager API
- Cloud Storage API
- IAM Service Account Credentials API
- Service Account with the following roles:
roles/compute.adminroles/container.adminroles/storage.adminroles/iam.serviceAccountUser
- Workload Identity Pool configured for GitHub Actions federation
- Sufficient Quotas:
- CPUs: 8+ (default GKE node pool is 2×
e2-standard-4) - External IP addresses: 2+ (NAT + NGF data plane LoadBalancer)
- Persistent disk: 100+ GB
- CPUs: 8+ (default GKE node pool is 2×
The roles above are the simplest set that lets the workflow run end-to-end. For least-privilege, replace roles/compute.admin and roles/container.admin on the deploy SA with the narrower split:
roles/compute.networkAdmin(VPC, subnets, router, NAT)roles/compute.securityAdmin(firewall rules)roles/container.clusterAdmin(GKE cluster + node pool CRUD)roles/container.developer(helm / kubectl into the cluster)
roles/storage.admin can be swapped for a custom storage-admin role with storage.buckets.{create,get,update} + storage.objects.{create,get,delete,list}. Bind roles/iam.serviceAccountUser only on the runtime SA attached to GKE nodes, not project-wide.
The runtime SA referenced by k8s.gcp_runtime_service_account_email in config/uc4/gcp/env.json is the same SA UC1/UC2 use. It carries:
roles/logging.logWriter(GKE node telemetry)roles/monitoring.metricWriterroles/monitoring.viewerroles/stackdriver.resourceMetadata.writer
UC4 does not mount anything from GCS into the data plane, so no Workload Identity binding for a bundle reader is required.
The NGF NGINX Plus data plane image comes from private-registry.nginx.com, which requires a valid NGINX Plus subscription:
- NGINX JWT Token - from MyF5 portal, used for the license (
nplus-license) secret and as the registry username - NGINX Repository Client Certificate -
nginx-repo.crtfrom the subscription bundle - NGINX Repository Client Key -
nginx-repo.keyfrom the subscription bundle
The cluster pulls the Plus data plane image directly using the nginx-plus-registry-secret (dockerconfigjson) that Terraform creates from NGINX_JWT; the NGF control plane image is public (ghcr.io).
- XC Tenant with API access enabled
- API Certificate (.p12 file) with password
- Namespace - automatically created by Terraform
- Custom Domain configured for
app_domain(XC also supports tenant-provided domains)
- Forked Repository with Actions enabled
- Protected Branches:
deploy-adsp-uc4- triggers validation + deploymenttest-adsp-uc4- triggers validation onlydestroy-adsp-uc4- triggers destroy workflow
Configure the following secrets in GitHub repository settings: Settings → Secrets and variables → Actions → New repository secret
| Secret Name | Description | How to Obtain |
|---|---|---|
GCP_WORKLOAD_IDENTITY_PROVIDER |
Workload Identity Provider resource name | Format: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID |
GCP_SERVICE_ACCOUNT |
Deploy service account email | Format: SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com |
NGINX_JWT |
NGINX Plus entitlement JWT | Download from MyF5 portal under your NGINX Plus subscription |
NGINX_REPO_CRT |
Client certificate for private-registry.nginx.com |
nginx-repo.crt contents from NGINX subscription bundle |
NGINX_REPO_KEY |
Client key for private-registry.nginx.com |
nginx-repo.key contents from NGINX subscription bundle |
VES_P12_CONTENT |
Base64-encoded XC API certificate (.p12 file) | Run: base64 -w 0 /path/to/certificate.p12 (Linux) or base64 -i /path/to/certificate.p12 (macOS) |
VES_P12_PASSWORD |
Password for XC API certificate | Provided when downloading certificate from XC console |
All secret values are the file contents (PEM body / JWT body / base64 blob), not file paths.
If you need to create the Workload Identity Pool:
# Set variables
PROJECT_ID="your-project-id"
PROJECT_NUMBER="your-project-number"
POOL_NAME="${PREFIX}-github-actions-pool"
PROVIDER_NAME="github-provider"
SA_PREFIX=""
SA_SUFFIX="github-actions-sa"
SERVICE_ACCOUNT="${SA_PREFIX}-${SA_SUFFIX}@${PROJECT_ID}.iam.gserviceaccount.com"
GH_ORGANIZATION="your-github-org"
GH_REPO="${GH_ORGANIZATION}/your-repo"
# 1. Create the service account
gcloud iam service-accounts create "${SA_PREFIX}-${SA_SUFFIX}" \
--display-name="${SA_PREFIX} GitHub Actions for ADSP Automation" \
--project="${PROJECT_ID}"
# 2. Grant the four roles
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/compute.admin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/container.admin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/storage.admin"
gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
--member="serviceAccount:${SERVICE_ACCOUNT}" \
--role="roles/iam.serviceAccountUser"
# 3. Create Workload Identity Pool
gcloud iam workload-identity-pools create "${POOL_NAME}" \
--project="${PROJECT_ID}" \
--location="global" \
--display-name="GitHub Actions Pool"
# 4. Create Provider
gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_NAME}" \
--project="${PROJECT_ID}" \
--location="global" \
--workload-identity-pool="${POOL_NAME}" \
--display-name="GitHub Provider" \
--attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository_owner == '${GH_ORGANIZATION}'" \
--issuer-uri="https://token.actions.githubusercontent.com"
# 5. Grant service account access
gcloud iam service-accounts add-iam-policy-binding "${SERVICE_ACCOUNT}" \
--project="${PROJECT_ID}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_NAME}/attribute.repository/${GH_REPO}"The minimum edits to get UC4 running, assuming you already have GCP + WIF + F5 entitlements wired up per the Prerequisites section above.
{
"gcp_project_id": "<your-project-id>",
"gcp_region": "us-west1",
"gcp_zone": "us-west1-a",
"project_prefix": "<short-prefix>",
"resource_owner": "<initials>",
"admin_src_addr": ["1.2.3.4/32", "10.0.0.0/8"],
"tf_state_bucket": ""
}admin_src_addris a JSON array of quoted CIDR strings (IP/prefix). Bare IPs and unquoted values fail JSON parsing in the workflow.- Leave
tf_state_bucketempty; the workflow derives it as<project_prefix>-state-bucket.
{
"k8s": {
"gcp_runtime_service_account_email": "<runtime-sa>@<project-id>.iam.gserviceaccount.com"
}
}The runtime SA email is the one attached to GKE nodes. Every other field keeps its example default.
{
"app": {
"app_host": "<fqdn-served-by-NGF-and-XC>"
}
}app_host must equal xc_base.app_domain below.
{
"xc_base": {
"xc_tenant": "<your-tenant>",
"api_url": "https://<your-tenant>.console.ves.volterra.io/api",
"xc_namespace": "<xc-namespace>",
"app_domain": "<fqdn>"
}
}xc_namespace cannot be system or shared. app_domain must equal app.app_host.
Drop the OpenAPI spec for the app at config/uc4/app/oas/openapi.json (or openapi.yaml / openapi.yml). The workflow uploads it to the XC object store and feeds it into the XC API definition. If the file is missing the XC job fails with a clear message.
git checkout -b deploy-adsp-uc4 && git push -u origin deploy-adsp-uc4Watch the workflow in the Actions tab. Modules run: state bucket → infra → GKE → NGF → app → XC.
test-adsp-uc4 runs validate only. destroy-adsp-uc4 tears down in reverse order.
F5-ADSP-Automation/
├── .github/workflows/
│ ├── deploy-adsp-uc4-gcp.yml # Main deployment workflow
│ ├── destroy-adsp-uc4-gcp.yml # Destroy workflow
│ └── pr_tf_validate.yml # PR validation
├── config/
│ ├── common/
│ │ └── gcp/env.json # Shared GCP settings
│ └── uc4/
│ ├── gcp/env.json # UC4 GCP + GKE + NGF config
│ ├── app/
│ │ ├── env.json # comfy-capybara chart + route config
│ │ └── oas/openapi.json # OpenAPI spec for the app (you drop this)
│ └── xc/env.json # XC tenant + LoadBalancer + WAF/API feature flags
├── infra/gcp/ # Network infrastructure
├── k8s/gcp/ # GKE Standard cluster
├── f5/
│ ├── ngf/gcp/ # NGINX Gateway Fabric (NGINX Plus) + Gateway
│ └── xc/ # F5 Distributed Cloud (shared module)
├── app/gcp/ # comfy-capybara helm_release + HTTPRoute
└── docs/
└── ADSP-UC4-GCP.md # This document
Remote state is stored in GCS bucket ${project_prefix}-state-bucket:
state/uc4/infra/- VPC, subnets, firewall rulesstate/uc4/k8s/- GKE cluster + node poolstate/uc4/ngf/- Gateway API CRDs, NGF control plane Helm release, Gateway, secretsstate/uc4/app/- comfy-capybara Helm release, namespace, HTTPRoutestate/uc4/xc/- XC namespace, HTTP LoadBalancer, WAF policy, api_definition
Edit config/common/gcp/env.json:
{
"gcp_project_id": "your-gcp-project-id",
"gcp_region": "us-west1",
"gcp_zone": "us-west1-a",
"project_prefix": "your-prefix",
"resource_owner": "your-initials",
"admin_src_addr": ["1.2.3.4/32", "10.0.0.0/8"],
"tf_state_bucket": ""
}Required Changes:
gcp_project_id- Your GCP project IDgcp_region- Target GCP regiongcp_zone- Target GCP zone (must be insidegcp_region; GKE cluster is zonal)project_prefix- Unique prefix for resource naming (lowercase, alphanumeric)resource_owner- Your initials or identifier for resource taggingadmin_src_addr- Public IP CIDRs allowed to reach management interfaces and the GKE control plane. Each entry is a quoted CIDR (/32for a single host); bare IPs and unquoted values fail JSON parsing.
Leave as-is:
tf_state_bucket- auto-generated as${project_prefix}-state-bucket
Edit config/uc4/gcp/env.json:
{
"features": {
"gke": true,
"k8s_ingress": true
},
"k8s": {
"gcp_runtime_service_account_email": "<runtime-sa>@<project-id>.iam.gserviceaccount.com",
"release_channel": "REGULAR",
"node_machine_type": "e2-standard-4",
"node_count": 2,
"node_disk_size_gb": 50,
"node_disk_type": "pd-balanced",
"master_ipv4_cidr_block": "172.16.0.0/28",
"master_authorized_networks_extra": []
},
"ngf": {
"namespace": "nginx-gateway",
"chart_version": "2.6.4",
"gatewayclass_name": "nginx",
"nginx_plus_image_repository": "private-registry.nginx.com/nginx-gateway-fabric/nginx-plus",
"nginx_plus_image_tag": "2.6.4",
"gateway_api_crds_url": "https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml"
}
}Required Changes:
k8s.gcp_runtime_service_account_email- Service account attached to GKE nodes
Customizable Settings (k8s block):
release_channel-RAPID,REGULAR, orSTABLEnode_machine_type- GKE node machine typenode_count- number of nodes in the poolnode_disk_size_gb/node_disk_type- node boot diskmaster_ipv4_cidr_block- control plane private endpoint CIDR (must not overlap with subnets)master_authorized_networks_extra- additional CIDRs allowed to reach the control plane on top ofadmin_src_addr
Customizable Settings (ngf block):
chart_version/nginx_plus_image_tag- pin specific NGF versions (keep them aligned)gateway_api_crds_url- the upstream Gateway API standard-channel CRDs. The version must match what the chart version supports (NGF 2.6.4 → Gateway API v1.5.1).gatewayclass_name- the GatewayClass the chart creates and the Gateway references
Do Not Modify:
features.gke: true/features.k8s_ingress: true- required for UC4.k8s_ingressopens the GKE data-plane LoadBalancer ports (80/443) to admin + XC origin ranges.
Edit config/uc4/app/env.json:
{
"app": {
"namespace": "comfy-capybara",
"chart_repository": "oci://ghcr.io/knowbase/charts",
"chart_name": "comfy-capybara",
"chart_version": "0.4.0",
"app_host": "comfy.example.com",
"image_registry": "",
"image_tag": "",
"image_pull_secret_name": "",
"route_type": "httproute"
}
}Required Changes:
app_host- FQDN routed by theHTTPRoute. Must equalxc_base.app_domainin Step 5.
Customizable Settings:
chart_version- pin a specific chart release published by the comfy-capybara repoimage_registry/image_tag- override the chart's defaults if pulling from a fork or non-appVersiontag; empty values fall back to chart defaultsimage_pull_secret_name- name of a pre-createdSecretin the app namespace for a private registry
Do Not Modify:
route_type: "httproute"- selects the Gateway APIHTTPRoutepath of the shared app module.virtualserveris the UC2 (NIC) path.
Drop the OpenAPI spec for the app at one of:
config/uc4/app/oas/openapi.jsonconfig/uc4/app/oas/openapi.yamlconfig/uc4/app/oas/openapi.yml
The workflow uploads the file to the XC object store (stored_objects/swagger/uc4-app-oas) and references it from the XC volterra_api_definition. The XC job fails clearly if the file is missing.
The spec must be valid OpenAPI 3.x. Postman collections and raw Swagger 1.x are not accepted.
Edit config/uc4/xc/env.json:
{
"xc_base": {
"xc_tenant": "your-xc-tenant",
"api_url": "https://your-tenant.console.ves.volterra.io/api",
"xc_namespace": "your-namespace",
"create_namespace": true,
"app_domain": "your-app.example.com",
"origin_server": "",
"origin_port": "80",
"backend_bigip": false,
"backend_k8s_ingress": true,
"xc_waf_blocking": true
},
"xc_features": {
"xc_api_pro": true,
"xc_api_val": true,
"xc_api_val_all": true,
"xc_api_val_active": true,
"enforcement_report": true,
"fall_through_mode_report": true
}
}Required Changes:
xc_tenant- Your XC tenant nameapi_url- Your XC API URLxc_namespace- Desired namespace name (cannot besystemorshared)app_domain- Public domain XC will serve. Must equalapp.app_hostin Step 3.
Important:
backend_k8s_ingress: true- XC origin pool resolves the NGF data plane LoadBalancer IP fromstate/uc4/ngfvia remote state.origin_server: ""- leave empty; resolved automatically from NGF remote state.create_namespace: true- the deploy workflow creates the XC namespace directly via the API (not Terraform) before uploading the OAS spec, and destroy checks it's empty before deleting it. Set tofalseif you're bringing your own pre-existing namespace; the pipeline will then never create or delete it.xc_api_pro: true+xc_api_val_*+enforcement_report: true+fall_through_mode_report: true- default UC4 stance is "report everything API-related; block traditional WAF hits viaxc_waf_blocking: true". Flipenforcement_block: trueandfall_through_mode_report: falseif you want OAS enforcement.- The full set of feature flags is in f5/xc/variables.tf.
- Fork this repository to your GitHub organization or account
- Configure files:
config/common/gcp/env.jsonconfig/uc4/gcp/env.jsonconfig/uc4/app/env.jsonconfig/uc4/app/oas/openapi.json(or.yaml/.yml)config/uc4/xc/env.json
- Set GitHub Secrets (see GitHub Secrets Setup)
- Commit and push changes to
mainbranch - Create deployment branch:
git checkout -b deploy-adsp-uc4 git push origin deploy-adsp-uc4
- Monitor workflow execution in GitHub Actions tab
- Retrieve outputs (see Accessing Deployment Outputs)
To validate Terraform without applying:
git checkout -b test-adsp-uc4
git push origin test-adsp-uc4This triggers validation for all modules but skips terraform apply steps.
- Make configuration changes on
mainbranch - Merge or push to
deploy-adsp-uc4branch - Workflow will automatically plan and apply changes
WARNING: This permanently deletes all resources including the state bucket.
git checkout -b destroy-adsp-uc4
git push origin destroy-adsp-uc4Destroy sequence:
- F5 XC resources (HTTP LB, WAF, namespace)
- Application workload (comfy-capybara Helm release + namespace + HTTPRoute)
- NGF control plane, Gateway, Gateway API CRDs
- GKE cluster
- Network infrastructure (after sweeping orphaned GKE LoadBalancer firewall rules)
- GCS state bucket (including all history)
Activate Cloud Shell in GCP Console, then:
# Set variables from your config
PROJECT_PREFIX="your-prefix"
STATE_BUCKET="${PROJECT_PREFIX}-state-bucket"
# Get GKE cluster name
gcloud storage cat gs://${STATE_BUCKET}/state/uc4/k8s/default.tfstate | \
jq -r '.outputs.cluster_name.value'
# Get NGF data plane LoadBalancer public IP (XC origin)
gcloud storage cat gs://${STATE_BUCKET}/state/uc4/ngf/default.tfstate | \
jq -r '.outputs.k8s_ingress_external_ip.value'
# Get NGF namespace + Gateway name
gcloud storage cat gs://${STATE_BUCKET}/state/uc4/ngf/default.tfstate | \
jq -r '.outputs.ngf_namespace.value, .outputs.gateway_name.value'
# Get app FQDN + namespace
gcloud storage cat gs://${STATE_BUCKET}/state/uc4/app/default.tfstate | \
jq -r '.outputs.app_host.value, .outputs.app_namespace.value'
# Get XC public domain + LB name + WAF policy name
gcloud storage cat gs://${STATE_BUCKET}/state/uc4/xc/default.tfstate | \
jq -r '.outputs.endpoint.value, .outputs.xc_lb_name.value, .outputs.xc_waf_name.value'PROJECT_ID="your-gcp-project-id"
ZONE="your-gcp-zone"
CLUSTER_NAME=$(gcloud storage cat gs://${STATE_BUCKET}/state/uc4/k8s/default.tfstate | \
jq -r '.outputs.cluster_name.value')
gcloud container clusters get-credentials "${CLUSTER_NAME}" \
--zone="${ZONE}" --project="${PROJECT_ID}"| Output | Module | Description |
|---|---|---|
cluster_name |
k8s | GKE cluster name |
cluster_endpoint |
k8s | GKE control plane endpoint |
ngf_namespace |
ngf | Namespace hosting NGF and the Gateway |
gateway_name |
ngf | Gateway name (apps reference it from HTTPRoute parentRefs) |
k8s_ingress_external_ip |
ngf | NGF data plane LoadBalancer public IP (XC origin) |
app_host |
app | FQDN routed by the HTTPRoute |
app_namespace |
app | Namespace the comfy-capybara workload runs in |
endpoint |
xc | XC application domain (public URL) |
xc_lb_name |
xc | XC HTTP LoadBalancer resource name |
xc_waf_name |
xc | XC WAF policy resource name |
gcloud container clusters get-credentials "${CLUSTER_NAME}" \
--zone="${ZONE}" --project="${PROJECT_ID}"
kubectl get nodes
kubectl get nsNGF_NS=$(gcloud storage cat gs://${STATE_BUCKET}/state/uc4/ngf/default.tfstate | \
jq -r '.outputs.ngf_namespace.value')
# Control plane Deployment + provisioned data plane (<gateway>-nginx)
kubectl -n "${NGF_NS}" get pods -o wide
kubectl -n "${NGF_NS}" get svc
kubectl -n "${NGF_NS}" get gateways
kubectl -n "${NGF_NS}" get gatewayclasses
# Gateway should report PROGRAMMED=True and an address
kubectl -n "${NGF_NS}" describe gateway
# Tail control plane logs
kubectl -n "${NGF_NS}" logs -l app.kubernetes.io/name=nginx-gateway-fabric --tail=100APP_NS=$(gcloud storage cat gs://${STATE_BUCKET}/state/uc4/app/default.tfstate | \
jq -r '.outputs.app_namespace.value')
kubectl -n "${APP_NS}" get pods
kubectl -n "${APP_NS}" get svc
kubectl -n "${APP_NS}" get httproutes
kubectl -n "${APP_NS}" describe httprouteThe HTTPRoute should show Accepted=True and ResolvedRefs=True against the Gateway.
NGF_IP=$(gcloud storage cat gs://${STATE_BUCKET}/state/uc4/ngf/default.tfstate | \
jq -r '.outputs.k8s_ingress_external_ip.value')
APP_HOST=$(gcloud storage cat gs://${STATE_BUCKET}/state/uc4/app/default.tfstate | \
jq -r '.outputs.app_host.value')
# Through the NGF data plane LoadBalancer directly (Host header drives HTTPRoute match)
curl -H "Host: ${APP_HOST}" "http://${NGF_IP}/"
curl -H "Host: ${APP_HOST}" "http://${NGF_IP}/api/healthz"
# Through XC (public domain)
curl "https://${APP_HOST}/"
curl "https://${APP_HOST}/api/healthz"Without a matching Host header the Gateway returns 404; that's expected.
UC4 has no in-cluster WAF; protection is provided by XC. With xc_waf_blocking: true, XC blocks a SQLi-style probe at the edge:
curl -i "https://${APP_HOST}/api/users?id=1%20OR%201=1"- Login to XC Console:
https://your-tenant.console.ves.volterra.io - Verify the namespace exists:
Administration → Namespaces - Navigate to:
Multi-Cloud App Connect → HTTP Load Balancersin the configured namespace - The LoadBalancer should show:
- Domain matching
app_domain(and theendpointoutput) - Origin Pool with one origin server matching the NGF data plane LoadBalancer public IP
- WAF attached, in blocking or monitoring mode per
xc_waf_blocking - API Definition attached with active validation in report mode
- Domain matching
- Send live traffic and check
Security → Security Eventsfor WAF activity, validation reports, and fall-through reports.
Error: Error: google: could not find default credentials
Resolution:
- Verify
GCP_WORKLOAD_IDENTITY_PROVIDERsecret is correctly formatted - Verify
GCP_SERVICE_ACCOUNTsecret matches the service account with WIF binding - Ensure service account has required roles in GCP project
- Check Workload Identity Pool configuration allows repository access
Error: 403 Forbidden during Terraform operations
Resolution:
- Verify service account has
roles/compute.admin,roles/container.admin, androles/storage.admin - Check API enablement:
gcloud services list --enabled --project=PROJECT_ID - Ensure project billing is active
Error: The user does not have access to service account <runtime-sa>
Resolution:
- The deploy SA needs
roles/iam.serviceAccountUseron the runtime SA email set inconfig/uc4/gcp/env.json(k8s.gcp_runtime_service_account_email) - Bind on the runtime SA only, not project-wide
Error: Error: Failed to get existing workspaces: storage: bucket doesn't exist
Resolution:
- State bucket is auto-created by
bootstrap_state_bucketjob - Verify job completed successfully in Actions log
- Check bucket exists:
gcloud storage ls -p PROJECT_ID | grep state-bucket - Ensure service account has
roles/storage.admin
Error: Error acquiring the state lock
Resolution:
- Another workflow run may be in progress
- Wait for concurrent run to complete
- If stuck, manually remove lock:
gcloud storage ls gs://${STATE_BUCKET}/state/uc4/MODULE_NAME/default.tflock gcloud storage rm gs://${STATE_BUCKET}/state/uc4/MODULE_NAME/default.tflock
Error: jq: parse error: Invalid numeric literal in the infra job
Resolution:
- Your
admin_src_addr(or another CIDR/list field) has unquoted values - JSON requires
["1.2.3.4/32"], not[1.2.3.4/32]
Error: XC job fails with No OAS spec found at config/uc4/app/oas/...
Resolution:
- Drop your OpenAPI spec at
config/uc4/app/oas/openapi.json(or.yaml/.yml) and re-run
Error: Error: Get "https://...": dial tcp ...:443: i/o timeout during NGF or App apply
Resolution:
- GKE control plane is private; the runner reaches it via authorized networks
- Confirm
admin_src_addrinconfig/common/gcp/env.jsonincludes the runner's egress IPs, or usemaster_authorized_networks_extrainconfig/uc4/gcp/env.jsonto add GitHub Actions hosted-runner egress ranges
Error: ErrImagePull or ImagePullBackOff on the <gateway>-nginx data plane pods
Resolution:
- The
NGINX_REPO_CRT/NGINX_REPO_KEY/NGINX_JWTsecrets must match your active NGINX Plus subscription - Terraform creates
nginx-plus-registry-secretandnplus-licensein thenginx-gatewaynamespace fromNGINX_JWT; confirm they exist:kubectl -n nginx-gateway get secret - Confirm
nginx_plus_image_tagexists atprivate-registry.nginx.com/nginx-gateway-fabric/nginx-plus
Error: kubectl -n nginx-gateway describe gateway shows PROGRAMMED=False or no address
Resolution:
- Confirm the Gateway API CRDs installed:
kubectl get crd | grep gateway.networking.k8s.io. The version must match what the chart supports (NGF 2.6.4 → Gateway API v1.5.1, set bygateway_api_crds_url). - Confirm the
nginxGatewayClass exists and is Accepted:kubectl get gatewayclass - Check control plane logs:
kubectl -n nginx-gateway logs -l app.kubernetes.io/name=nginx-gateway-fabric
Error: kubectl -n comfy-capybara describe httproute shows Accepted=False (NotAllowedByListeners)
Resolution:
- The Gateway listener must allow routes from the app namespace. UC4 sets
allowedRoutes.namespaces.from: Allon thehttplistener; confirm it wasn't overridden. - Confirm the HTTPRoute
parentRefsname/namespace/sectionName: httpmatch the Gateway. The app module reads these fromstate/uc4/ngf.
Error: The NGF apply finishes but k8s_ingress_external_ip is null, or the XC job reports an empty origin
Resolution:
- The data plane Service and its LoadBalancer IP are provisioned asynchronously after the Gateway is accepted. The NGF module waits (
time_sleep180s) before reading the Service, but GCP LoadBalancer IP assignment can occasionally exceed that. - Re-run the
Terraform: NGFjob (push an empty commit todeploy-adsp-uc4); on the second pass the Service already has its IP. - Confirm the Service exists and has an external IP:
kubectl -n nginx-gateway get svc - Check GCP regional quota for forwarding rules.
Error: Error: error reading VES_P12_PASSWORD
Resolution:
- Verify
VES_P12_PASSWORDsecret is set in GitHub - Verify password matches the certificate
- Re-download certificate from XC console if expired
Error: Error: Failed to create Volterra API client
Resolution:
- Verify
VES_P12_CONTENTis correctly base64-encoded - Test decoding:
echo $VES_P12_CONTENT | base64 -d > test.p12 - Verify
api_urlinconfig/uc4/xc/env.jsonmatches the tenant - Check API certificate is not expired in XC console
Resolution:
- Confirm
backend_k8s_ingress: trueis set inconfig/uc4/xc/env.jsonso XC picks up the NGF data plane IP fromstate/uc4/ngf. - Confirm the data plane Service has an external IP (see the pending-LoadBalancer entry above).
- The
app_domaininconfig/uc4/xc/env.jsonmust equal theapp_hostinconfig/uc4/app/env.json. If they drift, XC forwards a Host header theHTTPRoutewon't match and the origin looks healthy while the app is unreachable through XC. - Direct-to-data-plane reachability must work before XC will look healthy.
Resolution:
- Confirm the OAS file under
config/uc4/app/oas/parses as valid OpenAPI 3.x. Postman collections and raw Swagger 1.x are not accepted.
Error: Quota 'CPUS' exceeded. Limit: X in region Y
Resolution:
- Request quota increase in GCP Console:
IAM & Admin → Quotas - Reduce
node_machine_typeornode_countinconfig/uc4/gcp/env.json
Error: The network resource '...' is already being used by '.../firewalls/k8s-...-node-http-hc'
Resolution:
- These are GKE-managed LoadBalancer firewall rules left behind when a
LoadBalancerService is reaped after the cluster is deleted. - The infra destroy job sweeps
k8s-*/gke-*firewalls on the VPC before deleting the network. If a run predates that step, re-pushdestroy-adsp-uc4to re-run the infra job.
- Never commit secrets to the repository
- Use GitHub Secrets for all sensitive values (passwords, certificates, API keys)
- Rotate credentials regularly (XC certificates, NGINX JWT, service account keys)
- Restrict
admin_src_addrto known IP addresses only - Enable branch protection on
deploy-*anddestroy-*branches - Review firewall rules before deployment in production environments
- Keep
env.jsonfiles non-secret - they should contain no credentials - Use meaningful
project_prefixvalues to avoid naming collisions - Tag resources using
resource_ownerfor cost tracking - Version control all changes to configuration files
- Test changes on
test-adsp-uc4branch before deploying
- Do not edit state files manually
- Enable versioning on state bucket (auto-enabled by workflow)
- Back up state before major changes:
gcloud storage -m cp -r gs://${STATE_BUCKET}/state/uc4 gs://backup-bucket/state-uc4-$(date +%Y%m%d)
- Clean up old state after successful destroys
- Destroy environments when not in use (demo/test scenarios)
- Use minimal instance sizes for non-production:
- GKE nodes:
e2-standard-2if the workload fits - Drop to a single node for capability demos
- GKE nodes:
- Monitor costs using GCP Billing Reports
- Set billing alerts to avoid unexpected charges
- Use descriptive commit messages when triggering deployments
- Monitor GitHub Actions logs during deployment
- Review Terraform plans before approving apply steps
- Document customizations in repository README or wiki
- Test destroy workflow in non-production before using in production
- project_prefix: Lowercase, alphanumeric, max 10 characters
- resource_owner: 2-4 character initials or identifier
- Branch names: Follow existing pattern (
deploy-,test-,destroy-) - xc_namespace: Unique name, cannot be
systemorshared(enforced by Terraform validation)
Estimated monthly costs for us-west1 region (as of 2026, defaults from config/uc4/gcp/env.example.json):
| Component | Instance Type | Hours/Month | Est. Cost/Month |
|---|---|---|---|
| GKE Standard cluster management | - | 730 | ~$73 (one free cluster per billing account) |
| Node pool (2× e2-standard-4) | e2-standard-4 | 730 | ~$200 |
| Node boot disks (2× 50 GB pd-balanced) | pd-balanced | 730 | ~$10 |
| Cloud NAT | - | 730 | ~$33 |
| External IPs (NAT + NGF data plane LB) | Standard | 730 | ~$15 |
| Network Egress | Variable | - | ~$10 |
| Total (GCP) | ~$340/month | ||
| F5 NGINX Plus | - | - | Contact F5 Sales |
| F5 Distributed Cloud | - | - | Contact F5 Sales |
Cost Reduction Options:
- Destroy infrastructure when not in use (demo environments)
- Reduce
node_countto 1 for a single-node demo (loses any HA story; fine for capability demos) - Reduce
node_disk_size_gbto 30 - Switch
node_machine_typetoe2-standard-2if the workload fits
Note: F5 pricing varies based on subscription level and contract terms. Contact F5 for detailed pricing.
- F5 NGINX Gateway Fabric
- NGF Install with NGINX Plus
- Kubernetes Gateway API
- F5 Distributed Cloud Documentation
- GKE Standard Overview
- Workload Identity Federation
- VPC Networking
- Private Cluster + Authorized Networks
This repository is maintained as a demonstration environment. For issues:
- Check Troubleshooting section above
- Review GitHub Actions logs for detailed error messages
- Verify configuration files match documented formats
- Search existing issues in repository
For questions about F5 products, consult official F5 documentation or contact F5 support.
This project uses F5 NGINX Plus (subscription-based) and F5 Distributed Cloud services (separate billing). Review F5 licensing terms before deployment.
Terraform modules and configuration are provided as-is for demonstration purposes.