Install on Azure
Stand up Definite in your own Azure account: AKS, Azure Database for PostgreSQL, and Azure Blob Storage, driven by az and the definite CLI.
This guide walks through a standard install: an AKS cluster, a Postgres Flexible Server, Azure Blob Storage, local authentication, Definite-brokered DNS and TLS, and Azure OpenAI. Use definite v0.1.89 or newer for Azure installs.
AKS Standard is the validated compute path for this guide. Definite installs via Helm and runs a Kubernetes operator for AI sandboxing, both of which need direct Kubernetes API access. The commands below create an AKS Standard cluster. AKS Automatic still exposes Kubernetes and Helm, but its managed node, networking, and scaling defaults are not covered by this guide. Use Standard unless you have validated Automatic with Definite for your environment. Azure Container Apps and Azure Container Instances do not expose the required access and are not supported.
Like AWS and GCP, the install uses Definite-brokered DNS and TLS by default: Definite provides a URL like https://<slug>.onprem.definite.app during setup, so you do not need to configure customer DNS before the first install. The setup token is also handled the same way as the other clouds. definite init proves control of your Azure tenant to the Definite broker with an Entra-issued access token (from your az login session, or the AKS workload's managed identity when you run it in-cluster) and the broker hands back a short-lived setup token automatically. No manual token step is required. If you'd rather not rely on automatic attestation, Definite can still issue a DEFINITE_ONPREM_SETUP_TOKEN by hand. See Definite setup token below.
DNS and TLS options
You have two ways to expose Definite and terminate TLS. Pick one before you run definite init.
| Definite-managed DNS (recommended) | Customer-owned DNS | |
|---|---|---|
| Hostname | https://<slug>.onprem.definite.app, issued by Definite | Your own hostname, such as definite.yourco.com |
| TLS | Handled by the Definite broker, no cert-manager needed | You bring a certificate, a private CA, or cert-manager plus Let's Encrypt |
| DNS records | None to create; the broker points the hostname at your load balancer | You create an A record to the load balancer IP |
| Setup flag | --dns-mode definite-brokered (default) | --dns-mode customer-owned --hostname <hostname> |
| Best for | The fastest path to a working, publicly reachable install | Private or internal hostnames, or strict domain requirements |
Definite-managed DNS is the default and the recommended starting point on Azure. Definite provides the *.onprem.definite.app hostname and TLS during setup, so you do not need to configure Azure DNS, request a certificate, or run cert-manager before the first install. You can move to a customer-owned hostname later, once the system is healthy.
Choose customer-owned DNS only if you need a private or internal hostname or your organization requires its own domain. The mechanics are covered in step 4.
Definite-managed DNS needs the cluster's load balancer to be reachable from the Definite broker so it can validate and route the brokered hostname. For a fully private or air-gapped cluster, use customer-owned DNS with an internal record and your own certificate instead.
What you'll create
| Resource | Notes |
|---|---|
| Resource group | Holds the AKS resource, database, storage account, and customer-managed networking |
| AKS node resource group | Created and managed by AKS for node VMs, load balancers, disks, and related infrastructure; defaults to MC_<resource-group>_<cluster>_<region> |
| AKS cluster | Kubernetes 1.28+ |
| Azure Database for PostgreSQL (Flexible Server) | Postgres 15+; this demo walkthrough uses public access with a narrow firewall rule |
| Azure Storage Account + Blob container | Lakehouse data |
| (Optional) Google Workspace OAuth client | For OIDC SSO. Google Workspace is the supported provider today; Entra ID support is on the roadmap. |
For production, use private networking and high availability according to your organization's Azure standards. Those configurations require additional VNet, subnet, private DNS, and endpoint work and are outside the scope of this guide.
Plan governed or private AKS before provisioning
The az aks create example below is intentionally a simple first-install path. In a governed subscription or a hub-and-spoke network, settle these choices before creating the cluster:
- Node resource group policy: AKS creates and owns a second resource group for node infrastructure. Required-tag or deny policies can block cluster creation, scaling, or upgrades. Allow AKS to manage this group and its Azure-created tags. You can choose its name at cluster creation with
--node-resource-group, but the name cannot be changed later and the target group must not already exist. See Microsoft's AKS node resource group guidance. - Address space and routing: Without explicit networking flags, Azure chooses the cluster network. That address space may overlap a corporate VNet, on-prem network, ExpressRoute route, or Virtual WAN route. For an enterprise deployment, create the VNet and node subnet first, then pass
--vnet-subnet-idand non-overlapping service/DNS CIDRs. Do not wait until after cluster creation; AKS cannot be moved to another VNet. - Private control plane and DNS: A private AKS cluster needs private DNS plus a route from the machine running
az,kubectl, anddefinite. If you use centralized DNS, link or forward the AKS private zone before installation. Microsoft's private AKS guide covers the supported DNS shapes. - Ingress and egress: Definite needs outbound access to pull images from
ghcr.io/definite-app, reach the chosen LLM provider, and connect to both internal and internet-hosted data sources. Definite-brokered DNS also needs a publicly reachable load balancer. For an internal-only deployment, use customer-owned DNS and TLS. If you later publish MCP through an Application Gateway or WAF, account for/mcp, its/.well-known/OAuth metadata, and/api/v1/mcp-oauth/*; exposing only/mcpis not enough for OAuth-based clients.
You can provision AKS through the Azure portal, Terraform, Bicep, or your platform team's standard module instead of the command below. Phase 2 only requires a conformant AKS cluster, a working kubeconfig, Helm access, and enough capacity for Definite's resource requests.
Prerequisites
A subscription where you have permission to create AKS, Postgres Flexible Server, and Storage Accounts. The az CLI must be authenticated (az login) and the subscription set (az account set --subscription <your-subscription-id>).
Install the following on the machine you'll run az and definite from:
| Tool | Version | Check |
|---|---|---|
az | recent | az version |
kubectl | 1.28+ | kubectl version --client |
kubelogin | recent | kubelogin --version (Entra-integrated AKS only) |
helm | 3.12+ | helm version |
jq | recent | jq --version |
Run az, kubectl, helm, and definite from the same shell and user profile. On WSL, do not mix a Windows Azure CLI kubeconfig with Linux kubectl unless you explicitly set the kubeconfig path as shown below.
Decide which LLM provider Fi will use. Azure OpenAI is the most common choice for Azure deployments because it lives in the same subscription. Anthropic, Bedrock, and Vertex are also supported. If you go with Azure OpenAI, make sure you've requested model access and created a deployment (the URL is your llm.endpoint).
definite init proves control of your cloud account to the Definite broker and the broker returns a short-lived setup token automatically. On Azure this attestation is an Entra-issued access token: the CLI mints one from your az login session (az account get-access-token), or from the AKS workload's managed identity via Azure IMDS when run in-cluster, and the broker verifies its signature, audience, expiry, and tenant before issuing the token. The signed-in principal just needs to belong to the Azure tenant; no extra Azure role or app registration is required. With that in place, definite init --dns-mode definite-brokered exchanges the token for your *.onprem.definite.app hostname and license and injects both into the install, so the deploy stays fully automated. If you prefer not to use automatic attestation, email hello@definite.app for a manual DEFINITE_ONPREM_SETUP_TOKEN and reference it from config.yaml instead (see Set or skip the setup token).
If your organization holds its own license instead of using the brokered flow, Definite issues you a key directly. It looks like onprem_ followed by a long hex string, and you reference it from config.yaml as license.key. Until the license is activated the deployment comes up unlicensed and returns HTTP 403 on every product API route.
Phase 1: Provision Azure infrastructure
Set up shared env vars first:
export RG="<your-resource-group>"
export LOCATION="<your-region>" # e.g. eastus
export CLUSTER_NAME="<your-cluster-name>" # e.g. definite
export PG_NAME="<your-pg-server>" # lowercase, e.g. acme-pg
export PG_USER="definite"
export STORAGE_ACCOUNT="<your-storage-acct>" # lowercase, 3-24 chars
export BLOB_CONTAINER="definite"
1. Create the resource group
az group create --name "$RG" --location "$LOCATION"
2. Create the AKS Standard cluster
Check both the regional and VM-family vCPU quotas before creating the pool. The standard profile below needs 20 DSv2-family vCPUs. Azure enforces the family quota even when your total regional quota is higher.
az vm list-usage --location "$LOCATION" \
--query "[?name.value=='cores' || name.value=='standardDSv2Family' || name.value=='standardDSv3Family'].{Quota:name.localizedValue,Used:currentValue,Limit:limit}" \
--output table
If either the regional quota or the selected VM-family quota is too low, request an increase before provisioning or use the pilot profile below.
az aks create \
--resource-group "$RG" \
--name "$CLUSTER_NAME" \
--node-count 5 \
--node-vm-size Standard_DS3_v2 \
--enable-managed-identity \
--generate-ssh-keys
az aks get-credentials --resource-group "$RG" --name "$CLUSTER_NAME"
kubectl config current-context
kubectl get nodes
Do not continue until the current context is the intended AKS cluster and all expected nodes are Ready. If kubectl tries http://localhost:8080, there is no usable current context; definite bootstrap would target the same invalid endpoint.
For an Entra-integrated cluster, convert the kubeconfig to Azure CLI authentication before testing it:
kubelogin convert-kubeconfig -l azurecli
kubectl get nodes
If Azure CLI and kubectl disagree about the kubeconfig location, which is common when using WSL, install the Linux Azure CLI inside WSL and force both tools to the Linux path:
mkdir -p "$HOME/.kube"
export KUBECONFIG="$HOME/.kube/config"
az aks get-credentials \
--resource-group "$RG" \
--name "$CLUSTER_NAME" \
--file "$KUBECONFIG" \
--overwrite-existing
kubelogin convert-kubeconfig -l azurecli # Entra-integrated clusters only
kubectl config current-context
kubectl get nodes
Size the pool for the chart's requests. The default resources.api alone (2 replicas x 1 CPU guaranteed) plus frontend, job-runner, and the Fi warm pool do not fit on the old demo default of 3 x Standard_DS2_v2 (2 vCPU each), and definite-api pods sit Pending on Insufficient cpu. Use at least 5 nodes and a Standard_DS3_v2 (4 vCPU) SKU as above, or lower the resources.* requests in config.yaml for a smaller demo. Check headroom with kubectl describe node | grep -A5 Allocated and kubectl get pods -n definite after install.
Pilot profile: 5 x Standard_D2s_v3 nodes (10 DSv3-family vCPUs) together with the reduced resources block in Build config.yaml. This profile is for a demo or pilot only. It reduces replica count and resource requests and is not a production sizing recommendation.
# Demo or pilot only. Use with the reduced resources block below.
az aks create \
--resource-group "$RG" \
--name "$CLUSTER_NAME" \
--node-count 5 \
--node-vm-size Standard_D2s_v3 \
--enable-managed-identity \
--generate-ssh-keys
3. Create Azure Database for PostgreSQL (Flexible Server)
az postgres flexible-server create \
--resource-group "$RG" \
--name "$PG_NAME" \
--location "$LOCATION" \
--version 15 \
--admin-user "$PG_USER" \
--admin-password "<your-postgres-password>" \
--tier GeneralPurpose \
--sku-name Standard_D2ds_v4 \
--yes
az postgres flexible-server db create \
--resource-group "$RG" \
--server-name "$PG_NAME" \
--name definite
# Definite's schema migrations depend on pgcrypto, which Flexible Server blocks
# until you allow-list it. Enable it before the first install or migrations fail.
az postgres flexible-server parameter set \
--resource-group "$RG" \
--server-name "$PG_NAME" \
--name azure.extensions \
--value pgcrypto
On recent az (2.80+) the database name flag is --name/-n; older versions used --database-name. If you see unrecognized arguments: --database-name, upgrade az or swap the flag.
The migration itself runs CREATE EXTENSION pgcrypto; the parameter above only permits it. No server restart is required.
If you created the Flexible Server with public access (the demo default above), flexible-server create auto-allows only the outbound IP of the machine running the CLI. The AKS cluster reaches Postgres from a different IP, so add a firewall rule for the cluster's outbound IP, or definite-api and definite-job-runner will CrashLoopBackOff on Postgres connection timeouts:
# Read the effective outbound public IP resource from the AKS load balancer
# profile. Do not search public IPs by name because Azure may use a UUID.
AKS_OUTBOUND_IP_ID=$(az aks show \
--resource-group "$RG" \
--name "$CLUSTER_NAME" \
--query 'networkProfile.loadBalancerProfile.effectiveOutboundIPs[0].id' \
--output tsv)
AKS_OUTBOUND_IP=$(az network public-ip show \
--ids "$AKS_OUTBOUND_IP_ID" \
--query ipAddress \
--output tsv)
az postgres flexible-server firewall-rule create \
--resource-group "$RG" \
--server-name "$PG_NAME" \
--name allow-aks-outbound \
--start-ip-address "$AKS_OUTBOUND_IP" \
--end-ip-address "$AKS_OUTBOUND_IP"
The firewall flags above match Azure CLI 2.87: --server-name identifies the Flexible Server, and --name identifies the firewall rule.
This bites hardest when the demo command leaves Postgres on public access and Postgres and AKS end up in different regions (see region restrictions): the public-endpoint firewall rule is still mandatory. Private access across regions is also possible, but you must provide VNet peering or Virtual WAN routing, private DNS resolution, and an allowed path from AKS to the delegated Postgres subnet. For production, prefer private access so traffic never traverses the public internet.
4. Create a Storage Account + Blob container
az storage account create \
--resource-group "$RG" \
--name "$STORAGE_ACCOUNT" \
--location "$LOCATION" \
--sku Standard_LRS \
--kind StorageV2 \
--allow-blob-public-access false
# Grab a storage account key (the lakehouse uses a single shared key, not an
# HMAC pair like S3/GCS).
STORAGE_KEY=$(az storage account keys list \
--resource-group "$RG" \
--account-name "$STORAGE_ACCOUNT" \
--query '[0].value' --output tsv)
az storage container create \
--account-name "$STORAGE_ACCOUNT" \
--name "$BLOB_CONTAINER" \
--account-key "$STORAGE_KEY"
5. (If using Azure OpenAI) Create the resource and a model deployment
If Fi will use Azure OpenAI (the common Azure choice), provision the account and a model deployment. The config only references these; you have to create them here.
Create the account as an Azure AI Foundry resource with --kind AIServices. That is where Microsoft launches new models and what most Azure shops standardize on. The classic --kind OpenAI account also works with the exact same commands below. On an AIServices account, --allow-project-management defaults to true, so no extra flag is needed, and the model endpoint is https://<name>.openai.azure.com/ for both kinds. This path is verified end to end: Definite resolves and runs gpt-5.6 models deployed on a Foundry resource with either the GlobalStandard or DataZoneStandard SKU.
Use gpt-5.6-luna as the default Fi model: it is cost-effective and fast, and works well with Fi's guardrails. Step up to gpt-5.6-terra when answers need more depth, and reserve gpt-5.6-sol for the hardest workloads. Both the GlobalStandard and DataZoneStandard SKUs work; DataZoneStandard keeps data processing in-geo.
Definite authenticates to the model endpoint with an API key, so key-based authentication must remain enabled on the resource. Entra-only (key-disabled) auth configurations are not supported for this connection today.
Availability of new model families is phased and specific to the subscription and region. Before deploying, verify that the exact model, version, and SKU quota appear in the account catalog. Do not assume another subscription's availability applies.
export AOAI_NAME="<your-aoai-resource>" # e.g. acme-aoai
# Create the account as an Azure AI Foundry (AIServices) resource.
# --kind OpenAI works too, with the same commands. --allow-project-management
# defaults to true on AIServices, so no extra flag is needed.
az cognitiveservices account create \
--resource-group "$RG" \
--name "$AOAI_NAME" \
--location "$LOCATION" \
--kind AIServices \
--sku S0 \
--custom-domain "$AOAI_NAME"
# List the phased 5.6 family so you can confirm the model, its version, and SKUs.
az cognitiveservices account list-models \
--resource-group "$RG" \
--name "$AOAI_NAME" \
--query "[?format=='OpenAI' && starts_with(name, 'gpt-5.6')].{Model:name,Version:version,Lifecycle:lifecycleStatus,SKUs:join(',', skus[].name)}" \
--output table
# Default Fi deployment. The deployment name must match the model name because
# Fi uses it as the model id. Use GlobalStandard, or DataZoneStandard to keep
# processing in-geo.
export AOAI_MODEL="gpt-5.6-luna"
export AOAI_SKU="GlobalStandard"
export AOAI_CAPACITY="50"
# Derive the available model version from the catalog instead of pinning one.
AOAI_MODEL_VERSION=$(az cognitiveservices account list-models \
--resource-group "$RG" \
--name "$AOAI_NAME" \
--query "[?name=='$AOAI_MODEL'] | [0].version" \
--output tsv)
if [[ -z "$AOAI_MODEL_VERSION" ]]; then
echo "$AOAI_MODEL is not in the catalog for $AOAI_NAME; request access or pick another 5.6 model"
exit 1
fi
# Confirm the chosen model and SKU have nonzero quota before deploying.
AOAI_QUOTA_LIMIT=$(az cognitiveservices usage list \
--location "$LOCATION" \
--query "[?name.value=='OpenAI.$AOAI_SKU.$AOAI_MODEL'] | [0].limit" \
--output tsv)
if [[ -z "$AOAI_QUOTA_LIMIT" || "$AOAI_QUOTA_LIMIT" == "0" || "$AOAI_QUOTA_LIMIT" == "0.0" ]]; then
echo "$AOAI_MODEL $AOAI_SKU quota is unavailable in $LOCATION"
exit 1
fi
az cognitiveservices account deployment create \
--resource-group "$RG" \
--name "$AOAI_NAME" \
--deployment-name "$AOAI_MODEL" \
--model-name "$AOAI_MODEL" \
--model-version "$AOAI_MODEL_VERSION" \
--model-format OpenAI \
--sku-name "$AOAI_SKU" \
--sku-capacity "$AOAI_CAPACITY"
# properties.endpoint can return the cognitiveservices or services.ai host on AIServices accounts; Definite needs the openai.azure.com form, which --custom-domain guarantees exists.
AOAI_ENDPOINT="https://${AOAI_NAME}.openai.azure.com"
AOAI_KEY=$(az cognitiveservices account keys list \
--resource-group "$RG" --name "$AOAI_NAME" \
--query key1 --output tsv)
echo "$AOAI_ENDPOINT"
--sku-capacity is in units of 1,000 tokens per minute. A single Fi run uses roughly 20k tokens, so a capacity of 10 will rate-limit immediately under real use; start at 50 or higher and tune down deliberately.
These map to config.yaml as llm.endpoint ($AOAI_ENDPOINT), llm.deployment ($AOAI_MODEL), and the AZURE_OPENAI_API_KEY env var ($AOAI_KEY). Use only the https://<name>.openai.azure.com/ base endpoint in config.yaml. Do not paste a model invocation URL containing /openai/deployments/..., /responses, or an api-version query string. A Foundry resource also answers on .cognitiveservices.azure.com (which works) and .services.ai.azure.com (which does not), so prefer the openai.azure.com host.
A catalog entry and quota are separate checks. A model can appear in list-models while the subscription has zero usable quota for its SKU. If gpt-5.6-luna is absent or its selected-SKU limit is zero, stop and request access or quota. To use gpt-5.6-terra or gpt-5.6-sol instead, change AOAI_MODEL and repeat the catalog and quota checks for that exact model.
Verify the deployment with a real inference request before installing Definite. definite doctor reports the configured Azure OpenAI endpoint and deployment, but it does not call Azure OpenAI in the current release.
AOAI_TEST_RESPONSE=$(curl --fail-with-body --silent --show-error \
--request POST \
--header "Content-Type: application/json" \
--header "api-key: $AOAI_KEY" \
--data '{"messages":[{"role":"user","content":"Reply exactly azure-openai-ok"}],"max_completion_tokens":64}' \
"${AOAI_ENDPOINT%/}/openai/deployments/${AOAI_MODEL}/chat/completions?api-version=2025-01-01-preview")
echo "$AOAI_TEST_RESPONSE" | jq -e \
'.choices[0].message.content | type == "string" and length > 0'
The final command must return true. Do not continue if the request returns an authorization, quota, model, or deployment error.
6. Choose authentication
Use local authentication for the Azure install and bootstrap the initial admin in config.yaml. Google Workspace SSO is also supported and is cloud-agnostic; configure it after the local-auth deployment is healthy by following Google SSO.
Google Workspace is the only OIDC provider supported today; Entra ID and other generic OIDC providers are on the roadmap. If your organization is Entra-first, use local auth for now and switch to SSO when Entra support lands. Do not create an Entra app registration or configure an Entra issuer in the current release.
Phase 2: Install Definite with the definite CLI
1. Install the CLI
curl -fsSL https://storage.googleapis.com/definite-public/definite-onprem/install.sh | sh
definite version
Run definite version before relying on Azure Cloud Shell as the install host. If it reports a missing GLIBC_2.39 symbol, use Ubuntu 24.04, WSL2 with Ubuntu 24.04, the Windows CLI, or a management VM instead. That error is a local binary/runtime mismatch, not an AKS or firewall failure.
2. Confirm the target cluster and bootstrap prerequisites
Recheck the context even if AKS was created earlier or through the portal:
az account show --query '{subscription:name,subscriptionId:id,tenantId:tenantId}' --output table
kubectl config current-context
kubectl get nodes
Then bootstrap the cluster:
definite bootstrap
By default this installs an ingress controller, cert-manager plus the letsencrypt-prod ClusterIssuer, and the agent-sandbox CRDs the Fi runtime needs. The default brokered DNS path does not use cert-manager because the broker handles DNS and TLS for the *.onprem.definite.app hostname; leaving it installed is harmless, or you can omit it with definite bootstrap --skip-cert-manager.
--acme-email is optional and only sets the Let's Encrypt contact on the letsencrypt-prod ClusterIssuer. It is not the Definite initial admin, an Azure service principal, or an Entra identity. On the customer-owned DNS path, set it explicitly with definite bootstrap --acme-email ops@yourcompany.com if you use Let's Encrypt.
3. Set or skip the setup token
On the default path you can skip this step. As long as you're signed in with az login (or running inside an AKS workload that has a managed identity), definite init mints an Entra access token and the broker hands back a setup token automatically, just like AWS and GCP. Confirm you're authenticated:
az account show # should print your subscription and tenant
Only if you'd rather supply a Definite-issued token by hand (no automatic attestation), email hello@definite.app for a short-lived token and export it:
# Optional manual override: skip on the automatic path above.
export DEFINITE_ONPREM_SETUP_TOKEN="<token-from-definite>"
The manual token is short-lived, so request it close to when you'll run definite init. When set, config.yaml references it by env var name (see the broker block below) and the CLI uses it instead of attesting; the raw token never needs to live in the file.
The CLI requests the Entra token for the Azure Resource Manager audience (https://management.azure.com) by default, which any az login session or AKS managed identity can mint without admin consent. To scope the token to a dedicated Definite Entra app instead, set DEFINITE_ONPREM_AZURE_ATTESTATION_RESOURCE to that app's Application ID URI (coordinate with Definite so the broker accepts it).
4. Let definite init request Definite-brokered DNS
The ingress controller provisions an Azure Standard Load Balancer. Wait for it to land, then grab its external IP:
LB_IP=$(kubectl get svc ingress-nginx-controller -n ingress-nginx \
-o jsonpath='{.status.loadBalancer.ingress[0].ip}'
)
echo "$LB_IP"
The Azure LB health probe must return a 200, or Azure silently drops all external traffic. By default the Standard Load Balancer probes the data-plane port at / with no Host header; nginx answers with a non-200 (a 404 from the default backend, or a 308 HTTPS redirect), so Azure marks every node unhealthy and drops inbound SYNs with no RST. Pods stay Ready and in-cluster curls succeed, so this is easy to misread as an app or Azure-networking problem.
On definite v0.1.69+ this is handled for you: definite bootstrap sets controller.service.externalTrafficPolicy=Local on AKS, which makes Kubernetes publish a dedicated healthCheckNodePort that Azure wires the probe to (and it returns real 200s). If you ran an older CLI, apply the probe path manually instead:
kubectl annotate svc ingress-nginx-controller -n ingress-nginx \
service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path=/healthz \
--overwrite
For the default path, don't stop here to create DNS. definite init --dns-mode definite-brokered calls the Definite setup broker for you, using the ingress target above, and returns a URL like https://<slug>.onprem.definite.app. It acquires the setup token automatically from your az credentials, or uses DEFINITE_ONPREM_SETUP_TOKEN if you set one.
Customer-owned DNS is supported as an advanced option. If you explicitly need a private/internal hostname, create an A record from your hostname to $LB_IP, keep cert-manager from definite bootstrap (or supply a custom cert/private CA), and use the released CLI's customer-owned mode: --dns-mode customer-owned --hostname <hostname>. Don't use nip.io as a default first-run path.
5. Build config.yaml
Start from the AKS example config: minimal-aks.yaml. The shape:
deployment:
name: definite
namespace: definite
dns_mode: definite-brokered # broker handles DNS + TLS
# On the default path you can omit the `broker` block entirely: the CLI attests
# your Azure identity and acquires the setup token automatically. Add it only to
# supply a Definite-issued token by hand (manual override):
# broker:
# setupToken:
# env: DEFINITE_ONPREM_SETUP_TOKEN
# In the brokered flow the broker injects the license during init, so no license
# block is needed. For customer-owned/manual mode instead, set your own key:
# license:
# key: !env LICENSE_KEY # your Definite-issued onprem_... key
postgres:
url: postgres://<postgres-admin-user>:${POSTGRES_PASSWORD}@<pg-server>.postgres.database.azure.com:5432/definite
object_store:
type: azure
account: <your-storage-account>
container: definite
credentials:
env: AZURE_STORAGE_KEY
lakehouse:
prefix: lake/
storage:
size: 50Gi
storage_class_name: managed-csi-premium
auth:
mode: local
initial_admin_email: <admin@your-domain>
initial_admin_password:
env: INITIAL_ADMIN_PASSWORD
llm:
provider: azure_openai
endpoint: https://<your-aoai-resource>.openai.azure.com
deployment: gpt-5.6-luna # default Fi model and Azure deployment name
api_key:
env: AZURE_OPENAI_API_KEY
resources:
api:
replicas: 2
cpu: "1"
memory: 2Gi
lakehouse:
replicas: 1
cpu: "4"
memory: 16Gi
Secret references and string interpolation look similar but behave differently:
env: AZURE_STORAGE_KEYcontains the environment variable name, not the storage key. Keep the name in YAML and export the actual value in the same shell beforedoctorandinit.${POSTGRES_PASSWORD}is expanded when the CLI readsconfig.yaml. Replace<postgres-admin-user>and<pg-server>, but keep the password out of the file.- Apply the same rule to
INITIAL_ADMIN_PASSWORDandAZURE_OPENAI_API_KEY. Do not replace theirenv:values with plaintext secrets.
For the 5 x Standard_D2s_v3 demo or pilot pool, replace the resources block above with this reduced profile:
resources:
api:
replicas: 1
cpu: 500m
memory: 1Gi
lakehouse:
replicas: 1
cpu: "1"
memory: 4Gi
frontend:
replicas: 1
cpu: 250m
memory: 512Mi
job_runner:
replicas: 1
cpu: 250m
memory: 512Mi
This profile has no application-level redundancy and is not suitable for production.
Azure uses a single shared storage key (not an HMAC pair). Pull it from the Storage Account's "Access keys" blade, or via az storage account keys list as shown above.
Name your Azure OpenAI deployment exactly after the selected supported OpenAI model id, and use that same name in llm.deployment. Fi uses the deployment name as the model id when it calls Azure OpenAI, so a custom deployment name such as prod-model fails as an unknown model. Fi does not run on Claude models through Azure OpenAI. To use Claude, choose the anthropic, bedrock, or vertex provider instead.
For the full list of knobs, see the config reference in Definite's built-in docs.
6. Export secrets
export POSTGRES_PASSWORD="<your-postgres-password>"
export AZURE_STORAGE_KEY="<storage-key-from-step-4>"
export AZURE_OPENAI_API_KEY="<your-aoai-api-key>"
export INITIAL_ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | head -c 24)"
# export DEFINITE_ONPREM_SETUP_TOKEN="<token-from-definite>" # only for the manual token override
# export LICENSE_KEY="onprem_..." # only for customer-owned/manual mode
In the default brokered flow you do not export DEFINITE_ONPREM_SETUP_TOKEN or LICENSE_KEY: the CLI attests your Azure identity for the setup token, and the broker injects the license during init. Set DEFINITE_ONPREM_SETUP_TOKEN only if you're using the manual token override, and LICENSE_KEY only if you chose customer-owned/manual mode.
7. Preflight and deploy
definite doctor --config config.yaml
definite init --config config.yaml --dns-mode definite-brokered
If your Postgres Flexible Server is on private access, definite doctor and the definite init preflight cannot reach it from a laptop without a route into the VNet. Run doctor from inside the network. If Postgres reachability is the only failed check, you may pass --skip-preflight to the laptop-run definite init; do not use it to mask any other failure.
definite doctor performs a real Azure Blob PUT and DELETE of a small .preflight object. That proves the configured key can write to the container, but it does not exercise DuckDB's Azure extension or prove that a lakehouse query creates Parquet data. Complete the physical Blob verification in Verify the deployment after install.
init renders the bundled Helm chart and runs helm upgrade --install. Watch the rollout:
definite status --config config.yaml
definite logs api --follow
8. Confirm the initial admin
The auth.initial_admin_email and auth.initial_admin_password.env fields in config.yaml create the initial admin during the first API startup. Save INITIAL_ADMIN_PASSWORD in a password manager before closing the install shell. Do not patch the API Deployment with kubectl set env; a later Helm upgrade can discard manual Deployment changes.
When the brokered URL reports DNS/TLS ready, open https://<slug>.onprem.definite.app in a browser and log in with the admin email and password from config.yaml. On the customer-owned path, open your own hostname once its certificate is ready.
Known limitations on Azure
A few Azure-specific things to know before you start:
definite run load(file upload via the CLI) is documented as not supported on Azure in v1; S3, GCS, and MinIO backends are. Browser-based Drive uploads still work via the configured Storage Account.definite doctorPUTs and deletes a small.preflightobject in Azure Blob Storage. It does not exercise the DuckDB Azure extension, create a DuckLake table, or verify a Parquet object. Run the lakehouse smoke test below after deployment.- Azure OpenAI preflight is warn-and-skip in the current CLI. Run the direct inference request in Phase 1 and a real Fi prompt after deployment.
- Workload Identity / Entra-bound credentials for the lakehouse are not yet wired in; the storage account key is the supported path today.
Verify the deployment
Do not declare the install healthy based only on ready pods or SELECT 1. Complete every check below, including the physical Blob Storage check.
-
Confirm all pods are ready, HTTPS works, and the initial admin can sign in:
definite status --config config.yaml curl --fail --silent --show-error --output /dev/null \ "https://<your-brokered-hostname>/login" -
Record the number of objects under the lakehouse prefix before the write:
BLOB_COUNT_BEFORE=$(az storage blob list \ --account-name "$STORAGE_ACCOUNT" \ --container-name "$BLOB_CONTAINER" \ --account-key "$AZURE_STORAGE_KEY" \ --prefix lake/ \ --num-results '*' \ --query 'length(@)' \ --output tsv) echo "Blob objects before: $BLOB_COUNT_BEFORE" -
In Definite's query editor, create and read back a persistent table with more than 1,000 rows. This row count is intentional because it forces the test past the small-result inlining threshold:
DROP TABLE IF EXISTS azure_lakehouse_smoke; CREATE TABLE azure_lakehouse_smoke AS SELECT row_id, 'azure-blob-smoke' AS marker FROM range(1500) AS rows(row_id); SELECT COUNT(*) AS row_count, MIN(row_id) AS first_row, MAX(row_id) AS last_row, COUNT(DISTINCT marker) AS marker_count FROM azure_lakehouse_smoke;The result must be
1500,0,1499, and1. -
Prove that the write created a nonempty Parquet object in Azure Blob Storage. SQL success alone is not sufficient:
BLOB_COUNT_AFTER=$(az storage blob list \ --account-name "$STORAGE_ACCOUNT" \ --container-name "$BLOB_CONTAINER" \ --account-key "$AZURE_STORAGE_KEY" \ --prefix lake/ \ --num-results '*' \ --query 'length(@)' \ --output tsv) az storage blob list \ --account-name "$STORAGE_ACCOUNT" \ --container-name "$BLOB_CONTAINER" \ --account-key "$AZURE_STORAGE_KEY" \ --prefix lake/main/azure_lakehouse_smoke/ \ --num-results '*' \ --query "[?ends_with(name, '.parquet')].{Name:name,Bytes:properties.contentLength}" \ --output table test "$BLOB_COUNT_AFTER" -gt "$BLOB_COUNT_BEFORE"The table output must include at least one
.parquetobject withBytesgreater than zero, and the final comparison must exit successfully. -
Clean up the logical smoke-test table:
DROP TABLE azure_lakehouse_smoke;DuckLake can retain the physical Parquet object for snapshot history after the logical table is dropped. That retention is expected and preserves evidence that the data reached Blob Storage.
-
Run the remaining application smoke checks: confirm
/auth/mereturns the signed-in admin, runSELECT 1 AS ok, open the data apps page, and start a new Fi thread that produces a successful response through Azure OpenAI.
If any check fails, capture definite status --config config.yaml and definite logs api --tail 200 before changing the deployment.
Troubleshooting
Real issues we and early Azure installers have hit, with the fix for each.
definite bootstrap connects to localhost:8080
Symptom: definite bootstrap or kubectl get nodes fails with connect: connection refused against 127.0.0.1:8080 or localhost:8080.
Cause: kubectl has no current AKS context, or az aks get-credentials wrote the kubeconfig for a different OS/user profile. This is common when Windows Azure CLI is invoked from WSL while Linux kubectl reads $HOME/.kube/config.
Fix: repeat Create the AKS Standard cluster, force az and kubectl to the same KUBECONFIG, run kubelogin convert-kubeconfig -l azurecli for an Entra-integrated cluster, and verify kubectl get nodes before retrying bootstrap.
Azure Cloud Shell reports GLIBC_2.39 not found
Symptom: the install script succeeds in Azure Cloud Shell, but definite version fails immediately with a missing glibc symbol.
Cause: that Cloud Shell image is older than the runtime used by the current Linux release binary.
Fix: run the CLI from Ubuntu 24.04, WSL2 with Ubuntu 24.04, the Windows binary, or a management VM that can reach the AKS API. Keep az, kubectl, helm, and definite in that same environment.
Lakehouse writes fail on definite releases before v0.1.88
Symptom: on a release before v0.1.88, ordinary SQL can work, but creating a persistent table either reports an invalid Azure storage URI or fails with Problem with the SSL CA cert. No nonempty Parquet object appears in the Blob container.
Cause: those releases render the wrong Azure lakehouse URI shape and their runtime images do not expose the CA bundle at the path used by DuckDB's Azure client. Both were fixed in v0.1.88.
Fix: upgrade to definite v0.1.89 or newer, then complete Verify the deployment. Do not patch a customer cluster by hand or treat SELECT 1 as a successful storage test.
Postgres migrations fail on a blocked pgcrypto extension
Azure Database for PostgreSQL Flexible Server blocks most extensions by default, and Definite's schema migrations depend on pgcrypto. Allow-list it before the first install (this is folded into Phase 1, step 3):
az postgres flexible-server parameter set \
--resource-group "$RG" \
--server-name "$PG_NAME" \
--name azure.extensions \
--value pgcrypto
The migration runs CREATE EXTENSION pgcrypto itself; the parameter only permits it. No server restart is required.
External traffic drops and Azure marks every backend unhealthy
Symptom: the external IP stops serving traffic and connections to ports 80/443 time out with no RST, even though kubectl get pods / definite status are all green and a kubectl exec curl to the IP from inside the cluster succeeds. (The in-cluster success is a kube-proxy shortcut that never exercises the real LB path.) This is the single most confusing AKS failure: it looks like an app bug or an Azure subscription/NSG block, but it is a Kubernetes-level probe misconfiguration.
Cause: the Azure Standard Load Balancer health-probes the ingress data-plane port at / by default. nginx returns a non-200 for that probe (a 404 from the default backend, or a 308 HTTPS redirect), so Azure marks every node unhealthy and silently drops all inbound traffic.
Fix: upgrade to definite v0.1.69+ and re-run definite bootstrap, which sets controller.service.externalTrafficPolicy=Local (a dedicated healthCheckNodePort Azure probes with real 200s). On older CLIs, point the probe at nginx's health endpoint by annotating the controller Service (also covered in Phase 2, step 4):
# Either set externalTrafficPolicy=Local (preferred):
kubectl patch svc ingress-nginx-controller -n ingress-nginx \
-p '{"spec":{"externalTrafficPolicy":"Local"}}'
# ...or annotate the probe path:
kubectl annotate svc ingress-nginx-controller -n ingress-nginx \
service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path=/healthz \
--overwrite
Postgres creation is rejected in a region
Symptom: az postgres flexible-server create fails with The location is restricted from performing this operation ... open a support request (commonly seen in eastus).
Cause: Flexible Server capacity is restricted per subscription/region and can be unavailable even when other services in that region work.
Fix: open an Azure support request for the subscription/region restriction, or create Postgres in a different region (for example eastus2) and continue. az postgres flexible-server list-skus --location <region> -o table is a useful first check, but a populated SKU list or unused vCore quota does not prove that the subscription may provision there. The create request is authoritative.
If Postgres lands in a different region than AKS, choose one connectivity path explicitly: add the public-access firewall rule from Phase 1, step 3, or keep Postgres private and configure cross-region VNet peering or Virtual WAN, private DNS, and routes. Validate the exact path from AKS before installing. For a production move back to the preferred region, restore or dump the Postgres database into a new Flexible Server and update the Definite config during the cutover.
Retrying Postgres create fails with "server name is already used"
Symptom: after a transient InternalServerError on flexible-server create, retrying with the same name fails with Specified server name is already used, but az postgres flexible-server list doesn't show the server.
Cause: the failed attempt left the resource in a Dropping state that isn't immediately visible, and the name stays reserved until the drop completes.
Fix: retry under a new server name (fastest), or wait a few minutes for the drop to finish and reuse the name.
definite doctor fails the Postgres check against a TLS-required server
Symptom: doctor reports a Postgres connect/TLS handshake failure even though the database is reachable and correct (a manual psql "host=... sslmode=require" connects fine).
Cause: Azure Postgres Flexible Server enforces SSL by default, and the v0 doctor Postgres check connects without TLS, producing a false negative.
Fix: upgrade to the latest definite CLI, whose Postgres preflight now negotiates TLS and honors sslmode=require. On an older CLI, verify reachability manually with psql "host=<pg>.postgres.database.azure.com user=definite dbname=definite sslmode=require"; if that succeeds the DB is fine, so pass --skip-preflight to definite init for this laptop run only (don't use it to mask other failures).
cert-manager keeps a 2-hour backoff after a failed certificate (customer-owned DNS)
Symptom: on the customer-owned DNS path, after a Certificate fails twice you see a ~2-hour backoff, and deleting the failed CertificateRequest/Secret doesn't trigger a fresh attempt.
Cause: cert-manager tracks the backoff on the parent Certificate object, so recreating child resources doesn't reset the timer.
Fix: delete the Certificate itself and let ingress-shim recreate it, which forces an immediate clean retry:
kubectl delete certificate <name> -n definite
# ingress-shim recreates it from the Ingress annotation within seconds
Every Fi run fails with "model is not known to Pi" on Azure OpenAI
Symptom: Fi runs fail immediately with configured LLM model is not known to Pi, even though config.yaml correctly sets llm.provider: azure_openai and llm.deployment to the selected Azure OpenAI model.
Cause: Fi's runtime model selection is driven by the fi_model_config row in the app database, not the chart env vars. On older builds that row was always seeded with the platform default (default_provider = anthropic, default_model = claude-sonnet-5) regardless of config.yaml, so on a fresh Azure OpenAI install the stale Anthropic model got grafted onto the Azure provider, producing the impossible azure-openai-responses/claude-sonnet-5 pair.
Fix: upgrade to the latest chart. On install it now reconciles the untouched fi_model_config row to your configured llm.provider / llm.deployment, so Azure OpenAI works out of the box and Settings → Models shows the right model (an admin's later Settings change is still preserved). If you're on an older build, correct the row directly (the provider value must match the app's internal enum exactly, azureOpenai in camelCase, not azure_openai):
UPDATE fi_model_config
SET default_provider = 'azureOpenai', default_model = '<your-AOAI-model>'
WHERE id = 1;
There is no definite CLI command for this; on older builds it's reachable only via a direct DB write or the in-app Settings → Models page.
Fi fails with "Azure OpenAI base URL is required"
Symptom: Fi runs fail with failed to publish Pi events: Azure OpenAI base URL is required.
Cause: the Fi sandbox pods got AZURE_OPENAI_ENDPOINT, but pi-ai's azure-openai-responses provider reads AZURE_OPENAI_BASE_URL (or AZURE_OPENAI_RESOURCE_NAME).
Fix: upgrade to the latest chart, which sets AZURE_OPENAI_BASE_URL on every Fi SandboxTemplate. On an older build, patch the templates (and roll existing pods, see below) to add AZURE_OPENAI_BASE_URL alongside AZURE_OPENAI_ENDPOINT.
SandboxTemplate env changes don't reach running Fi pods
Symptom: you edited a Fi SandboxTemplate (for example to add an env var) but Fi still behaves as before.
Cause: a live SandboxTemplate edit is not retroactive. Warm-pool pods created before the edit keep their old env, and once a pod is bound by a SandboxClaim it stays pinned to that thread for the retention window, so deleting pods alone can leave a stale claimed instance behind.
Fix: delete both the sandbox pods and any active SandboxClaim objects, then verify each pool member's env before considering it clean:
kubectl delete sandboxclaim -n definite --all # release pinned pods
kubectl delete pod -n definite -l app.kubernetes.io/component=fi-sandbox
# the warm pool replenishes from the current template; then spot-check:
kubectl get pod -n definite -l app.kubernetes.io/component=fi-sandbox \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.containers[0].env}{"\n"}{end}'
The initial admin account isn't created
Symptom: you can reach the login page, but no admin account exists.
Cause: the config field is initial_admin_email (not admin_email), and the password must be supplied as a secret reference rather than inline.
Fix: follow Confirm the initial admin. Set auth.initial_admin_email and auth.initial_admin_password.env in config.yaml before the first install.
Day-2 operations
Same CLI as AWS and GCP:
definite status --config config.yaml
definite logs api --follow
definite upgrade --config config.yaml
definite license status
definite run maintenance stats
Definite's built-in docs include a full CLI reference.
Phase 3: Teardown
# Trigger Kubernetes to delete the Azure Standard Load Balancer cleanly
# before the resource group goes away.
kubectl delete svc ingress-nginx-controller -n ingress-nginx --ignore-not-found
az group delete --name "$RG" --yes
Deleting the resource group removes the AKS cluster, Postgres server, storage account, and everything else in one shot. Snapshot or export anything you care about first. Deleting the ingress Service first lets Kubernetes release the cloud LB cleanly; az group delete will pick up anything you miss, but the explicit order avoids a stuck LB you'd otherwise have to clean up by hand.
Support
This install path is actively being matured. For issues or questions, contact hello@definite.app. If you're planning an Azure install, we'd love to be in the room: design-partner support is free.