Deployment Guide
Octopus runs in production on Google Kubernetes Engine, in GCP project
octopus-prod-maiolabs (europe-west1). It moved there from AWS ECS Fargate on 2026-08-20.
For local development, see the Installation Guide.
How a deploy happens
Push to the staging branch. That is the trunk, despite the name — there is one live environment,
and this deploys it.
The deploy-gcp-prod.yml workflow then:
- authenticates to GCP by Workload Identity Federation — no service-account keys
- builds five images and pushes them to Artifact Registry, tagged with the short commit SHA
- fetches cluster credentials and runs
infra/gcp/scripts/deploy.shwith that tag - waits on the rollout of every Deployment and StatefulSet
To roll out an image that already exists, run the workflow manually and pass image_tag. Nothing
is rebuilt.
What gets deployed
| Component | Kind | Image |
|---|---|---|
octopus | Deployment | .../octopus/app |
portal-backend | Deployment | .../octopus/portal-backend |
portal-frontend | Deployment | .../octopus/portal-frontend |
docs | Deployment | .../octopus/docs |
website | Deployment | .../octopus/website |
falkordb | StatefulSet | mirrored upstream image, pinned |
redis | StatefulSet | mirrored upstream image, pinned |
Registry prefix is europe-west1-docker.pkg.dev/octopus-prod-maiolabs/octopus. CI builds the five
application images; the two datastore images are third-party and pinned in the manifests.
Deploying by hand
./infra/gcp/scripts/deploy.sh <image-tag>
./infra/gcp/scripts/deploy.sh <image-tag> --dry-run
The script pins --context on every kubectl call, applies the namespace first, substitutes the
image tag into the manifests, and refuses a tag that is not present in Artifact Registry — without
that check the pods sit in ImagePullBackOff and it reads as a cluster fault.
Infrastructure
Terraform lives in infra/gcp/terraform/, with environments/prod/ as the only runnable root.
./infra/gcp/scripts/tf.sh plan
./infra/gcp/scripts/tf.sh apply
Always use the wrapper. db_password has no default, so a bare terraform run leaves it unset and
the plan quietly proposes changing the Cloud SQL password — an apply would rotate it and drop every
live connection. The wrapper sources the real value from Secret Manager.
The GCS state backend needs Application Default Credentials. gcloud auth login is a different
credential store and is not enough:
gcloud auth application-default login
Configuration and secrets
Secrets live in Secret Manager as octopus-prod-<NAME>. Pods read them through Workload
Identity — there are no keys or static credentials in the cluster.
Terraform creates the secret containers; values are loaded separately by
infra/gcp/scripts/sync-secrets.sh. No secret value passes through Terraform state.
Networking and TLS
A GKE Ingress provisions a Google Cloud Load Balancer with container-native NEG backends, on the
static IP octopus-prod-ingress-ip. It routes three hostnames:
octopus.maiolabs.ai— the API and websiteportal.octopus.maiolabs.ai— the portaldocumentation.octopus.maiolabs.ai— these docs
Certificates come from Certificate Manager using DNS authorization, which matters: validation happens over a TXT record, so a certificate is issued and active before any traffic points at the load balancer. HTTP-validated certificates cannot do that, and would leave a gap at cutover.
Observability
| Logs | Cloud Logging. resource.type="k8s_container", namespace octopus |
| Errors | Log-based metric octopus-prod-app-errors, labelled by container |
| Alerts | 5xx spike at the load balancer; pod crash loop. Both email |
| Database | Cloud SQL query insights |
Scaling
- Horizontal: raise
replicasin the Deployment. The node pool autoscales 2→4. - Vertical: adjust the container resource requests, then re-apply.
- Database:
db_tierinterraform.tfvars. Currentlydb-g1-small, sized from 30 days of measured load on the AWS source (CPU averaged 3.4%).
Local Docker
docker compose up -d
See the Installation Guide for the environment variables a local run needs.