# Deploy OpenWork EE on Google Cloud with GKE and Helm Status: self-host operator guide Related: `packaging/helm/openwork-ee`, `packaging/helm/openwork-ee/examples/values.gcp-ingress.yaml` This is the recommended Google Cloud path for a first production-like OpenWork EE self-host install. Use Helm on GKE Autopilot with Cloud SQL for MySQL. For web/API exposure, use GKE Ingress with Google-managed certificates, a reserved global IP address, and explicit backend health checks. Google recommends Gateway API for new L7 traffic management, and GKE Ingress is in maintenance mode. The current OpenWork chart emits Ingress resources, so GKE Ingress is the simplest supported GCP path today. Treat Gateway API support as a future chart/platform hardening item. Do not use raw Kubernetes `LoadBalancer` Services as the normal GCP path for OpenWork. GKE `LoadBalancer` Services are useful for TCP services and quick smoke tests, but the customer-facing web app and SSO flow need HTTP(S) load balancing, host routing, managed certificates, and backend health checks. ## What this deploys - Den API on port `8788` - Den Web on port `3005` - optional inference service, disabled by default - one Cloud SQL for MySQL database - one single-org OpenWork deployment - one external GKE Ingress backed by a Google Cloud Application Load Balancer - one Google-managed certificate covering web and API hosts Google Cloud owns the GKE cluster, Autopilot compute lifecycle, VPC networking, Cloud Load Balancing, managed certificates, Cloud SQL, IAM, and firewall rules. The OpenWork Helm chart owns OpenWork Deployments, Services, ConfigMaps, Secrets, health probes, the optional Ingress, and the database migration Job. The `BackendConfig` and `ManagedCertificate` resources in this guide are GKE-specific platform resources applied alongside the chart. ## Use Helm or something else? Use Helm on GKE for Google Cloud unless the customer explicitly cannot run Kubernetes. The OpenWork EE release artifact is already a Helm chart, and GKE Autopilot keeps the first customer path small while still supporting migration Jobs, separate web/API services, SSO-ready HTTPS, and later enterprise network controls. The practical gap to fill is GCP-specific ingress and database guidance, not a different OpenWork packaging format. ## Prerequisites - Google Cloud CLI authenticated to the target project. - `kubectl`, `helm`, and `gke-gcloud-auth-plugin`. - Permission to create GKE, Compute Engine networking and global addresses, Cloud SQL, Service Networking, DNS, IAM, and Kubernetes resources. - Enabled APIs: Kubernetes Engine API, Compute Engine API, Cloud SQL Admin API, and Service Networking API. - A real admin email address for the first owner account. - A domain you control, such as `openwork.example.com` and `api.openwork.example.com`. Google Cloud docs used for this guide: - GKE Autopilot clusters: https://cloud.google.com/kubernetes-engine/docs/how-to/creating-an-autopilot-cluster - GKE Ingress for Application Load Balancers: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress - GKE external Ingress and NEGs: https://cloud.google.com/kubernetes-engine/docs/how-to/container-native-load-balancing - GKE managed certificates: https://cloud.google.com/kubernetes-engine/docs/how-to/managed-certs - GKE Ingress configuration and BackendConfig: https://cloud.google.com/kubernetes-engine/docs/how-to/ingress-configuration - GKE Ingress health checks: https://cloud.google.com/kubernetes-engine/docs/troubleshooting/ingress-health-checks - Cloud SQL private IP: https://cloud.google.com/sql/docs/mysql/private-ip - Cloud SQL from GKE: https://cloud.google.com/sql/docs/mysql/connect-kubernetes-engine ## 1. Create the GKE cluster For a first deployment, create a regional Autopilot cluster: ```bash export GCP_PROJECT=REPLACE_PROJECT_ID export GCP_REGION=us-central1 export GKE_CLUSTER=openwork-ee gcloud config set project "$GCP_PROJECT" gcloud services enable \ container.googleapis.com \ compute.googleapis.com \ sqladmin.googleapis.com \ servicenetworking.googleapis.com gcloud container clusters create-auto "$GKE_CLUSTER" \ --location "$GCP_REGION" \ --project "$GCP_PROJECT" # If the cluster command reports that the auth plugin is missing: gcloud components install gke-gcloud-auth-plugin --quiet gcloud container clusters get-credentials "$GKE_CLUSTER" \ --location "$GCP_REGION" \ --project "$GCP_PROJECT" kubectl get nodes ``` GKE enables HTTP load balancing by default. Do not disable it; GKE Ingress needs that add-on. Some standalone Google Cloud SDK installations do not include `gke-gcloud-auth-plugin`. If `kubectl` cannot authenticate after cluster creation, install the component as shown above, rerun `get-credentials`, and verify with `kubectl get nodes` before debugging Kubernetes resources. ## 2. Create Cloud SQL for MySQL Create Cloud SQL for MySQL with private IP in the same VPC as the GKE cluster. The most important requirements are: - MySQL 8-compatible Cloud SQL instance. - Database name: `openwork_den`. - Private services access configured for the VPC. - Private IP enabled on the Cloud SQL instance. - GKE is VPC-native and can reach the private IP. - Backups enabled. - Public IP disabled for production unless there is a documented exception. Private IP requires a one-time private services access connection for the VPC: ```bash export VPC_NETWORK=default export SQL_RANGE=openwork-sql-range gcloud compute addresses create "$SQL_RANGE" \ --global \ --purpose=VPC_PEERING \ --prefix-length=16 \ --network="$VPC_NETWORK" gcloud services vpc-peerings connect \ --service=servicenetworking.googleapis.com \ --ranges="$SQL_RANGE" \ --network="$VPC_NETWORK" ``` Create the instance and database: ```bash export SQL_INSTANCE=openwork-ee-mysql gcloud sql instances create "$SQL_INSTANCE" \ --database-version=MYSQL_8_0 \ --region="$GCP_REGION" \ --network="projects/$GCP_PROJECT/global/networks/$VPC_NETWORK" \ --no-assign-ip gcloud sql databases create openwork_den \ --instance="$SQL_INSTANCE" gcloud sql users create openwork \ --instance="$SQL_INSTANCE" \ --password=REPLACE_DB_PASSWORD ``` Get the private IP: ```bash gcloud sql instances describe "$SQL_INSTANCE" \ --format='value(ipAddresses[0].ipAddress)' ``` Example database URL: ```text mysql://openwork:@:3306/openwork_den ``` This guide uses direct private IP because the current OpenWork chart does not inject Cloud SQL Auth Proxy sidecars. Cloud SQL Auth Proxy is a stronger future hardening path when the chart supports sidecars or an operator-managed proxy pattern. If the Cloud SQL instance enforces encrypted client connections, use `?sslaccept=accept` for the simple private-MySQL smoke path. This keeps TLS on without requiring a cloud CA bundle to be mounted into the OpenWork image. Use strict certificate verification later, after you provide the required CA bundle, with a hardened value such as `sslmode=verify-ca` or `sslmode=verify-full`. Verify the same URL works for both the migration Job and runtime pods before testing the browser flow. Before installing OpenWork, verify network access from the cluster: ```bash kubectl run mysql-client \ --rm \ -it \ --restart=Never \ --image=mysql:8 \ -- mysql \ --host="REPLACE_CLOUD_SQL_PRIVATE_IP" \ --user=openwork \ --password \ --execute "select 1" ``` ## 3. Reserve a global IP and create GKE resources Reserve a global IP address for the HTTPS load balancer: ```bash gcloud compute addresses create openwork-ee-ip \ --global gcloud compute addresses describe openwork-ee-ip \ --global \ --format='value(address)' ``` Create the namespace: ```bash kubectl create namespace openwork-ee ``` Create a Google-managed certificate resource: ```bash kubectl apply -n openwork-ee -f - <<'YAML' apiVersion: networking.gke.io/v1 kind: ManagedCertificate metadata: name: openwork-ee-cert spec: domains: - REPLACE_WEB_HOST - REPLACE_API_HOST YAML ``` Create explicit backend health checks for the two OpenWork services: ```bash kubectl apply -n openwork-ee -f - <<'YAML' apiVersion: cloud.google.com/v1 kind: BackendConfig metadata: name: openwork-ee-den-api-backend spec: healthCheck: type: HTTP requestPath: /ready port: 8788 checkIntervalSec: 15 timeoutSec: 5 --- apiVersion: cloud.google.com/v1 kind: BackendConfig metadata: name: openwork-ee-den-web-backend spec: healthCheck: type: HTTP requestPath: /api/ready port: 3005 checkIntervalSec: 15 timeoutSec: 5 YAML ``` The Helm values annotate the OpenWork Services so GKE associates these `BackendConfig` objects with the Google Cloud backend services. ## 4. Prepare Helm values Copy the starter file: ```bash cp packaging/helm/openwork-ee/examples/values.gcp-ingress.yaml values.gcp.yaml ``` Replace every `REPLACE_*` placeholder. Generate secrets: ```bash openssl rand -base64 48 openssl rand -base64 48 ``` Use the first value for `secret.values.betterAuthSecret` and the second for `secret.values.denDbEncryptionKey`. Do not reuse either value across environments. To send transactional email, configure SMTP in the same values file: ```yaml secret: values: emailFrom: "OpenWork " smtpHost: "smtp.example.com" smtpPort: "587" smtpUser: "openwork@example.com" smtpPass: "REPLACE_SMTP_PASSWORD" smtpSecure: "false" ``` These values become `EMAIL_FROM`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, and `SMTP_SECURE` in Den API. If you use `secret.create=false`, add those keys to the existing Kubernetes Secret referenced by `secret.existingSecret`. SMTP delivery requires both `EMAIL_FROM` and `SMTP_HOST`; leave `smtpHost` blank only when SMTP-backed transactional email should be disabled. Use a values file, not a long list of `--set` flags. Several OpenWork values are comma-separated strings, such as `config.public.corsOrigins`, and plain `--set` parsing commonly breaks them. Make sure your file uses the current chart keys. Public URL values belong under `config.public.*`; database and app secrets belong under `secret.values.*`. Values such as `config.urls`, `config.databaseUrl`, or `secrets.*` are ignored by the chart. Before installing, render the chart and verify the migration Job will use your Cloud SQL URL: ```bash helm template openwork-ee oci://ghcr.io/different-ai/charts/openwork-ee \ --version REPLACE_OPENWORK_VERSION \ --namespace openwork-ee \ -f values.gcp.yaml > /tmp/openwork-rendered.yaml grep -E 'DATABASE_URL|DEN_BASE_URL|DEN_WEB_PUBLIC_ORIGIN|EMAIL_FROM|SMTP_HOST|SMTP_PORT|SMTP_SECURE' /tmp/openwork-rendered.yaml ``` Redact secrets before sharing rendered manifests or terminal output. ## 5. Install OpenWork Published chart releases live in GHCR: ```bash helm upgrade --install openwork-ee oci://ghcr.io/different-ai/charts/openwork-ee \ --version REPLACE_OPENWORK_VERSION \ --namespace openwork-ee \ --create-namespace \ -f values.gcp.yaml ``` For a checkout-local test: ```bash helm upgrade --install openwork-ee ./packaging/helm/openwork-ee \ --namespace openwork-ee \ --create-namespace \ -f values.gcp.yaml ``` If GHCR image pulls fail with `ImagePullBackOff`, authenticate to GHCR and add an `imagePullSecrets` entry. Public releases should not require this, but private packages or private forks do: ```bash kubectl create secret docker-registry ghcr-pull-secret \ --namespace openwork-ee \ --docker-server=ghcr.io \ --docker-username="$GITHUB_USER" \ --docker-password="$GITHUB_TOKEN" ``` ```yaml imagePullSecrets: - name: ghcr-pull-secret ``` ## 6. Migration troubleshooting The migration Job runs before the Deployments are useful. If it fails, fix that before debugging web/API readiness. Avoid `kubectl describe job openwork-ee-migrate` in shared reports because the hook Job currently includes `DATABASE_URL` and `DEN_DB_ENCRYPTION_KEY` in the rendered environment. Use logs and redacted rendered manifests instead. For a retained-log debug attempt, temporarily disable hook behavior: ```yaml migrations: enabled: true hook: false backoffLimit: 0 ``` Then run Helm and inspect the normal Job logs: ```bash helm upgrade --install openwork-ee oci://ghcr.io/different-ai/charts/openwork-ee \ --version REPLACE_OPENWORK_VERSION \ --namespace openwork-ee \ --create-namespace \ -f values.gcp.yaml \ --wait=false kubectl get jobs,pods -n openwork-ee kubectl logs -n openwork-ee -l job-name=openwork-ee-migrate --all-containers=true ``` Return to the default hook mode after debugging: ```yaml migrations: enabled: true hook: true backoffLimit: 2 ``` ## 7. Point DNS at the global load balancer IP Get the reserved IP address: ```bash gcloud compute addresses describe openwork-ee-ip \ --global \ --format='value(address)' ``` Create DNS records: - `openwork.example.com` -> the reserved global IP address. - `api.openwork.example.com` -> the reserved global IP address. GKE can take several minutes to provision the load balancer. Google-managed certificates can take up to an hour to become active after DNS points at the load balancer. Check status: ```bash kubectl get ingress -n openwork-ee kubectl describe managedcertificate openwork-ee-cert -n openwork-ee kubectl describe ingress openwork-ee -n openwork-ee ``` If you are still using temporary hosts before DNS/TLS is ready, temporarily update the corresponding `config.public.*` origins in `values.gcp.yaml`, then run `helm upgrade` again. Do not leave production deployments on raw IPs or placeholder hostnames. The current chart rolls the Den API, Den Web, and inference pods automatically when ConfigMap or Secret content changes. On older chart versions, manually restart the deployments after changing public origin values: ```bash kubectl rollout restart deployment/openwork-ee-den-api deployment/openwork-ee-den-web -n openwork-ee kubectl rollout status deployment/openwork-ee-den-api -n openwork-ee --timeout=180s kubectl rollout status deployment/openwork-ee-den-web -n openwork-ee --timeout=180s ``` ## 8. Verify readiness Check Kubernetes state: ```bash helm status openwork-ee -n openwork-ee kubectl get pods -n openwork-ee kubectl get jobs -n openwork-ee kubectl get ingress -n openwork-ee kubectl describe backendconfig openwork-ee-den-api-backend -n openwork-ee kubectl describe backendconfig openwork-ee-den-web-backend -n openwork-ee kubectl logs -n openwork-ee deploy/openwork-ee-den-api kubectl logs -n openwork-ee deploy/openwork-ee-den-web ``` Check readiness from your machine: ```bash curl -fsS https://api.openwork.example.com/ready curl -fsS https://openwork.example.com/api/ready ``` ## 9. Bootstrap the first owner The chart defaults to `single_org`. Set these before first sign-in: ```yaml config: tenancy: mode: "single_org" singleOrgName: "Acme" singleOrgSlug: "acme" ownerEmails: "admin@acme.com" requireEmailVerification: "false" public: bootstrapAdminEmails: "admin@acme.com" secret: values: initialAdminBootstrapCode: "REPLACE_BOOTSTRAP_CODE" ``` For releases that include initial-administrator bootstrap, inject the release-documented one-time setup secret through the Kubernetes Secret referenced by `secret.existingSecret`. Do not store the code in the values file or a ConfigMap. Then open `https://openwork.example.com/setup`, enter the configured owner email and one-time operator code, and create the first account. OpenWork creates the singleton organization, grants owner and configured platform-admin access, and signs the administrator in. Public signup remains disabled. After the first user exists, the setup code cannot bootstrap another account. `ownerEmails` and `bootstrapAdminEmails` authorize roles; neither setting creates an account or password. There is no default administrator password. Chart versions without `/setup` do not support private initial-administrator bootstrap and must be upgraded before following this step. ## 10. Configure SSO with a test IdP Self-hosted installs keep plan gating off unless the operator explicitly sets `DEN_PLAN_GATING_ENABLED=true`, so SSO management should be available in the EE self-host default. Use OIDC first for a smoke test because most IdPs provide discovery metadata. Auth0, Okta trial, and Google Cloud Identity test tenants all work as realistic demo IdPs. Configure the IdP application with this callback URL: ```text https://openwork.example.com/api/auth/sso/callback/openwork-sso- ``` In OpenWork, sign in as the owner, open the organization SSO settings, and enter the IdP issuer/client details. After saving, the organization sign-in path is: ```text https://openwork.example.com/sso/ ``` For SAML, OpenWork shows the generated ACS URL and metadata URL after the SAML connection is registered. Use those values in the IdP rather than guessing. OpenWork rejects unsigned or weak SAML responses, so configure the IdP to sign assertions. After SSO is configured, root sign-in shows the SSO-only experience for the single organization. Password sign-in for that organization is rejected. ## 11. Troubleshooting | Symptom | Likely cause | Fix | |---|---|---| | Ingress does not reconcile | HTTP load balancing add-on is disabled or Ingress annotation is wrong | Keep HTTP load balancing enabled and use `kubernetes.io/ingress.class: gce` | | Backends are unhealthy | GKE load balancer health checks do not match OpenWork readiness endpoints | Apply the `BackendConfig` resources and keep the service annotations from the starter values | | Ingress events report `TimeoutSec should be less than checkIntervalSec` | The backend health-check timeout is greater than or equal to its effective interval | Set `checkIntervalSec: 15` and `timeoutSec: 5` on both `BackendConfig` resources | | Managed certificate is not `Active` | DNS does not point at the load balancer or provisioning is still running | Point both hosts at the reserved global IP and wait; check `kubectl describe managedcertificate` | | Migration Job fails to connect to MySQL | Private services access, VPC, credentials, IP, or TLS mode are wrong | Test from `mysql-client`, confirm the private IP, and confirm GKE and Cloud SQL share VPC reachability | | Migration Job logs show `self-signed certificate in certificate chain` | Strict certificate verification is being used without the cloud MySQL CA bundle | Use `?sslaccept=accept` for the smoke path or mount/configure the CA bundle before strict verification | | `ImagePullBackOff` from GHCR | Private image or missing pull token | Add `imagePullSecrets` | | Browser auth loops or CORS errors | Public origins do not match DNS/TLS | Set `webOrigin`, `apiOrigin`, `corsOrigins`, `betterAuthTrustedOrigins`, and `authCallbackUrl` to the final HTTPS domains | | SSO callback rejected | IdP callback URL does not match OpenWork | Use the callback/ACS URL shown by OpenWork for that org/provider | | SSO settings show Enterprise gating | `DEN_PLAN_GATING_ENABLED=true` or org is not entitled | Leave plan gating off for self-host smoke tests, or grant enterprise entitlement | ## 12. Cleanup For a disposable test: ```bash helm uninstall openwork-ee -n openwork-ee gcloud compute addresses delete openwork-ee-ip --global gcloud container clusters delete "$GKE_CLUSTER" --location "$GCP_REGION" gcloud sql instances delete "$SQL_INSTANCE" ``` Delete DNS records, retained backups, private service access ranges, and any IAM resources if they were only for the test.