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 needscontainer.clusters.get; provisioning needscontainer.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.tfvarsRequired: project_id, cluster_name, location, plus the standard credentials/ingress_host/database_url.
Apply
terraform init
terraform applyThe providers authenticate with a short-lived GCP token from your ADC — no kubeconfig file is read or written.
Verify
terraform output next_stepsDatastores (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_domaincorrectly 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_domainset? 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 = trueCreating 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.