Taguru
deploy · azure

Deploy on Azure

The same single-writer, single-data-directory design that decides the AWS mapping decides the Azure one — with two twists of its own. Azure is the platform where the storage trap is easiest to step into: its container services persist to Azure Files, exactly the network-filesystem case the design rules out. And it is the platform with the thinnest embedding integration: Azure OpenAI speaks the API the embed client already speaks, so there is no bridge to run and the default floor is already calibrated for the obvious model choice.

Compute: VM (Docker/systemd) or AKS Storage: Managed Disk — never Azure Files TLS: Application Gateway + Key Vault cert in front Embeddings: Azure OpenAI, direct — no bridge

The three constraints that decide everything

The general model is on the Docker Compose and Kubernetes pages; this page is only the Azure projection of it.

  • One data directory, one writer — and network filesystems are out. On Azure that means Managed Disk only, never Azure Files. An SMB share does refuse a second server — but as a bare Permission denied (os error 13) at startup, which reads as a permissions bug and sends you debugging fsGroup instead of the real problem; and the refusal rides on an SMB lease, the same partition-can-hand-the-lock-away class the AWS page documents for NFS. NFS-protocol Azure Files shares carry that lease failure mode directly. A single-attached managed disk has no version of this problem.
  • Availability is restore time. Deploys are stop-then-start, there is no failover, and the availability story is snapshots plus (optionally) a replica to promote by hand — see the promotion runbook. Azure adds one genuinely useful lane here: a ZRS managed disk is a zone-redundant block device that stays single-attach — when a zone dies, detach it from the dead VM and attach it to a VM in another zone, no snapshot restore in the path. Availability = restore time still holds; ZRS just makes the cross-zone restore a reattach.
  • 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 Azure that layer is Application Gateway v2 (add WAF if it faces the internet) with its certificate in Key Vault; the server behind it speaks HTTP on 8248 to the gateway alone.

Compute: VM or AKS, and why not the rest

Anything that cannot keep one managed disk attached to one long-lived process is out.

ServiceFitWhy
VM + Docker (or the bare binary under systemd)✓ the simplest honest fitOne VM, one Premium SSD data disk, one container. The Docker Compose page applies nearly verbatim.
AKS✓ with deploy/kubernetes.yaml as-isThe default StorageClass is already the Azure Disk CSI driver with WaitForFirstConsumer — the manifest's replicas: 1 + Recreate + RWO PVC land unchanged, no storage setup at all.
Container Apps / ACITheir only persistence is Azure Files — the constraint above rules the whole category out, not a configuration within it.
App ServiceShared/ephemeral filesystem semantics, persistent content on Azure Files — same class of problem, plus no say over the storage at all.

VM

  • Keep the data directory on its own data disk (Premium SSD; ZRS if you want the cross-zone reattach lane), separate from the OS disk: snapshots, restores, and VM replacement then never touch the OS. Azure exposes data disks by LUN under a stable path. 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 no mkdir, so the host does this once:
    DEV=$(readlink -f /dev/disk/azure/scsi1/lun0)
    mkfs -t xfs "$DEV" && mount "$DEV" /mnt/taguru
    mkdir -p /mnt/taguru/data && chown 65532:65532 /mnt/taguru/data
  • Plan the egress before the first boot. A VM without a public IP has no default outbound access on subnets created since default outbound retirement — package installs, the registry pull, and the Azure OpenAI endpoint are all silently unreachable until the subnet has a NAT Gateway (or another explicit outbound method). The failure is quiet: cloud-init just fails to install anything.
  • No SSH required. A system-assigned managed identity lets the boot script read secrets from Key Vault (below), and Run Command / Bastion cover the rare interactive need.
  • docker run --restart unless-stopped (or a systemd unit with Restart=on-failure) is the restart policy — restarts are the host's job, not the gateway's (see the probe split).
  • VM size: memory ≈ TAGURU_CACHE_BYTES (default 512 MiB) + pinned contexts + headroom — a B2s runs the default profile; measure with taguru estimate before scaling up.
  • Zone recovery is a reattach if the disk is ZRS. An LRS disk is zone-bound and recovery into another zone is a snapshot restore; a ZRS disk detaches from the dead VM and attaches to a standby in any zone of the region, data intact — run taguru inspect after the first rehearsal, not the first outage.

AKS

  • deploy/kubernetes.yaml applies unchanged. Everything the Kubernetes page says — the probe wiring, fsGroup: 65532, the grace period — holds on AKS, and unlike EKS there is no CSI or StorageClass work: the built-in default class provisions a managed disk in the pod's zone on first consumption.
  • Never let the PVC land on azurefile. The cluster ships azurefile* classes next to the disk ones, and picking one appears to work — one replica comes up and serves. The failure arrives later and wears a costume: a second pod against the same share (a scale-out mistake, or a RollingUpdate overlap where the new pod starts before the old one exits) crash-loops with data directory is not usable: Permission denied — an SMB lock surfacing as a permissions error. On a RollingUpdate that reads as a deploy stuck for no reason. Keep the PVC on the disk classes and the failure class does not exist.
  • Two guards, two layers. On a managed disk, a second pod on another node is refused by the platform (Multi-Attach error — the volume stays 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 file share.
  • A Recreate rollout on the same node costs seconds — single-digit in practice, with the PV reattached, not reprovisioned. A reschedule to another node adds the detach/attach round trip; another zone needs the snapshot walk (an LRS RWO disk cannot follow the pod across zones — see the ZRS note above for the exception).
  • Fronting with Application Gateway: AGIC (the Application Gateway Ingress Controller) or Application Gateway for Containers routes into the pod; either way the health-check and timeout rules are the section below — they belong to the gateway, whichever controller programs it.
  • Grace vs. drain: terminationGracePeriodSeconds: 60 in the manifest covers the drain plus the final flush. Keep the gateway's connection-draining window 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 AKS the same way, with TAGURU_REPLICATE_URL pointed at Blob Storage and the pod's credentials granted via workload identity — see the Kubernetes page.

The image: mirror ghcr.io through ACR

Keep pulls inside Azure and survive a ghcr.io outage without changing what you run.

  • The one-shot mirror is az acr import — it copies the manifest as-is, so a digest pin carries over unchanged:
    az acr import --name REGISTRY \
      --source ghcr.io/t0k0sh1/taguru:VERSION \
      --image taguru:VERSION
    Re-run it per release (CI is the natural place); reference the image as REGISTRY.azurecr.io/taguru@sha256:…, pinned exactly as the image guidance says.
  • For pull-on-demand instead of import-per-release, ACR's artifact cache rules mirror ghcr.io the way ECR's pull-through cache does, with upstream credentials (a GitHub token with read:packages) stored in Key Vault.
  • Pullers authenticate with managed identity (AcrPull role) — no registry passwords in the boot script; on AKS, attach the registry to the cluster and the kubelet identity gets AcrPull for you.

Secrets: Key Vault feeds the boot, identity does the auth

TAGURU_API_TOKENS (and TAGURU_KEY_SCOPES, the embedding key, …) come from Key Vault at boot; nothing lands in a manifest or a template.

  • VM: a system-assigned managed identity plus the Key Vault Secrets User role is the whole credential chain — the boot script trades the IMDS token for the secrets:
    T=$(curl -s -H Metadata:true \
      'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fvault.azure.net' \
      | jq -r .access_token)
    TOKENS=$(curl -s -H "Authorization: Bearer $T" \
      'https://VAULT.vault.azure.net/secrets/taguru-api-tokens?api-version=7.4' | jq -r .value)
    docker run -d --restart unless-stopped -p 8248:8248 \
      -e TAGURU_API_TOKENS="$TOKENS" -e TAGURU_LOG_FORMAT=json \
      -v /mnt/taguru/data:/data REGISTRY.azurecr.io/taguru@sha256:…
    Remember the value's shape: name:token pairs — a bare token is refused at startup (refusing to start with broken credentials), by design.
  • AKS: the Secrets Store CSI driver (Key Vault provider) or External Secrets Operator materializes the Key Vault entry as the taguru-keys Secret the manifest already consumes, authenticated by workload identity. For live key rotation, mount it as a file and pass --config — 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: Azure OpenAI, direct — the only cloud with no bridge

The embed client POSTs OpenAI-shaped JSON with a Bearer header; Azure OpenAI accepts exactly that.

  • Point the client straight at the resource — the v1 surface takes the API key as a Bearer token and the deployment name as model, which is precisely what the client sends:
    TAGURU_EMBED_URL=https://RESOURCE.openai.azure.com/openai/v1/embeddings
    TAGURU_EMBED_MODEL=DEPLOYMENT_NAME
    TAGURU_EMBED_API_KEY=…   # from Key Vault, like the API tokens
    No LiteLLM, no proxy, no header rewriting. (The older deployment-scoped …/openai/deployments/NAME/embeddings?api-version=… endpoint also accepts the Bearer form — but the v1 URL is the one to write down: one URL, standard body, nothing Azure-shaped for the client to know.)
  • Deploy text-embedding-3-large and the default floor is already home. TAGURU_SEMANTIC_FLOOR's default 0.35 is calibrated for exactly this model. One honest caveat from measurement: on Japanese content, true paraphrase matches land roughly 0.35–0.55 — the default sits at the bottom of the true band, and a marginal true match can dip just under it. Run taguru calibrate --context NAME --probes FILE against your own content once (see the calibration story) rather than trusting any table, this one included.
  • The circuit breaker and budgets apply unchanged: embedding attempts are cut at min(TAGURU_EMBED_TIMEOUT_SECS, remaining request budget) and repeated provider failures trip the breaker — the knobs and the metrics to alert on are on the Bedrock page.
  • Document extraction (taguru extract) speaks to Azure OpenAI chat models over the same v1 surface; its credentials live in the offline producer's environment only, never on the server — see Document extraction.

The agent side — Claude via Microsoft Foundry driving taguru-mcp, or a remote-MCP agent service registering https://your-host/mcp — rides through the same gateway this page fronts the server with; the MCP endpoint needs nothing Azure-specific.

Network: Application Gateway in front, one port behind

One NSG rule chain and a strict division of labor between the two health endpoints.

  • Reachability: the gateway's frontend accepts 443 from clients (WAF SKU if that's the internet); the backend subnet's NSG accepts 8248 from the gateway's subnet only. Nothing else reaches the server — a bearer token is the whole credential, and the token never travels unencrypted because TLS terminates at the gateway, certificate served from Key Vault.
  • Create a custom health probe for GET /health — the default probe asks for /, which is not a health surface. Then keep the division of labor straight:
    SignalEndpointWho acts
    Routing: is this backend serving?/health (503 while the write path is degraded)The gateway 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 gateway has no restart lever, so pointing its probe at /live would just mask write-path failures.
  • requestTimeout must cover the request budget — set it explicitly. The backend HTTP settings carry their own request timeout, and its initial value is tool-dependent: Azure documents a 20-second default, while some tooling writes 30 — neither is coupled to TAGURU_REQUEST_TIMEOUT_SECS (30s), and the documented default is shorter than the server's budget, so an untouched gateway can time out requests the server would have answered. At the boundary the gateway wins: it returns 504 and the server keeps burning its budget behind it, finishing a request nobody is waiting for (it logs 408 when the budget runs out). Set requestTimeout at or above TAGURU_REQUEST_TIMEOUT_SECS, and move both together when a slow embedding provider needs a bigger ceiling across retries.
  • Connection draining ≥ the request budget. When a backend leaves the pool, in-flight requests get exactly drainTimeoutInSec to finish, then the connection is cut — a response that arrives seconds after the window closes reaches no one. Size the drain window to TAGURU_REQUEST_TIMEOUT_SECS or above, and keep it at or below the pod grace period on AKS (see grace vs. drain).
  • Once the server is reachable beyond the VNet, set TAGURU_RATE_LIMIT_PER_MIN; WAF policies on the gateway are optional on top.

Backups and DR: disk snapshots + an export lane

Every writer is fsync+rename, so a managed-disk snapshot is safe at any instant — even mid-write, with the server running.

  1. Automate snapshots with Azure Backup's managed-disk policy (or scheduled incremental snapshots), on whatever cadence bounds your RPO. POST /flush first when you want a snapshot to contain this second's writes rather than the last flush interval's.
  2. Rehearse the restore: create a disk from the snapshot, attach it, and run the image against it — XFS twins need -o nouuid to mount beside their original:
    mount -o nouuid $(readlink -f /dev/disk/azure/scsi1/lun1) /mnt/restore
    docker run --rm -v /mnt/restore/data:/data \
      REGISTRY.azurecr.io/taguru@sha256:… inspect /data
    taguru inspect runs 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.
  3. ZRS is the zone lane, snapshots are the region lane. A ZRS data disk turns a zone outage into detach-and-reattach (rehearse it: attach to a VM in another zone, mount, taguru inspect) — but it is one disk with one write history, not a backup. Cross-region recovery is always a snapshot or export restore.
  4. Keep a second, environment-independent lane: taguru export --url against the running server writes portable JSONL batch streams — ship them to Blob Storage on a schedule. They restore anywhere through taguru 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 pointed at a Blob Storage container the data directory also ships continuously (RPO ≈ seconds; recover with taguru restore) — that lane and the promotion runbook are on the Docker Compose and architecture pages, and they complement, not replace, the snapshot you can hand to taguru inspect.

Observability

Three surfaces, three Azure sinks — nothing needs shell access to the scratch container.

  • /metrics (Prometheus text) → Azure Monitor managed Prometheus (on AKS, enable the metrics add-on and a pod annotation covers the scrape; on a VM, the Azure Monitor agent's Prometheus collection). The health-story metrics to alert on are the flush failures behind /health and — with the embedding tier wired — the circuit-breaker family described on the Bedrock page.
  • TAGURU_LOG_FORMAT=json → one JSON object per line, which Container Insights (AKS) or the Azure Monitor agent (VM) forwards to Log Analytics with fields intact.
  • OTEL_EXPORTER_OTLP_ENDPOINT → an OpenTelemetry collector → Application Insights (or any OTLP backend): 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 managed 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 router is stateless — run it anywhere, give clients one hostname. If clients address shards directly instead, give each shard its own hostname (multi-site listeners on the gateway): 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.