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.

Note

AKS is the supported compute platform on Azure. Definite installs via Helm and runs a Kubernetes operator for AI sandboxing, both of which need direct Kubernetes API access. Azure Container Apps and Azure Container Instances do not expose that access, so they 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
Hostnamehttps://<slug>.onprem.definite.app, issued by DefiniteYour own hostname, such as definite.yourco.com
TLSHandled by the Definite broker, no cert-manager neededYou bring a certificate, a private CA, or cert-manager plus Let's Encrypt
DNS recordsNone to create; the broker points the hostname at your load balancerYou create an A record to the load balancer IP
Setup flag--dns-mode definite-brokered (default)--dns-mode customer-owned --hostname <hostname>
Best forThe fastest path to a working, publicly reachable installPrivate 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.

Note

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

ResourceNotes
Resource groupHolds the AKS cluster, database, storage account, and networking
AKS clusterKubernetes 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 containerLakehouse data
(Optional) Google Workspace OAuth clientFor 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.


Prerequisites

Azure access

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>).

Local tooling

Install the following on the machine you'll run az and definite from:

ToolVersionCheck
azrecentaz version
kubectl1.28+kubectl version --client
helm3.12+helm version
jqrecentjq --version
LLM access

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 setup token

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).

(Optional) Customer-owned license

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>"             # 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 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 get nodes
Note

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.

Info

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
Note

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.

Warning

This bites hardest when Postgres and AKS end up in different regions (see region restrictions): the pods still route to Postgres over the public endpoint, so the firewall rule is mandatory. For production, place the Flexible Server in a delegated subnet on the same VNet as the AKS cluster (private access) instead, so traffic never traverses the public internet and no firewall rule is needed.

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.

Note

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 the https://<name>.openai.azure.com/ form of the endpoint in config.yaml. A Foundry resource also answers on .cognitiveservices.azure.com (which works) and .services.ai.azure.com (which does not), so always use the openai.azure.com host.

Note

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

2. Bootstrap cluster prerequisites

definite bootstrap --acme-email you@yourcompany.com

This installs an ingress controller, the agent-sandbox CRDs the Fi runtime needs, and (for the customer-owned DNS path only) cert-manager and the letsencrypt-prod ClusterIssuer. In the default brokered path you don't need cert-manager: the broker handles DNS and TLS for the *.onprem.definite.app hostname.

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.

Note

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"
Warning

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.

Note

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://definite:${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

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.

Note

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.

Warning

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
Note

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 doctor PUTs and deletes a small .preflight object 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.

  1. 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"
    
  2. 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"
    
  3. 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, and 1.

  4. 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 .parquet object with Bytes greater than zero, and the final comparison must exit successfully.

  5. 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.

  6. Run the remaining application smoke checks: confirm /auth/me returns the signed-in admin, run SELECT 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.

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: create Postgres in a different region (for example eastus2) and continue. You can check availability first with az postgres flexible-server list-skus --location <region> -o table (an empty/error result means try another region). If Postgres lands in a different region than AKS, remember the public-access firewall rule from Phase 1, step 3 is mandatory.

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.