# Azure deployment (Container Apps + Key Vault + Azure Files)

Deploys the server as a **per-user remote OAuth connector** for Claude, running
as a stateless HTTPS service on Azure Container Apps with secrets in Key Vault
and persistent state on Azure Files. By default the infra deploys in
`MCP_AUTH_MODE=oauth`: the server is an OAuth 2.0 Authorization Server +
Protected Resource that bridges each Claude user to **their own** Clio account.

A shared-account `static` variant is documented at the end for single-tenant
setups.

## Architecture

```
                  Azure subscription
   ┌───────────────────────────────────────────────────────────┐
   │                                                            │
   │   ┌────────────┐  HTTPS    ┌──────────────┐   ┌─────────┐  │
   │   │  Claude    │──OAuth───►│  Container   │──►│  Clio   │  │
   │   │ (each user │  + /mcp   │  Apps        │   │  v4 API │  │
   │   │  signs in) │           │  (stateless) │   └─────────┘  │
   │   └────────────┘           └──────┬───────┘                │
   │       ▲ user sign-in (302 via Clio login)                  │
   │       └────────────────────────────┘                       │
   │                            │ secrets-from-Key-Vault         │
   │                            │ file-mount: /state             │
   │                  ┌─────────┴──────────┐                     │
   │                  │ Key Vault (RBAC)   │                     │
   │                  │ Azure Files share  │                     │
   │                  └────────────────────┘                     │
   └───────────────────────────────────────────────────────────┘
```

Resources provisioned by `infra/main.bicep`:

- Log Analytics workspace + Application Insights
- Container Apps environment (with the Azure File share registered)
- Azure Container Registry (Basic)
- User-assigned managed identity (ACR pull + Key Vault Secrets User)
- Azure Key Vault (RBAC mode, soft delete + purge protection on)
- Azure Storage Account + File Share (`clio-state`)
- Container App with:
  - HTTPS ingress, target port 8765
  - `MCP_AUTH_MODE=oauth` and an auto-derived `PUBLIC_BASE_URL`
  - **3 Key Vault secret references** in OAuth mode (client id, client secret,
    encryption key). The two static-mode secrets are wired **only** when
    `authMode != 'oauth'`.
  - `/state` volume mounted from the file share (`tokens.enc`, `sessions/`, audit)
  - Liveness + readiness probes on `/healthz`
  - HTTP-based autoscale (1 → 4 by default)

### `PUBLIC_BASE_URL` is set for you

In `oauth`/`hybrid` mode the OAuth issuer and the Clio redirect URI must be
fixed, absolute HTTPS URLs. The Bicep derives this from the Container Apps
environment's stable default domain:

```
PUBLIC_BASE_URL = https://<appName>.<environment defaultDomain>
```

which is exactly the app's ingress FQDN. You do **not** set it manually. It's
also surfaced as the `SERVICE_API_URI` output, and `${PUBLIC_BASE_URL}/mcp` as
`SERVICE_API_MCP_ENDPOINT`.

## Prerequisites

- Azure CLI (`az`) and Azure Developer CLI (`azd`) installed.
- Docker — `azd` builds the image locally and pushes to ACR.
- A Clio Developer Application. You'll register its connector redirect URI in
  step 4, once `azd up` has produced the public URL.
- An Azure subscription with these resource providers registered. Unregistered
  providers are the most common first-deploy failure on a fresh subscription:

  ```bash
  for ns in Microsoft.App Microsoft.ContainerRegistry Microsoft.OperationalInsights \
            Microsoft.KeyVault Microsoft.Storage Microsoft.Insights; do
    az provider register --namespace "$ns"
  done
  ```

> **Confirm the subscription and tenant before you provision.** This connector
> reaches live client-matter data. Deploying it into the wrong tenant is the one
> mistake here that is genuinely painful to unwind.
>
> ```bash
> az account list --all --output table
> az account set --subscription "<subscription-id>"
> az account show --query "{sub:name, id:id, tenant:tenantId, user:user.name}"
> ```

## 1. Provision (OAuth mode by default)

```bash
az login
azd auth login
azd env new clio-manage-prod
azd env set AZURE_SUBSCRIPTION_ID <subscription-id>
azd env set AZURE_LOCATION eastus2     # any Container Apps-supported region
azd env set CLIO_REGION us             # us | ca | eu | au
azd env set MIN_REPLICAS 1             # keep one replica warm — see below
azd up
```

`azd up` builds the image, provisions infrastructure, and deploys with
`MCP_AUTH_MODE=oauth`. Expect ~6–8 minutes the first time.

### Keep one replica warm

**Set `MIN_REPLICAS=1` for any connector you actually use.** Bicep already
defaults to 1; the setting above is explicit so it survives an environment that
was previously set to 0.

Scale-to-zero looks attractive and is the wrong trade here. Measured on a
scale-to-zero deployment of this exact image, the first request after idle took
**31.6 seconds** to return `/healthz`. That is inside Claude's 300-second
timeout, so it does not hard-fail — it just makes every first interaction of the
day feel broken, including the OAuth redirect, which is the worst possible place
to stall a user.

`minReplicas: 0` remains reasonable for a dev environment you poke occasionally.
See [Cost](#cost) for what warm actually costs (~$15–20/month all-in).

The relevant outputs are:

- `AZURE_KEY_VAULT_NAME` — where you'll write the three secrets
- `SERVICE_API_URI` — the public HTTPS base URL (this is your `PUBLIC_BASE_URL`)
- `SERVICE_API_MCP_ENDPOINT` — the `${PUBLIC_BASE_URL}/mcp` connector URL

## 2. Populate Key Vault (3 secrets)

OAuth mode needs exactly three secrets. There is **no shared bearer token and no
bootstrap refresh token** — each user authorizes their own Clio account through
Claude.

```bash
KV_NAME=$(azd env get-values | grep AZURE_KEY_VAULT_NAME | cut -d= -f2 | tr -d '"')

# 1. Clio app credentials
az keyvault secret set --vault-name "$KV_NAME" --name clio-client-id     --value "<from Clio>"
az keyvault secret set --vault-name "$KV_NAME" --name clio-client-secret --value "<from Clio>"

# 2. Token encryption key (64 hex / 32 bytes) — encrypts every per-user session
az keyvault secret set --vault-name "$KV_NAME" --name clio-encryption-key \
  --value "$(openssl rand -hex 32)"
```

After the secrets exist, restart the revision so it picks them up:

```bash
az containerapp revision restart \
  -n "$(azd env get-values | grep SERVICE_API_NAME | cut -d= -f2 | tr -d '"')" \
  -g "$(azd env get-values | grep AZURE_RESOURCE_GROUP | cut -d= -f2 | tr -d '"')"
```

## 3. Verify

```bash
BASE=$(azd env get-values | grep SERVICE_API_URI | cut -d= -f2 | tr -d '"')
curl -sS "${BASE}/healthz"
# {"status":"ok","server":"clio-manage-mcp","auth_mode":"oauth","region":"us"}

curl -sS "${BASE}/readyz"
# {"status":"ready"}        (in OAuth mode, readiness is independent of any account)
```

On a warm replica (`MIN_REPLICAS=1`) `/healthz` should answer in well under a
second. If it takes ~30 s, the environment is still scaled to zero — check
`azd env get-values | grep MIN_REPLICAS`.

You can also confirm OAuth discovery is live:

```bash
curl -sS "${BASE}/.well-known/oauth-authorization-server" | head
```

## 4. Register the Clio redirect URI

On your Clio Developer Application (*Settings → Developer Applications*), add the
connector callback as a Redirect URI:

```bash
echo "Register this Redirect URI in Clio: ${BASE}/oauth/clio/callback"
```

(Clio allows multiple redirect URIs — keep `http://127.0.0.1:5678/callback` too
if you also use local stdio. See [docs/oauth-setup.md](oauth-setup.md).)

## 5. Add the connector in Claude → sign in to Clio

Give each attorney the connector URL (same for everyone):

```
${PUBLIC_BASE_URL}/mcp     # = the SERVICE_API_MCP_ENDPOINT output
```

In Claude: **Settings → Connectors → Add custom connector → paste the URL.**
Claude runs OAuth discovery and Dynamic Client Registration, then the server
redirects straight to **Clio** to sign in and authorize. The session's access
tier (**Read only**, **Read & write**, or **Read, write & delete**, the last
only when `CLIO_ALLOW_DESTRUCTIVE=true`) comes from the scopes the client
requested, clamped to what the deployment allows. Each user is bound to their own Clio account; sessions are encrypted
and isolated, and the granted tier decides which tools the session sees. See
[docs/oauth-setup.md](oauth-setup.md).

### Verify the full round trip

Discovery working does not mean the bridge works. Walk every hop once on a fresh
deployment — each one names the artifact that proves it:

| # | Check | Expected |
|---|---|---|
| 1 | `GET /healthz` | 200 in well under 1 s (confirms `MIN_REPLICAS=1`) |
| 2 | `GET /.well-known/oauth-protected-resource/mcp` | `resource` + `authorization_servers` |
| 3 | `POST /mcp` with no token | 401 + `WWW-Authenticate` carrying `resource_metadata` |
| 4 | `POST /register` (DCR, unauthenticated) | a `client_id` |
| 5 | `GET /authorize` with that `client_id` + PKCE | 302 to Clio's authorize host, `state` = txn id |
| 6 | Submit consent | 302 to `app.clio.com/oauth/authorize` |
| 7 | Sign in at Clio | 302 → `/oauth/clio/callback` → client redirect carrying a code |
| 8 | `POST /token` | access + refresh pair, with `scope` |
| 9 | `POST /mcp` `tools/list` | only the tools the chosen tier covers |
| 10 | `clio_who_am_i` | the signed-in Clio user |

**Run hop 5 twice — once accepting, once declining.** Declining should land you
back at Claude with a clean `access_denied`, not stranded on a Clio page. That
path depends on `redirect_on_decline=true`, which the server sends.

Then confirm tier filtering actually bites: connect once as **Read only** and
check that `tools/list` omits every create/update tool, and that
`clio_api_request` rejects a `POST`.

Hop 4 writes a permanent client record to the file share — delete test
registrations from `clients/` when you're done.

## 6. Custom domain (optional)

```bash
# Add a domain
az containerapp hostname add -n <app> -g <rg> --hostname mcp.example.com

# Then issue a managed cert
az containerapp hostname bind -n <app> -g <rg> --hostname mcp.example.com \
  --environment <cae> --validation-method CNAME
```

If you front the app with a custom domain, the OAuth issuer must match the URL
users actually reach. Set `PUBLIC_BASE_URL` to the custom domain
(`https://mcp.example.com`) and re-register `${PUBLIC_BASE_URL}/oauth/clio/callback`
in Clio. (The Bicep derives `PUBLIC_BASE_URL` from the default ingress FQDN; to
pin it to a custom domain, set the env var on the Container App and restart.)

The server sets Express `trust proxy` to `1`, trusting exactly one proxy hop,
which matches the Container Apps ingress. If you chain an additional layer in
front (Front Door, API Management), the client IP the server observes becomes
that layer's address.

## Operating

**Logs** stream to Log Analytics — query with:

```kusto
ContainerAppConsoleLogs_CL
| where ContainerAppName_s startswith "ca-cliomanage"
| order by TimeGenerated desc
| take 200
```

The server writes structured JSON to stderr and imports **no Application
Insights SDK**, so Log Analytics above is the real query surface. The App
Insights component is still provisioned (it costs nothing idle and is ready if a
future release instruments the process), but nothing reports to it — which is
why the container deliberately carries no
`APPLICATIONINSIGHTS_CONNECTION_STRING`. Don't re-add it expecting traces.

**Audit log** lives on the file share as **`/state/audit-<replica>.log`** — one
file per replica, not a single `audit.log`. `appendFile`'s atomicity is a local
filesystem guarantee, not an SMB one, so concurrent appends from several
replicas to one file could interleave and corrupt the record. Reading the full
trail means concatenating `audit-*.log`. (stdio keeps the plain `audit.log`; it
is single-process by definition.) State also includes the encrypted `tokens.enc`
(static mode) and the `sessions/` directory (OAuth sessions). To pull the audit
logs down:

```bash
az storage file download-batch \
  --account-name "$(azd env get-values | grep AZURE_STORAGE_ACCOUNT_NAME | cut -d= -f2 | tr -d '"')" \
  --source clio-state \
  --destination ./audit-export
```

**Rotation** — the server never rotates the audit files. Run periodic exports +
truncation from a scheduled Azure Function or a cron sidecar.

**Multi-replica** — sessions, registered clients, and pending authorizations are
stored as encrypted records on the shared `/state` mount, so scaling out is safe
as long as every replica shares the same `clio-encryption-key`.

Two known edges at scale, neither a problem at firm size:

- **Registered DCR clients are never swept.** `sweep()` covers `pending/` and
  `sessions/`; client registrations carry no expiry and accumulate in
  `clients/`. They are inert, but delete test registrations after verification
  rather than leaving them.
- **Refresh-token lookup scans.** Sessions are keyed by access-token hash, so a
  refresh grant reads and decrypts every session file to find the match. Over
  SMB each read is a network round trip, so refresh latency grows linearly with
  the live session count. Fine for tens of users; a refresh-hash index is the
  fix if that ever changes.

## Cost

Verified against East US pay-as-you-go retail rates, 2026-07. For a
moderate-volume firm (a few thousand tool calls/day) running 1 warm replica
(`minReplicas=1`):

| Component                          | Approx. monthly       |
|------------------------------------|-----------------------|
| Container App (0.5 vCPU, 1 idle replica) | ~$10 (idle rate) |
| Container Apps environment         | included              |
| ACR Basic                          | ~$5                   |
| Key Vault                          | <$1                   |
| Azure Files (10 GiB)               | ~$1                   |
| Log Analytics                      | free under 5 GB/mo    |
| **Total**                          | **~$15–16/mo idle, ~$16–18 light use** |

Container Apps bills idle vCPU and memory at $0.000003/second against
$0.000024/second for active vCPU, and the subscription's monthly free grant
(180,000 vCPU-seconds + 360,000 GiB-seconds) comes off first. A connector
serving one firm sits near the idle rate nearly all the time, which is why the
idle figure is the realistic one. The pathological ceiling — one replica billing
at the active rate around the clock — is ~$34/month for compute.

Log Analytics ingestion is free under 5 GB/month with 30-day retention; this
deployment stays well under both, and the Bicep additionally caps ingestion at
1 GB/day so a logging misconfiguration cannot run up the $2.30/GB meter.

Set `minReplicas=0` for scale-to-zero: total drops to ~$5–6/month with ACR Basic
as the floor. **The cold start is not "a few seconds" — a scale-to-zero
deployment of this image was measured at 31.6 seconds** to first byte on
`/healthz`. Fine for a dev environment, wrong for a connector people use. See
[Keep one replica warm](#keep-one-replica-warm).

## Troubleshooting

**`/healthz` 200 but Claude can't connect** — check OAuth discovery is reachable
(`curl ${BASE}/.well-known/oauth-authorization-server`) and that the connector URL
you pasted ends in `/mcp`. The 401 on an unauthenticated `/mcp` is expected — it
carries the `WWW-Authenticate` challenge Claude follows to discover the OAuth
endpoints.

**Sign-in fails / "Authorization session expired"** — the Clio Developer
Application is missing the redirect URI, or it doesn't match exactly. Register
`${PUBLIC_BASE_URL}/oauth/clio/callback` (HTTPS, no trailing slash). The state
record is also single-use and short-lived; just retry the connect.

**Clio code exchange / `invalid_grant`** — usually a region mismatch (the Clio
app and `CLIO_REGION` must agree) or a redirect-URI mismatch. Confirm both.

**Write or delete tools missing from Claude's tool list** — the session was
granted a lower access tier; `tools/list` only exposes
tools the tier covers. Reconnect and pick a higher tier. Delete tools
additionally require `CLIO_ALLOW_DESTRUCTIVE=true` on the server.

**Container won't start after switching to static mode** — static mode references
two extra Key Vault secrets (`clio-http-auth-tokens`, `clio-refresh-token`). A
secret reference to a missing Key Vault secret fails the container. Create both
before deploying in `static`/`hybrid`.

**401 on `/mcp` in static mode** — the bearer token doesn't match any of the
comma-separated values in `clio-http-auth-tokens`. Rotate or add one.

**Cannot mount /state** — the storage account / file share is missing or the
managed identity lacks access. `azd provision` should re-converge it.

**Slow first request** — `minReplicas=0` + cold start. Raise to `1` if your firm
needs warm always-on latency.

---

## Optional: shared-account (static) deployment

For a single-tenant deployment where one shared Clio login is acceptable, run in
`static` mode. A shared bearer token gates `/mcp`, and one shared Clio account is
seeded from a refresh token.

```bash
azd env set MCP_AUTH_MODE static
azd up
```

In static mode the Bicep wires two **additional** Key Vault secrets, so create
all five before restarting:

```bash
# (the three from OAuth mode: clio-client-id, clio-client-secret, clio-encryption-key)

# 4. Shared bearer token(s) callers present on /mcp (comma-separated, one per caller)
TOKEN1=$(openssl rand -base64 32 | tr -d '=+/' | head -c 48)
az keyvault secret set --vault-name "$KV_NAME" --name clio-http-auth-tokens --value "$TOKEN1"
echo "Caller bearer token: $TOKEN1"

# 5. Shared Clio refresh token (one-time local bootstrap; see docs/oauth-setup.md)
node examples/bootstrap-refresh-token.mjs        # prints refresh_token=...
az keyvault secret set --vault-name "$KV_NAME" --name clio-refresh-token --value "<refresh_token>"
```

Restart the revision. The Container App reads `clio-refresh-token` (via
`CLIO_BOOTSTRAP_REFRESH_TOKEN`) on startup, mints an access token, and writes the
encrypted blob to `/state/tokens.enc`. In static mode `/readyz` returns 503 until
that shared account is authenticated.

Connect a client by pointing it at `${PUBLIC_BASE_URL}/mcp` with
`Authorization: Bearer <TOKEN1>`.

> Per-user OAuth used to be a future item; it is now the **default** mode (this
> document's main path). Static mode remains for the single-shared-account case.
