Skip to Content
Installing MarshalGKE (Google Cloud)

Install onto GKE

Two postures via deploy_cluster: reuse (default) an existing GKE cluster, or provision a new private one in the same apply.

Prerequisites specific to this target

  • Application Default Credentials — gcloud auth application-default login. Reuse needs container.clusters.get; provisioning needs container.admin + compute network admin.
  • A Postgres 16 the cluster can reach over the VPC (Cloud SQL private IP recommended) — or provision it yourself, see below.
  • terraform ≥ 1.5, gcloud, kubectl, helm ≥ 3.14.

Install (reuse an existing cluster)

Configure terraform.tfvars

cp ../examples/gcp-gke.tfvars.example terraform.tfvars $EDITOR terraform.tfvars

Required: project_id, cluster_name, location, plus the standard credentials/ingress_host/database_url.

Apply

terraform init terraform apply

The providers authenticate with a short-lived GCP token from your ADC — no kubeconfig file is read or written.

Verify

terraform output next_steps

Datastores (Postgres external, Redis bundled)

Configured independently — database_mode = "external" (recommended, points at managed Cloud SQL) and redis_mode = "bundled" (default, in-cluster — everything Marshal keeps in Redis is ephemeral/reconstructible, so there’s no durability reason to pay for managed Memorystore).

database_mode = "external" database_url = "postgres://marshal:PW@10.20.0.5:5432/marshal?sslmode=require" redis_mode = "bundled"

Chart ≤ 0.5.0 + bundled Redis: image pull fix required. Bitnami retired every version-pinned tag from the free bitnami/* Docker Hub namespace — the bundled Redis/Postgres subcharts’ default image refs 404 on any chart predating 0.6.0. Fixed upstream from that release on; until then, override it:

values_overrides = [ <<-EOT redis: image: repository: bitnamilegacy/redis EOT ]

Ingress / TLS (internal by default, HTTPS by default)

ingress_host has no default — terraform plan fails without it even before real DNS exists; apply first, point DNS at the LB IP once convenient. internal_ingress = true (default) uses GKE’s internal LB. HTTPS is on by default (tls_mode = "self_signed", an unmistakably-labeled placeholder cert) — swap for "inline" (paste real PEM) or "secret" (point at a Secret you manage, e.g. cert-manager) whenever ready.

Wildcard preview certs — real constraints, read before relying on one.

  • The self-signed placeholder does cover *.preview_domain correctly out of the box.
  • GKE-managed certs do NOT support wildcard domains at all — don’t reach for this option if you need the preview domain covered.
  • cert-manager needs a DNS-01 solver for a wildcard, not the default HTTP-01 — otherwise the cert request stays pending forever with no obvious “wildcard” error.
  • No preview_domain set? None of this applies.

Node pools: control-plane vs. spaces

Two pools, sized independently — primary (session workloads) and control_plane (api/gateway/web/marketplace/redis only, default e2-standard-8 — the chart’s own baseline of 2 replicas each already sums to ~4.4 vCPU of requests, too tight for a smaller machine). Isolation is gated behind taint_control_plane_pool (default false).

Flipping taint_control_plane_pool on an already-running cluster — ordering matters.

Requires chart ≥ 0.9.0 (has the matching nodeSelector/tolerations wiring) already running before you flip the toggle — an older chart silently ignores those values, so control-plane pods would carry no toleration and become instantly unschedulable the moment the taint lands. New installs on chart ≥ 0.9.0 can set it from day one with no ordering risk.

Also watch control_plane_max_node_count = 1: a machine-type change cordons + drains the node, and the chart’s minAvailable: 1 PodDisruptionBudgets will never allow draining the last replica of every component at once — the node sits Ready,SchedulingDisabled forever. Scale to 2 first, or temporarily delete the PDBs for a planned maintenance window.

Provisioning a private cluster instead of BYO

deploy_cluster = true creates, in one apply: private nodes, a locked-down control plane (master_authorized_networks required or you lock yourself out), Cloud Router + Cloud NAT, a dedicated VPC, Workload Identity, and a sized node pool.

deploy_cluster = true cluster_name = "marshal" location = "europe-west1" master_authorized_networks = [{ cidr_block = "10.8.0.0/24", display_name = "admin-vpn" }] database_mode = "external" database_url = "postgres://marshal:PW@10.20.0.5:5432/marshal?sslmode=require"

No existing VPN/bastion? Two-phase apply + a throwaway IAP bastion.

Only some resources need VPC connectivity: the network/cluster/node-pool go through the google provider (reachable from anywhere with a GCP token), but the actual install (namespace/Secrets/helm_release) dials the cluster’s private endpoint directly.

# 1. Network + cluster only (works from anywhere): terraform apply -target=google_compute_network.vpc -target=google_compute_subnetwork.subnet \ -target=google_compute_router.router -target=google_compute_router_nat.nat terraform apply -target=google_container_cluster.this -target=google_container_node_pool.primary # 2. master_authorized_networks' CIDR = the subnet the bastion will live in. # 3. Bastion, no external IP, IAP-tunneled SSH: gcloud compute instances create marshal-bastion --zone=<region>-b --subnet=<subnet> \ --no-address --scopes=cloud-platform --image-family=debian-12 --image-project=debian-cloud gcloud compute firewall-rules create allow-iap-ssh --network=<network> --direction=INGRESS \ --action=ALLOW --rules=tcp:22 --source-ranges=35.235.240.0/20 gcloud compute ssh marshal-bastion --zone=<region>-b --tunnel-through-iap # 4. From the bastion: same GCS backend, scp'd tfvars, terraform apply for everything.

Delete the bastion once done — it’s not part of this Terraform config.

Provisioning Cloud SQL yourself

This repo deliberately does not create the Postgres instance:

# Private Services Access — one-time per VPC. gcloud compute addresses create google-managed-services-<vpc> \ --global --purpose=VPC_PEERING --prefix-length=16 --network=<vpc> gcloud services vpc-peerings connect --service=servicenetworking.googleapis.com \ --ranges=google-managed-services-<vpc> --network=<vpc> # Postgres 16, private IP only. gcloud sql instances create <name> --database-version=POSTGRES_16 --edition=ENTERPRISE \ --tier=db-custom-2-7680 --region=<region> \ --network=projects/<project>/global/networks/<vpc> --no-assign-ip --availability-type=ZONAL gcloud sql databases create marshal --instance=<name> gcloud sql users create marshal --instance=<name> --password=<generated>

Must-set flag: --edition=ENTERPRISE — the default edition (ENTERPRISE_PLUS) rejects db-custom-* tiers outright.

Registry — pull-through cache on by default

create_registry_cache defaults to true — one Terraform toggle creates an Artifact Registry remote repository mirroring registry.marshal.codes and self-wires the install onto it. See Registry options for how this compares to the other targets.

Optional: object storage (audit + spaces persistence)

create_audit_persistence_bucket = true create_spaces_persistence_bucket = true

Creating a bucket doesn’t turn the feature on by itself — an org admin still wires the URL via the API afterward.

Day-2

Bump chart_version and terraform apply. Roll back with helm rollback marshal -n marshal.