Documentation

Google Cloud

Status: reference template, not yet verified by the maintainers. Community-tested: not yet. If you deploy this, please open an issue or a pull request with what worked and what did not. See CONTRIBUTING.md and the index.

Two ways to run the services, same database and secrets:

The commands and YAML below are templates: placeholders such as <project-id>, <region> and <your-db-host> must be filled in, and nothing here was run against a real Google Cloud project.

Architecture

flowchart LR
  apps[Your applications] -->|HTTPS 443| lb[External Application Load Balancer<br/>Google-managed certificate]
  admins[Admins via VPN or IAP] -->|HTTPS| ilb[Internal load balancer or IAP]
  subgraph vpc[VPC]
    lb -->|HTTP 8080, /readyz| gw[gateway pods or Cloud Run service<br/>guard-spec-gateway-full]
    ilb -->|HTTP 8081, /health| cp[control-plane pod or Cloud Run service]
    gw -->|private IP, TLS| sql[(Cloud SQL for PostgreSQL 17)]
    cp -->|private IP, TLS| sql
  end
  gw -->|HTTPS via Cloud NAT| llm[Upstream LLM API]
  sm[Secret Manager] -. injected at start .-> gw
  sm -. injected at start .-> cp
  gw -. stdout JSON logs .-> logging[Cloud Logging]
  cp -. stdout JSON logs .-> logging
Need Google Cloud service
Compute GKE Autopilot, or Cloud Run
Database Cloud SQL for PostgreSQL 17, private IP only, SSL required
Secrets Secret Manager
TLS and routing External Application Load Balancer with a Google-managed certificate (GKE Ingress, or a serverless NEG for Cloud Run)
Logs Cloud Logging (collected from stdout on both platforms)
Metrics and traces Prometheus endpoints on 9090/9091 (Managed Service for Prometheus on GKE); traces over OTLP/HTTP to an OpenTelemetry collector
Images ghcr.io directly (GKE), or a copy in Artifact Registry (needed for Cloud Run)

Prerequisites

Sizing

The README has no server benchmarks. Measure under your traffic. Starting points:

Service Size Basis
gateway (L1 + L2 + L3) 1 vCPU, 2 GiB, 2 replicas or minimum instances About 900 MiB of memory right after start in a local check of gateway-full:0.1.0; the chart's default is a 1Gi request and a 2Gi limit
gateway (L1 only) 0.5 vCPU, 1 GiB Measure
control plane 0.25 to 1 vCPU, 512 MiB, 1 replica Chart defaults: 100m CPU, 64Mi request, 256Mi limit; Cloud Run has its own minimums
Cloud SQL Enterprise edition, db-custom-1-3840, 20 GB SSD Measure: the audit table grows with every request, bounded by AUDIT_RETENTION_DAYS

Step 1: database

# Template.
gcloud sql instances create guard-spec \
  --database-version=POSTGRES_17 --edition=ENTERPRISE \
  --tier=db-custom-1-3840 --region=<region> \
  --network=projects/<project-id>/global/networks/<vpc> --no-assign-ip \
  --ssl-mode=ENCRYPTED_ONLY \
  --backup-start-time=03:00 --enable-point-in-time-recovery \
  --deletion-protection

gcloud sql databases create guardspec --instance=guard-spec
gcloud sql users create gsowner --instance=guard-spec --password='<owner-password>'

Migrations and the application password

From the VM inside the VPC, in the checkout of the release tag:

DB_HOST=<your-db-host>
export PGPASSWORD="$(gcloud secrets versions access latest --secret=guard-spec-owner-password)"

docker run --rm -e PGPASSWORD -v "$PWD/migrations:/migrations:ro" migrate/migrate:v4.20.1 \
  -path=/migrations -database "postgres://gsowner@$DB_HOST:5432/guardspec?sslmode=require" up

After creating the secrets (step 2), apply the application password:

export GUARDSPEC_APP_PASSWORD="$(gcloud secrets versions access latest --secret=guard-spec-app-password)"

docker run --rm -e PGPASSWORD -e GUARDSPEC_APP_PASSWORD \
  -e PGHOST="$DB_HOST" -e PGUSER=gsowner -e PGDATABASE=guardspec -e PGSSLMODE=require \
  -v "$PWD/deploy/postgres/set-app-password.sh:/set-app-password.sh:ro" \
  --entrypoint /bin/sh postgres:17.11-alpine /set-app-password.sh
# guardspec_app password set

Run both again after every upgrade and every rotation of the app password.

Step 2: secrets

openssl rand -hex 32    | tr -d '\n' | gcloud secrets create guard-spec-app-password   --data-file=-
openssl rand -hex 32    | tr -d '\n' | gcloud secrets create guard-spec-jwt-secret     --data-file=-
openssl rand -base64 32 | tr -d '\n' | gcloud secrets create guard-spec-mfa-enc-key    --data-file=-
openssl rand -hex 16    | tr -d '\n' | gcloud secrets create guard-spec-admin-password --data-file=-
printf '%s' '<your-upstream-key>'    | gcloud secrets create guard-spec-upstream-llm-key --data-file=-

MFA_ENC_KEY is not recoverable. It encrypts every user's TOTP secret. Keep a copy outside this project (an offline vault), and keep it with your Cloud SQL backups: a backup restored without the matching key locks out every user with 2FA. Never destroy old versions of guard-spec-mfa-enc-key; the control plane has no re-encryption step.

Give each runtime service account roles/secretmanager.secretAccessor on the secrets it needs only (not on the project).

Option A: GKE Autopilot

Create a private Autopilot cluster in the same VPC, then follow Kubernetes in the on-premise guide. The Google Cloud specific parts:

Option B: Cloud Run

Images

Cloud Run deploys from Artifact Registry (and Docker Hub), not from ghcr.io. Copy the images, after verifying their signatures (SECURITY.md), or create an Artifact Registry remote repository that proxies ghcr.io:

gcloud artifacts repositories create guard-spec --repository-format=docker --location=<region>
gcloud auth configure-docker <region>-docker.pkg.dev
crane copy ghcr.io/guard-spec/guard-spec-gateway-full:0.1.1 \
  <region>-docker.pkg.dev/<project-id>/guard-spec/guard-spec-gateway-full:0.1.1
crane copy ghcr.io/guard-spec/guard-spec-control-plane:0.1.1 \
  <region>-docker.pkg.dev/<project-id>/guard-spec/guard-spec-control-plane:0.1.1

Models

Use guard-spec-gateway-full: the models are in the image, every instance starts with L1 + L2 + L3, and DETECTOR_REQUIRE_ML=true stops an instance that cannot load them instead of running on L1 alone.

Things specific to Cloud Run

Gateway service

# gateway.yaml (template): gcloud run services replace gateway.yaml --region=<region>
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: guard-spec-gateway
  annotations:
    run.googleapis.com/ingress: internal-and-cloud-load-balancing
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "1"
        autoscaling.knative.dev/maxScale: "10"
        run.googleapis.com/cpu-throttling: "false"
        run.googleapis.com/execution-environment: gen2
        run.googleapis.com/network-interfaces: '[{"network":"<vpc>","subnetwork":"<subnet>"}]'
        run.googleapis.com/vpc-access-egress: private-ranges-only
    spec:
      serviceAccountName: guard-spec-gateway@<project-id>.iam.gserviceaccount.com
      timeoutSeconds: 300
      containers:
        - image: <region>-docker.pkg.dev/<project-id>/guard-spec/guard-spec-gateway-full:0.1.1
          ports:
            - containerPort: 8080
          resources:
            limits: {cpu: "1", memory: 2Gi}
          env:
            - name: DATABASE_URL
              value: postgres://guardspec_app@<your-db-host>:5432/guardspec?sslmode=require
            - name: UPSTREAM_LLM_URL
              value: <your-upstream-url>
            - name: UPSTREAM_ALLOW_PRIVATE
              value: "false"
            - name: PGPASSWORD
              valueFrom:
                secretKeyRef: {name: guard-spec-app-password, key: "1"}
            - name: UPSTREAM_LLM_KEY
              valueFrom:
                secretKeyRef: {name: guard-spec-upstream-llm-key, key: "1"}
          startupProbe:
            httpGet: {path: /readyz, port: 8080}
            periodSeconds: 5
            failureThreshold: 24
          livenessProbe:
            httpGet: {path: /health, port: 8080}
            periodSeconds: 10
gcloud run services replace gateway.yaml --region=<region>
gcloud run services add-iam-policy-binding guard-spec-gateway --region=<region> \
  --member=allUsers --role=roles/run.invoker

key: "1" pins the secret version; bump it on rotation instead of using latest, so that a rotation is a deliberate redeploy.

Control plane service

Same shape, with ingress: internal so that only the VPC (and your VPN) reaches it:

# control-plane.yaml (template)
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: guard-spec-control-plane
  annotations:
    run.googleapis.com/ingress: internal
spec:
  template:
    metadata:
      annotations:
        autoscaling.knative.dev/minScale: "1"
        run.googleapis.com/cpu-throttling: "false"
        run.googleapis.com/network-interfaces: '[{"network":"<vpc>","subnetwork":"<subnet>"}]'
        run.googleapis.com/vpc-access-egress: private-ranges-only
    spec:
      serviceAccountName: guard-spec-control-plane@<project-id>.iam.gserviceaccount.com
      containers:
        - image: <region>-docker.pkg.dev/<project-id>/guard-spec/guard-spec-control-plane:0.1.1
          ports:
            - containerPort: 8081
          resources:
            limits: {cpu: "1", memory: 512Mi}
          env:
            - {name: DATABASE_URL, value: "postgres://guardspec_app@<your-db-host>:5432/guardspec?sslmode=require"}
            - {name: ADMIN_EMAIL, value: <admin-email>}
            - {name: PUBLIC_BASE_URL, value: "https://<admin-host>"}
            - {name: AUDIT_RETENTION_DAYS, value: "90"}
            - name: PGPASSWORD
              valueFrom: {secretKeyRef: {name: guard-spec-app-password, key: "1"}}
            - name: JWT_SECRET
              valueFrom: {secretKeyRef: {name: guard-spec-jwt-secret, key: "1"}}
            - name: MFA_ENC_KEY
              valueFrom: {secretKeyRef: {name: guard-spec-mfa-enc-key, key: "1"}}
            - name: ADMIN_PASSWORD
              valueFrom: {secretKeyRef: {name: guard-spec-admin-password, key: "1"}}
          startupProbe:
            httpGet: {path: /health, port: 8081}
          livenessProbe:
            httpGet: {path: /health, port: 8081}
            periodSeconds: 10

Admin access goes through an internal Application Load Balancer with a serverless NEG, reached over your VPN, or an external one protected by Identity-Aware Proxy. Set TRUSTED_PROXIES to the load balancer's proxy-only subnet if you put one in front; leave it empty otherwise.

Load balancer for the gateway

A global external Application Load Balancer with a serverless NEG pointing at guard-spec-gateway and a Google-managed certificate for <gateway-host>. With a serverless NEG the backend service timeout does not apply; the Cloud Run timeoutSeconds does.

gcloud compute network-endpoint-groups create guard-spec-gateway-neg \
  --region=<region> --network-endpoint-type=serverless --cloud-run-service=guard-spec-gateway
gcloud compute backend-services create guard-spec-gateway \
  --global --load-balancing-scheme=EXTERNAL_MANAGED
gcloud compute backend-services add-backend guard-spec-gateway --global \
  --network-endpoint-group=guard-spec-gateway-neg --network-endpoint-group-region=<region>
gcloud compute url-maps create guard-spec-gateway --default-service=guard-spec-gateway
gcloud compute ssl-certificates create guard-spec-gateway --domains=<gateway-host> --global
gcloud compute target-https-proxies create guard-spec-gateway \
  --url-map=guard-spec-gateway --ssl-certificates=guard-spec-gateway
gcloud compute forwarding-rules create guard-spec-gateway --global \
  --load-balancing-scheme=EXTERNAL_MANAGED --target-https-proxy=guard-spec-gateway --ports=443

Check

curl -s https://<gateway-host>/readyz | jq .detector
# {"layers":["l1","l2","l3"],"degraded":false,"onnx":true,...}

Then log in to https://<admin-host>/ui/ as <admin-email> with the guard-spec-admin-password secret, turn on 2FA, and create the first API key as in the README's Quick start.

Observability

Hardening checklist

No extra cost:

Costs money but worth considering: Cloud Armor in front of the gateway (its rate limits are per instance and in memory), a regional (HA) Cloud SQL instance.

Upgrade and rollback

  1. Read the CHANGELOG. Before 1.0, minor versions can change the schema.
  2. Take an on-demand Cloud SQL backup.
  3. On the VM, check out the new tag and run the migrations and the app password again.
  4. Cloud Run: change the image tag and gcloud run services replace each service; a new revision gets traffic once its startup probe passes. GKE: follow the Kubernetes upgrade steps.
  5. Check /readyz and the logs.

Rolling back the images: route traffic to the previous Cloud Run revision (gcloud run services update-traffic guard-spec-gateway --to-revisions=<revision>=100) or helm rollback. That does not undo migrations: if the new version changed the schema, restore the backup (with the MFA_ENC_KEY that matches it).

Troubleshooting

Symptom Likely cause
Revision fails to start, log names a variable A secret is missing, a default or too short
Permission denied on secret The service account lacks secretAccessor on that secret
db connect and exit No VPC egress to the private IP, sslmode missing (ENCRYPTED_ONLY), or guardspec_app has no password yet
Migration fails on CREATE ROLE guardspec_app The role exists on this instance from another database; use a separate instance
Cloud Run returns 403 before reaching the gateway IAM invoker check is on; allow unauthenticated invocations (the gateway checks API keys itself). If an organization policy forbids allUsers, turn off the invoker check on the service instead
Image cannot be deployed to Cloud Run The image is still on ghcr.io; copy it to Artifact Registry
Gateway instance stops with a model error Not enough memory, or gateway-full with a volume over /models
/readyz shows "layers":["l1"] Plain gateway image without models
Streams stop after 30 seconds on GKE The BackendConfig is not attached; check the Service annotation
Logins all come from one address in the activity log TRUSTED_PROXIES not set to the load balancer's range

Source: docs/deploy/gcp.md @ ca69dad29f02