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.
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 debuggingfsGroupinstead 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.
| Service | Fit | Why |
|---|---|---|
| VM + Docker (or the bare binary under systemd) | ✓ the simplest honest fit | One VM, one Premium SSD data disk, one container. The Docker Compose page applies nearly verbatim. |
| AKS | ✓ with deploy/kubernetes.yaml as-is | The 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 / ACI | ✗ | Their only persistence is Azure Files — the constraint above rules the whole category out, not a configuration within it. |
| App Service | ✗ | Shared/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 nomkdir, 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 withRestart=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 withtaguru estimatebefore 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 inspectafter the first rehearsal, not the first outage.
AKS
deploy/kubernetes.yamlapplies 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 shipsazurefile*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 withdata 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
Recreaterollout 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: 60in 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:
Re-run it per release (CI is the natural place); reference the image asaz acr import --name REGISTRY \ --source ghcr.io/t0k0sh1/taguru:VERSION \ --image taguru:VERSIONREGISTRY.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.iothe way ECR's pull-through cache does, with upstream credentials (a GitHub token withread:packages) stored in Key Vault. - Pullers authenticate with managed identity (
AcrPullrole) — no registry passwords in the boot script; on AKS, attach the registry to the cluster and the kubelet identity getsAcrPullfor 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 Userrole is the whole credential chain — the boot script trades the IMDS token for the secrets:
Remember the value's shape: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:…name:tokenpairs — 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-keysSecret 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:
No LiteLLM, no proxy, no header rewriting. (The older deployment-scopedTAGURU_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…/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-largeand 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. Runtaguru calibrate --context NAME --probes FILEagainst 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:Signal Endpoint Who 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 /livewould just mask write-path failures. requestTimeoutmust 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 toTAGURU_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). SetrequestTimeoutat or aboveTAGURU_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
drainTimeoutInSecto finish, then the connection is cut — a response that arrives seconds after the window closes reaches no one. Size the drain window toTAGURU_REQUEST_TIMEOUT_SECSor 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.
- Automate snapshots with Azure Backup's managed-disk policy (or scheduled
incremental snapshots), on whatever cadence bounds your RPO.
POST /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, and run the
image against it — XFS twins need
-o nouuidto 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 /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. - 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. - Keep a second, environment-independent lane:
taguru export --urlagainst the running server writes portable JSONL batch streams — ship them to Blob 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 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/healthand — 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 routeris 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.
Taguru