Deploy on Google Cloud
The same single-writer, single-data-directory design that decides the AWS and Azure mappings decides the Google Cloud one, with one genuine upgrade neither of those offers: a Regional Persistent Disk stays single-attach while replicating synchronously across two zones, so a zone outage becomes a force-attach instead of a snapshot restore. The other twist runs the other way — Vertex AI's embedding models are not OpenAI-compatible and authenticate with short-lived OAuth2 tokens, so unlike Azure OpenAI this is the platform that needs a bridge, the same seat the Bedrock page's proxy sits in.
The three constraints that decide everything
The general model is on the Docker Compose and Kubernetes pages; this page is only the Google Cloud projection of it.
- One data directory, one writer — and network filesystems are out. On Google Cloud that means Persistent Disk only, never Filestore. Filestore is NFS, the same protocol the AWS page rules out for EFS: a second writer isn't refused cleanly, it's refused (or worse, silently tolerated at the protocol level) through an NFS lock that a partition can hand away. A zonal or regional Persistent Disk enforces single-attach at the platform layer, before the advisory lock ever has to.
- Availability is restore time — and Google Cloud has a real upgrade to it.
Deploys are stop-then-start, there is no failover, and the baseline story is snapshots
plus (optionally) a replica to promote by hand — see
the promotion runbook. A Regional Persistent
Disk changes the baseline itself: it replicates synchronously to a second zone in
the same region while staying a single-attach block device, and on a zone outage
--force-attachmoves it to a VM in the surviving zone with no snapshot restore in the path — RPO effectively zero, not "since the last snapshot." See the failover procedure below. - TLS belongs to the layer in front. A bearer token is the whole credential, so the server must never be reachable over plain HTTP from outside. On Google Cloud that layer is the external Application Load Balancer with a Google-managed certificate; the server behind it speaks HTTP on 8248 to the load balancer alone.
Compute: GCE or GKE, and why not Cloud Run
Anything that cannot keep one block device attached to one long-lived process is out — verified against the platform, not assumed from its marketing copy.
| Service | Fit | Why |
|---|---|---|
| GCE VM + Docker (or the bare binary under systemd) | ✓ the simplest honest fit | One VM, one Persistent Disk, one container. The Docker Compose page applies nearly verbatim. |
| GKE Standard or Autopilot | ✓ with deploy/kubernetes.yaml as-is | Verified on a live Autopilot cluster: the manifest applies unmodified — Autopilot only adds an ephemeral-storage resource default and a seccompProfile/dropped NET_RAW capability, never overrides what the manifest already sets. The unspecified storageClassName resolves to standard-rwo (pd-balanced, RWO) with no setup. |
| Cloud Run | ✗ | Checked against the current volume types (gcloud run deploy --help): cloud-storage (GCS FUSE — object semantics), nfs (Filestore), and in-memory only. No block-device option exists in the product at all — this isn't a configuration to avoid, there's nothing to configure. |
GCE VM
- Keep the data directory on its own Persistent Disk, separate from the boot
disk: snapshots, restores, and VM replacement then never touch the OS. Format, mount,
and create the data directory owned by uid 65532 before the first start — the
image is the binary alone on
scratch, with no shell and nomkdir, so the host does this once:mkfs.ext4 -F /dev/disk/by-id/google-taguru-data mkdir -p /mnt/taguru mount /dev/disk/by-id/google-taguru-data /mnt/taguru mkdir -p /mnt/taguru/data && chown 65532:65532 /mnt/taguru/data echo '/dev/disk/by-id/google-taguru-data /mnt/taguru ext4 discard,defaults,nofail 0 2' >> /etc/fstab docker run --restart unless-stopped(or a systemd unit withRestart=on-failure) is the restart policy — restarts are the host's job, not the load balancer's (see the probe split).- VM size: memory ≈
TAGURU_CACHE_BYTES(default 512 MiB) + pinned contexts + headroom — an e2-small runs the default profile; measure withtaguru estimatebefore scaling up. A shared-core machine type is fine for the server itself, but building anything on the VM (not needed for normal operation — the image ships prebuilt) is a different story: acargo build --releaseon an e2-small (2 vCPU burstable, 2 GiB RAM) was still linking the final binary past the half-hour mark, memory dropping under 100 MiB free at points. Extracting the published image's own binary (docker create+docker cp, matched to the VM's architecture) and copying it over took under a minute and is what any failover rehearsal should do — never compile on the box you're trying to bring back up. - Zone recovery is a force-attach if the disk is a Regional PD. A zonal disk is zone-bound and recovery into another zone is a snapshot restore; a Regional PD detaches from the dead VM (or is force-attached over its objection) and attaches to a standby in its replica zone, data intact — see the failover procedure.
GKE
deploy/kubernetes.yamlapplies unchanged — verified, not assumed. Applied verbatim to a fresh Autopilot cluster: the pod reached1/1 RunningwithreadOnlyRootFilesystem,runAsUser: 65532, andfsGroup: 65532all honored exactly as written, andGET /health/GET /liveboth answered 200 immediately. The only changes Autopilot made were additive: it defaulted anephemeral-storagerequest/limit the manifest doesn't set, and injectedseccompProfile: RuntimeDefaultplus a droppedNET_RAWcapability. Nothing the manifest specifies was overridden or rejected.- Never let the PVC land on the Filestore or GCS FUSE storage classes. A GKE
cluster ships
standard-rwo/premium-rwo(Persistent Disk) alongsideenterprise-multishare-rwxand GCS FUSE-backed classes — picking one of the latter appears to work, one replica comes up and serves, and the failure arrives later wearing the same costume the Azure page documents forazurefile: a second pod against the same network-backed volume surfaces as a permissions or lock error, not an obvious storage misconfiguration. LeavestorageClassNameunset (it resolves to the PD-backed default) or name a PD class explicitly. - Two guards, two layers. On a Persistent Disk, a second pod on another
node is refused by the platform (the volume stays attached where it is), and a second
pod on the same node — which ReadWriteOnce does not prevent — is refused by the
server's own lock with an explicit message (
data directory is held by another taguru process). Both refusals are loud and correct; neither exists on a network-backed share. - Regional PD as a GKE StorageClass: verified live —
A PVC naming this class provisioned and bound cleanly on Autopilot with no other changes to the manifest — only the PVC'sapiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: regional-pd provisioner: pd.csi.storage.gke.io parameters: type: pd-balanced replication-type: regional-pd volumeBindingMode: WaitForFirstConsumerstorageClassNameneeds to change to move a deployment from zonal to regional storage. - Fronting with an external Application Load Balancer: a
BackendConfigpointed atGET /healthplus a standardIngressis enough — see the section below for the verified health-check wiring. - Grace vs. drain:
terminationGracePeriodSeconds: 60in the manifest covers the drain plus the final flush. Keep the load balancer's connection-draining timeout at or below it — a drain window longer than the pod's remaining lifetime just resets whatever is still in flight when the pod dies.
The stateless and read-pool variants (kubernetes-stateless.yaml,
kubernetes-replicas.yaml) work on GKE the same way, with
TAGURU_REPLICATE_URL pointed at a GCS bucket and the pod's credentials
granted via Workload Identity — see the Kubernetes
page and Backups and DR below for the bucket side, verified
end to end.
The image: mirror ghcr.io through Artifact Registry
Keep pulls inside Google Cloud and survive a ghcr.io outage without changing what you run.
- Artifact Registry's remote repository mode mirrors
ghcr.ioon first pull — verified live, a cold pull through a freshly created remote repository resolved and cached the published image with no extra configuration:
The image reference becomesgcloud artifacts repositories create taguru-mirror \ --repository-format=docker --mode=remote-repository \ --remote-docker-repo=https://ghcr.io \ --location=REGIONREGION-docker.pkg.dev/PROJECT/taguru-mirror/t0k0sh1/taguru:VERSION. The cache stores the same manifest, so a digest pin carries over unchanged — pin exactly as the image guidance says. - Pullers authenticate with the node's (or pod's, via Workload Identity) service
account holding
roles/artifactregistry.reader— no registry passwords in the boot script; on GKE, the default node service account already has this once the repository exists in the same project.
Persistent Disk, and the Regional PD upgrade
A zonal disk is the default fit; a Regional PD is the one change that turns a zone outage into a reattach instead of a restore.
- pd-balanced is the default choice for the data directory — pd-ssd if the write volume genuinely needs the extra IOPS, measured, not assumed.
- A Regional PD is created with two replica zones, not a region:
It attaches, formats, and mounts exactly like a zonal disk — nothing about the server's use ofgcloud compute disks create taguru-data \ --region=REGION --replica-zones=ZONE-A,ZONE-B \ --type=pd-balanced --size=10GB/datachanges. The difference only shows up when a zone goes away. - On GKE, the same trade is one StorageClass parameter
(
replication-type: regional-pd, shown in Compute above) — verified to provision and bind without touching the deployment manifest at all. - Regional PD is not a backup. It is one disk with one write history, replicated — a bad write replicates too. Snapshots and the export lane (below) are still the only defense against corruption or accidental deletion; Regional PD only removes zone failure from the list of things a restore is needed for.
Regional PD failover: the procedure
Rehearsed on a live VM pair, not described from the API reference.
- Normal operation: the Regional PD is attached read-write to one VM, in one of
its two replica zones — exactly like a zonal disk from the server's point of view.
POST /flushbefore a planned failover so the surviving zone's copy is current to the second, not the last flush interval. - The zone with the attached VM becomes unavailable (an outage, or a deliberate drain for maintenance). The disk is still attached to the now-unreachable VM as far as the platform's bookkeeping is concerned.
- Force-attach from a VM in the surviving zone:
gcloud compute instances attach-disk taguru-vm-standby \ --zone=ZONE-B --disk=taguru-data --disk-scope=regional \ --force-attach--force-attachsucceeds even though the disk shows as attached elsewhere, and the platform keeps trying to detach it from the dead VM in the background — the standby does not wait on that. - Mount and start the server on the standby exactly as on the primary — same
UID, same mount path. No snapshot restore, no
taguru restore: the block device itself is current as of the last synchronously-replicated write. - Verify with
taguru inspect /databefore serving traffic — the same fully-validating load + WAL replay the server boots with. A non-zero exit means investigate before routing anything at the standby. - Rehearse this before an outage forces it. The mechanics are simple, but the
first time anyone runs
--force-attachshould not be during an actual zone loss — the flag name alone invites hesitation at 3 a.m.
On GKE, the same disk resource is what a regional-pd StorageClass provisions
underneath; the CSI driver performs the equivalent force-attach when the scheduler moves
the pod to a node in the surviving zone after the original node becomes
NotReady — the manual procedure above is what to reach for on a bare VM, or
to understand what the driver is doing on your behalf.
Secrets: Secret Manager feeds the boot
TAGURU_API_TOKENS (and TAGURU_KEY_SCOPES, the embedding key, …) come from Secret Manager at boot; nothing lands in a manifest or a template.
- GCE VM: the attached service account plus
roles/secretmanager.secretAccessoris the whole credential chain — the boot script reads the secret directly, no token exchange to write by hand. Write it to a permission-restricted file rather than-e: a command-line environment variable lands indocker inspectand any other user'spsoutput on the same host, not just shell history.install -m 600 /dev/null /etc/taguru.env echo "TAGURU_API_TOKENS=$(gcloud secrets versions access latest --secret=taguru-api-tokens)" >> /etc/taguru.env echo "TAGURU_LOG_FORMAT=json" >> /etc/taguru.env docker run -d --restart unless-stopped -p 8248:8248 \ --env-file /etc/taguru.env \ -v /mnt/taguru/data:/data REGION-docker.pkg.dev/PROJECT/taguru-mirror/t0k0sh1/taguru:VERSION - GKE: the Secret Manager add-on
(
gcloud container clusters update --enable-secret-manager) mounts a secret directly through aSecretProviderClass— verified live, including one trap that cost real debugging time: the CSI driver name issecrets-store-gke.csi.k8s.io, not the communitysecrets-store.csi.k8s.iomost tutorials reference. Get the driver name wrong and the pod sticks inContainerCreatingwithdriver name … not found in the list of registered CSI drivers— a message that reads like the add-on isn't enabled at all, when it is.
Grant access straight to the pod's Kubernetes service account, no separate Google service account needed:apiVersion: secrets-store.csi.x-k8s.io/v1 kind: SecretProviderClass metadata: name: taguru-secrets spec: provider: gke parameters: secrets: | - resourceName: "projects/PROJECT_NUMBER/secrets/taguru-api-tokens/versions/latest" path: "taguru-api-tokens"
Mount it as a file and passgcloud secrets add-iam-policy-binding taguru-api-tokens \ --role=roles/secretmanager.secretAccessor \ --member="principal://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/PROJECT_ID.svc.id.goog/subject/ns/NAMESPACE/sa/KSA_NAME"--configfor live key rotation — the manifest's comments carry the exact steps. TAGURU_PUBLIC_URL(for claude.ai-connector OAuth on/mcp) is plain configuration, not a secret — set it beside the rest of the environment.
Embeddings: Vertex AI needs a bridge
Verified against the live API, not the desk-analysis assumption: Vertex's OpenAI-compatible surface covers chat, not embeddings.
- The OpenAI-compatible
/embeddingsendpoint does not work. Vertex publishes an OpenAI-compatible surface at…/endpoints/openapi/chat/completionsthat answers real chat requests correctly — verified live. The equivalent…/endpoints/openapi/embeddingsreturned500 INTERNALfor every embedding model tried (gemini-embedding-001,text-embedding-005, with and without thegoogle/publisher prefix). The embed client (src/embedding.rs) speaks exactly this OpenAI-shaped protocol and nothing else, so a bridge is required — this is the one cloud, alongside Bedrock, where the model has to sit behind a proxy rather than take the client's request directly. - LiteLLM bridges it cleanly — for one model. Pointed at
vertex_ai/gemini-embedding-001with Application Default Credentials, LiteLLM's OpenAI-compatible/v1/embeddingsreturned correct, distinct vectors for distinct inputs, and fed a realtaguru calibraterun without any client-side changes.model_list: - model_name: gemini-embedding-001 litellm_params: model: vertex_ai/gemini-embedding-001 vertex_project: PROJECT vertex_location: REGION dimensions: 768 # see the note below — pin this text-embedding-005through the same bridge is broken, not just untested. Verified live: every request tovertex_ai/text-embedding-005through LiteLLM 1.95.0 returned the identical vector regardless of input text — cosine 1.0 between embeddings of completely unrelated strings. The native Vertex:predictendpoint embeds this model correctly; the failure is specific to this LiteLLM version's handling of it. Usegemini-embedding-001until this is independently confirmed fixed, and treat "the bridge returns 200 with plausible-looking numbers" as insufficient — diff two embeddings of clearly different inputs before trusting a bridge configuration.- Pin
dimensionsin the bridge config — the client never asks for one.gemini-embedding-001defaults to 3072 dimensions; the embed client's request body is{"model", "input"}only, with nodimensionsfield to negotiate a smaller width. Settingdimensions: Nin LiteLLM'slitellm_params(verified: it maps to Vertex's ownoutputDimensionality) fixes the width server-side with no code change needed — pick a width once, before the first gloss is embedded, since changing it later means re-embedding everything. - Calibrate before trusting any floor for this model — measured overlap, not a clean
number. A real
taguru calibraterun againstgemini-embedding-001, at both 768 and 3072 dimensions, over a small mixed Japanese corpus (sake brands, a mountain, an author, a castle) returned OVERLAP both times: the best non-expected candidate's cosine landed inside the range of expected-match cosines, most often because a subject's own gloss text names its related concept directly (a brand's gloss reads "…産地。秋田県", and "秋田県" is itself a concept in the corpus). Unlike Bedrock's Titan V2, where calibration reliably finds a clean gap around 0.2, Vertex embeddings on a small corpus did not separate cleanly at either width tried. This is exactly the casetaguru calibrateis built to refuse to paper over — see the calibration walkthrough. Run it against your own, larger corpus before pickingTAGURU_SEMANTIC_FLOOR; no table entry here would be honest. - Document extraction (
taguru extract) can use Vertex's OpenAI-compatible/chat/completionsdirectly for one-shot runs —TAGURU_EXTRACT_API_KEY=$(gcloud auth print-access-token)— since that surface works and the credential only needs to last one run. Steady use should still go through the same LiteLLM bridge, since a raw access token expires in about an hour andTAGURU_EXTRACT_API_KEYis read once at process start. Model credentials stay in the offline producer's environment, never on the server — see Document extraction.
The agent side needs none of this: Claude on Vertex AI
(CLAUDE_CODE_USE_VERTEX=1) drives taguru-mcp over stdio
unchanged, and a managed agent runtime registering
https://your-host/mcp rides through the same load balancer this page fronts
the server with.
Network: external load balancer in front, one port behind
One firewall rule and a strict division of labor between the two health endpoints — verified against a live backend.
- Reachability — the source ranges differ by load balancer type. The frontend
accepts 443 from clients; the backend's firewall rule accepts 8248 from the load
balancer itself, and what that means depends on which external Application Load
Balancer is in front. A global one (what GKE's default Ingress class
provisions, and what this page's own testing used) sends both health checks and proxied
traffic from Google's well-known ranges (
130.211.0.0/22,35.191.0.0/16) — allow those two and nothing else reaches the server. A regional external Application Load Balancer is Envoy-based: health checks still come from those same ranges, but proxied traffic to the backend originates from your VPC's proxy-only subnet instead — allowing only the health-check ranges leaves the health check passing while every real request is dropped by the firewall. Allow the proxy-only subnet's CIDR too if you provision a regional one. Either way, a bearer token is the whole credential, and it never travels unencrypted because TLS terminates at the load balancer. - A
BackendConfigpointed atGET /health, on GKE — verified live: with a custom health check configured this way, the backend service reportedhealthState: HEALTHYagainst the running pod within seconds. Then keep the division of labor straight:Signal Endpoint Who acts Routing: is this backend serving? /health(503 while the write path is degraded)The load balancer routes away and back — it never restarts anything. Restart: is the process wedged? /liveDocker's restart policy, systemd, or the kubelet's liveness probe. The load balancer has no restart lever, so pointing its check at /livewould just mask write-path failures. - The backend service timeout defaults to 30 seconds — verified via
gcloud compute backend-services create --help— which happens to equalTAGURU_REQUEST_TIMEOUT_SECS's own default. That's a coincidence worth not relying on: the server always answers within its own request budget (embedding attempts are cut at the request's remaining time, not their own nominal ceiling), so with both left at their defaults nothing ever hits the load balancer's limit. RaiseTAGURU_REQUEST_TIMEOUT_SECS— say, to give a slow embedding bridge more room across retries — and--timeouton the backend service has to move with it, or the load balancer returns 504 while the server is still mid-request and would have answered. - Connection draining ≥ the request budget. When a backend leaves the pool,
in-flight requests get exactly the configured drain timeout to finish, then the
connection is cut. Size it to
TAGURU_REQUEST_TIMEOUT_SECSor above, and keep it at or below the pod's grace period on GKE (see grace vs. drain). - The Google-managed certificate needs a real, resolvable hostname before it will
issue — point DNS at the load balancer's static IP first. On GKE that's a
ManagedCertificateresource referenced from the Ingress; on a VM fronted by a manually configured load balancer it's the same certificate manager under a plaingcloudcall instead:
Either path staysgcloud compute ssl-certificates create taguru-cert \ --domains=HOST --globalProvisioninguntil the domain resolves and the challenge succeeds, which can take longer than the first impatient check. - Once the server is reachable beyond the VPC, set
TAGURU_RATE_LIMIT_PER_MIN; Cloud Armor in front is optional on top.
Backups and DR: snapshot schedules + an export lane
Every writer is fsync+rename, so a Persistent Disk snapshot is safe at any instant — even mid-write, with the server running.
- Automate snapshots with a resource policy — verified CLI shape:
gcloud compute resource-policies create snapshot-schedule taguru-snap \ --region=REGION --daily-schedule --start-time=13:00 \ --max-retention-days=14POST /flushfirst when you want a snapshot to contain this second's writes rather than the last flush interval's. - Rehearse the restore: create a disk from the snapshot, attach it to a running
VM, mount it, and run the image against it:
gcloud compute disks create taguru-restore-test --source-snapshot=SNAPSHOT --zone=ZONE gcloud compute instances attach-disk RESTORE_VM --zone=ZONE --disk=taguru-restore-test mkdir -p /mnt/restore && mount /dev/disk/by-id/google-taguru-restore-test /mnt/restore docker run --rm -v /mnt/restore/data:/data \ REGION-docker.pkg.dev/PROJECT/taguru-mirror/t0k0sh1/taguru:VERSION inspect /datataguru inspectruns the same fully-validating load + WAL replay the server boots with; a non-zero exit means the copy is corrupt, and a snapshot you have never restored is not a backup. - Regional PD is the zone lane, snapshots are the region and corruption lane — see the failover procedure for the former; a Regional PD is still one write history, so it does not replace the latter.
- Keep a second, environment-independent lane:
taguru export --urlagainst the running server writes portable JSONL batch streams — ship them to Cloud Storage on a schedule. They restore anywhere throughtaguru import, across versions and machines. Note the export's own caveat: each context's stream is internally consistent, but a multi-context export is not one point in time — the snapshot lane is.
With TAGURU_REPLICATE_URL=gs://BUCKET the data directory also ships
continuously — verified end to end: a running server shipped its first generation to a
bucket within a second of boot, and taguru restore --out DIR gs://BUCKET
into an empty directory followed by taguru inspect came back clean, no
corruption, no manual repair. Credentials ride Application Default Credentials on a VM
or Workload Identity on GKE — nothing cloud-specific to configure beyond the bucket URL.
This lane complements, not replaces, the snapshot you can hand to
taguru inspect — see
the promotion runbook for the read-replica side
of the same mechanism.
Observability
Three surfaces, three Google Cloud sinks — verified against a live GKE deployment, including one nesting detail worth knowing before it surprises you.
/metrics(Prometheus text format, confirmed against a live pod) → Google Cloud Managed Service for Prometheus, via aPodMonitoringresource on GKE or the Ops Agent's Prometheus receiver on a VM. The health-story metrics to alert on are the flush failures behind/healthand — with the embedding tier wired — the circuit-breaker family described on the Bedrock page.TAGURU_LOG_FORMAT=json→ Cloud Logging ingests it correctly, but not entirely the way the field names suggest. Verified against real log entries from a GKE pod: thelevelfield does get mapped automatically to Cloud Logging'sseverity(aWARNline surfaces asseverity: WARNINGin agcloud logging readquery) — but the human-readable text stays nested atjsonPayload.fields.message, not the top-leveljsonPayload.messageCloud Logging's console uses for its default one-line summary. Filtering and alerting on severity works out of the box; expect to expand each entry (or write a log-based view) to read the message itself.OTEL_EXPORTER_OTLP_ENDPOINT→ an OpenTelemetry collector → Cloud Trace: the full retrieval span tree from Tracing, with no instrumentation to add.
Scale-out: shards, not replicas
Write scale is disjoint context sets on independent instances — the model is on the Kubernetes page.
- Each shard is a complete stack of this page: its own VM (or pod), its own Persistent Disk, its own snapshots. Contexts that must be searched together (groups, cross-context search reach only their own shard) cohabit on one shard.
- In front,
taguru routeris stateless — run it anywhere, give clients one hostname. If clients address shards directly instead, give each shard its own hostname behind the load balancer: context names live in the path, so path-based routing cannot split them. - Read scale is the replica pool tailing a bucket
(the read pool) — replicas are read-only and
need only storage credentials, not a disk, verified via the same
TAGURU_REPLICATE_URL/taguru restorepath as Backups and DR above.
Taguru