Install on Azure
Admin guide
Greenlight installs into an Azure subscription you control. Plan on about 10–15 minutes for infrastructure provisioning and another 15–20 minutes for the install wizard.
This page covers Azure. See Install on GCP or Install on AWS for the equivalent deployment in those clouds.
What Shift provides
Section titled “What Shift provides”Your Shift implementation contact delivers the approved release with a private, versioned ARM template URL and its release instructions. The release carries the compiled Bicep deployment, pinned Kubernetes assets, release metadata, and access to the Greenlight container images for that version. The public docs describe the resources and permissions without publishing the template URL or image access details.
What you need before you start
Section titled “What you need before you start”- An Azure subscription and a dedicated Greenlight resource group. You do not need to know which region to use, or whether it will work: the installer lists only the regions your subscription can use that offer PostgreSQL 16, the Kubernetes version this release runs, and the node sizes it needs, and checks the one you pick before anything is created. A region Azure does not answer for is left off the list and counted on screen, so you can still type it. Some subscription types — sponsorships and certain enterprise agreements — restrict which regions and VM sizes they may use, so the answer differs between two subscriptions looking at the same region.
- A resource group that holds nothing but Greenlight. Greenlight’s updater identity holds Contributor and Role Based Access Control Administrator across the whole group, so anything else in it would be within its reach. Let the installer create the group, or create an empty one beforehand: the installer offers an existing group only when it holds nothing else, and a first install into a group that does stops before anything is created.
- A provisioning identity with Owner, or Contributor plus Role Based Access Control Administrator, on that resource group. Resource-provider registration is a subscription-level action; an identity without
/register/actioncannot complete a first install on a fresh subscription. - A non-overlapping RFC 1918 range if the Greenlight VNet will peer with an existing network.
- A domain where you can delegate one subdomain, such as
greenlight.contoso.com, to Azure DNS. - An IT administrator who can configure the OIDC identity provider, delegate the DNS subdomain, install the Greenlight GitHub App, and become the initial Greenlight
org_admin. - The tools on the machine you install from, at the versions listed in the private package README. The installer checks all of them before it asks for anything and tells you how to install whatever is missing, on one install line for your platform.
- macOS or Linux:
curl, plus bash 3.2 or newer, which is the/bin/bashmacOS already ships. You do not needaz,kubectl,helm, orjq: the installer brings its own digest-checked copies, keeps them in a cache under your home directory, and uses them only for that run. Nothing you installed yourself is replaced or relinked. If you already haveazorjqat a version the installer supports, it uses yours; it always uses its ownkubectlandhelm. Preparing that cache also usestar,unzip, andshasum, which macOS and Linux both ship. - Windows 10 or 11: PowerShell 7.2 or newer (
winget install Microsoft.PowerShell), and thecurlWindows already ships. You do not needaz,kubectl, orhelm: the installer brings its own digest-checked copies the same way, keeps them in a cache under%LOCALAPPDATA%, and uses them only for that run. If you already haveazat a version the installer supports, it uses yours; on ARM-based Windows, where Microsoft publishes no Azure CLI build for the installer to bring, installazyourself. No WSL, Docker, or virtualization is needed, and the installer needs no administrator rights. Windows ships the older Windows PowerShell 5.1 aspowershell; run the installer withpwsh. Hosts whose policy blocks running scripts — group policy enforcingAllSigned, or AppLocker/WDAC script rules — are not supported by this path; ask your Windows administrators for an exception or install from macOS or Linux.
- macOS or Linux:
Fresh subscriptions usually have most resource providers unregistered. The installer registers the namespaces Greenlight needs — including Microsoft.Compute, which the node-size check reads — before it builds the region list, since Azure answers which databases and VM sizes a region offers only for registered providers, then names that subscription change on the review screen. No resource group or cluster is created until you confirm. --plan-only performs the same registration (and the same resource-group create it already did). An identity that cannot register a provider is told the register/action permission it lacks and that the Contributor and Owner roles grant it, and to run the installer again once it holds one, or to have someone who does run it.
What the install provisions
Section titled “What the install provisions”Everything below is created inside the dedicated Greenlight resource group. The installer prints what it will create, grouped in four layers, before its first question.
| Area | Resources |
|---|---|
| Network | A VNet with separate AKS system, AKS app, control-plane database, and app-database subnets, a subnet for the AKS API server’s VNet address, plus private DNS |
| Compute | One AKS cluster with a dedicated system node pool and an autoscaling app node pool, running Cilium so each app’s network policy is enforced |
| Databases | Two private PostgreSQL Flexible Servers: one for Greenlight and one for app-owned logical databases |
| Storage | One Storage account with public blob access disabled for app objects, app assets, and platform storage |
| Registry | One Azure Container Registry for Greenlight and app images |
| Secrets | One Key Vault with a private endpoint for platform, integration, and app secrets |
| DNS and ingress | One Azure DNS zone, a public ingress address, Traefik, and cert-manager |
| Identity | Dedicated managed identities and federated workload-identity bindings for the control plane, certificates, data access, and deployed apps |
| Notification email | An Azure Communication Services email service with an Azure-managed sender domain, so Greenlight can email your admins from the moment the install finishes |
The public entry points are the Greenlight/app ingress and the authenticated AKS API, which operators and installer runs from outside use and which you can limit to your own address ranges. The cluster’s own calls to the AKS API, Greenlight’s included, stay inside the VNet on every install: the API server also has an address there. On a private install the ingress is internal too. PostgreSQL and Key Vault stay on private networking, storage disallows public blob access, and workloads use managed identity instead of stored cloud credentials. Delete locks protect the cluster, both database servers, storage, and the Key Vault, so nothing automated — and no accidental console action — can remove them without a deliberate unlock first.
Notification email
Section titled “Notification email”Greenlight emails your administrators about permission requests, requests to connect a new system, app health, and the weekly digest. The install sets up the sender for you: mail comes from a DoNotReply@…azurecomm.net address Azure manages, with no DNS records to add and no key to store.
More detail
- Your own domain. To send from your own domain, verify it on the install’s email service in the Azure portal and point Greenlight at an address on it. The installer and every update keep a sender you set this way.
- Your own relay. To send through an SMTP relay you already run, pass
--email-smtp-hostand--email-sender(and--email-smtp-portor--email-smtp-usernamewhen the relay needs them). The relay then goes ahead of the Azure sender on every run. Put the relay’s password in the install’s Key Vault asnotifications-smtp-password; the installer never asks for it. - Region. Azure keeps email data in a geography rather than a region, and the install uses the one your region belongs to. In a region with no matching geography the installer sets up no sender and says notification email is off; a relay is the way to send from there.
- Resource provider. The sender needs the
Microsoft.Communicationresource provider. The installer registers it when your identity can. When it cannot, the install still completes, says email is off, and prints the one command a subscription administrator runs; the next update then sets up the sender. An install from the Azure portal or Marketplace runs no such check, so register the provider first if the identity deploying it cannot. - Checking it. Settings → Notifications says so plainly whenever your install has no email sender.
Deployment attribution
Section titled “Deployment attribution”The deployment template carries a Microsoft partner tracking ID, so that when you deploy it Microsoft can associate the Azure resources it creates with Shift as the publisher of the software running on them. The ID is nothing but the name of an empty deployment step: it provisions no resources, adds nothing to your bill, and reads nothing out of your subscription or your Greenlight installation. Microsoft collects and governs that information under its own privacy policies, and does not report your consumption back to Shift.
Deleting the Microsoft.Resources/deployments resource whose name begins with pid- from deployment/azure/main.json before an install leaves that run unattributed, and changes nothing else about the installation. It is not a durable opt-out on its own: the updater applies each release’s own template, which carries the ID again. If your organization needs to be excluded from this attribution permanently, contact Shift.
Greenlight-managed capacity
Section titled “Greenlight-managed capacity”Greenlight selects and manages the infrastructure profile in the versioned deployment package. The values below are the standard installation footprint, sized for cost and quota review.
| Resource | Default |
|---|---|
| AKS system node pool | 1 × Standard_D4s_v6 |
| AKS app node pool | Standard_D4s_v6, autoscale 0–3 |
| PostgreSQL control plane | Flexible Server, B2ms |
| PostgreSQL app databases | Flexible Server, B2ms |
| Key Vault | Standard |
| Container Registry | Basic |
If your subscription has no vCPU quota for Standard_D4s_v6 in the region you choose, or the region does not offer it, the installer picks an equal 4 vCPU / 16 GB size from the release’s list that you do have quota for (for example Standard_D4as_v6 or Standard_D4s_v5) and tells you which. The minimal profile’s 2 vCPU / 8 GB system node falls back the same way, to an equal 2 vCPU size. If none of the listed sizes fits, an unattended run stops rather than choosing an unlisted one, and a terminal offers the equal sizes that fit. Burstable B-series sizes are never used. A size you name yourself is never swapped: the installer stops, or on a terminal offers the equal sizes that fit.
Greenlight manages workload autoscaling within this profile. After install, the app node pool’s node counts, and adding a node to Greenlight’s own pool, are changed from the Cluster capacity card on Internal Health (Upgrades).
One choice sets every size. --sizing-profile picks a whole footprint at once. standard is the table above. minimal suits test, dev and lightly used installs: a smaller 2 vCPU / 8 GiB machine for Greenlight’s own node, and an app pool that stops at one node.
./install.sh --sizing-profile minimalA profile sets starting sizes and ceilings. It never creates capacity up front, so your install still grows only when something needs it. You can name a single size alongside a profile and it wins for that setting alone.
Choose the profile when you first install. On an install that already exists, a profile that disagrees with the sizes it is running stops the run and tells you which single flag changes each one, rather than reporting success and leaving everything where it was. Naming the profile the install was created with is fine, so your original install command reruns as it was. If the installer cannot read the install’s recorded settings, for example because its cluster credentials did not arrive, any profile you name stops the run, since there is nothing to check it against. Drop --sizing-profile and change a size with its own flag instead.
Greenlight’s own pool can survive losing a node. It runs on one node by default. Two keeps Greenlight answering if a node is lost, at the cost of a second machine:
./install.sh --system-node-count 2Your cloud replaces a failed node in minutes and Greenlight’s state lives in its database, so one node is the right default for most installs. Raise it when an hour of downtime would matter more than the extra machine. On an install that already exists, the installer never falls back to that default when it cannot read the recorded settings or finds none: the run stops and asks for the node counts. Rerun it with access to the cluster, or pass --system-node-count, --app-node-min-count and --app-node-max-count yourself.
The app node pool’s size is yours to set. Its machine size and the autoscaler’s smallest and largest node counts are install settings:
./install.sh --app-node-size Standard_D8s_v4 --app-node-min-count 1 --app-node-max-count 8The counts can be changed later; the machine size cannot. Change the smallest or largest node count from the Cluster capacity card on Internal Health; the updater applies it, and automatic updates replay your recorded sizing rather than resetting it to these defaults.
Raising the largest count raises a ceiling; it does not add a node. Azure’s autoscaler adds one only when an app pod cannot otherwise be scheduled. Lowering it below the nodes running now removes nodes your apps run on, so the Cluster capacity card accepts that only when their apps fit on the nodes that stay, and otherwise says there is not enough room and to remove some apps first.
Apps prefer the app node pool and share the node that runs Greenlight itself when the pool has no room, so a small installation stays on one machine and your first app does not buy a second. Greenlight’s own services outrank apps on that shared node, so a busy app cannot crowd them out. It is moved to the app node pool instead, and the autoscaler adds a node for it.
The machine size is settled at install. Changing it afterwards means replacing the node pool — a manual maintenance-window operation your Greenlight contact will walk you through, not an installer rerun. Do not pass --app-node-size to a rerun against a running installation.
Do not edit node pools or database SKUs directly — the next Greenlight update replays your recorded settings and undoes the edit.
Some subscriptions restrict which VM sizes a region allows, or have no vCPU quota left for them. When the default node size is one of those, the installer switches to the first size with the same shape that the region offers and your quota covers, and says which. The shape is the one that node needs: 4 vCPU / 16 GiB, or 2 vCPU / 8 GiB for the minimal profile’s system node. When a size you named is refused, an interactive run offers the same-shape sizes that fit — the same shape, not merely a bigger one — so you can pick one and carry on; a scripted run instead names the flag to pass — --control-node-size or --app-node-size, whichever it rejected — and stops before anything is created. A size you named for one node pool is never swapped to follow the other.
When the subscription is out of vCPU quota
Section titled “When the subscription is out of vCPU quota”On an interactive install, the installer checks quota as soon as you answer the region question. If no size the release can use fits there, it names the quota (for example Standard Dsv6 Family vCPUs), how many vCPUs the install needs, and how many are free. Then it asks what to do: choose another region, ask Azure to raise the quota, or stop. Nothing has been created at that point.
More detail: the quota request
- Choose another region asks the region again and checks the new one the same way.
- Ask Azure to raise the quota files the request through Azure’s Quota API under your sign-in, then waits up to ten minutes for Azure’s answer, which is when automatic approvals arrive. If Azure grants it, the install continues and the review lists the raised quota as a subscription change. The installer registers the
Microsoft.Quotaresource provider the first time it files a request, and names that on the review too. - If the request is still pending, or Azure refuses it, the installer says so with the request’s ID (and Azure’s reason for a refusal), then asks again: choose another region, or stop. It files one request per quota per run. A later run reads the quota again before asking anything.
- Requesting quota needs the optional
Microsoft.Quota/quotas/writepermission (the Quota Request Operator role). Without it, the installer stops, naming the permission and the role. - A
--non-interactiverun never asks. It stops, naming the quota and the vCPUs needed.
On a rerun of an existing installation, quota is checked only for nodes the run adds. A raised system node count or app pool minimum is checked before anything changes, a raised maximum gets a warning, and a rerun that adds no nodes is never refused on quota.
Required access
Section titled “Required access”| Identity | Access | Why |
|---|---|---|
| Provisioning identity | Owner on the subscription, or Owner (or Contributor + Role Based Access Control Administrator) on a Greenlight resource group created beforehand | Creates resources and role assignments |
| Greenlight runner identity | Resource-scoped access to AKS, PostgreSQL, Key Vault, ACR, Storage, the delegated DNS zone, and the install’s own Communication Services resource, plus Managed Identity Contributor on the registry push identity only | Operates the control plane, provisions app resources, and sets which GitHub runs may use the push identity |
Registry push identity (id-greenlight-push) |
AcrPush on the Greenlight registry, nothing else | Default-branch builds of your Greenlight app repositories sign in as it through GitHub’s OIDC token to push their images; Greenlight trusts only your GitHub organization’s gl-app-* repositories |
| cert-manager identity | DNS Zone Contributor on the delegated zone | Completes DNS-01 certificate challenges |
| Connected-data identity | No broad Azure resource access by default | Receives access inside a connected database only when a DBA grants it |
| Updater identity | Contributor + Role Based Access Control Administrator on the Greenlight resource group | Applies Greenlight releases inside the maintenance window |
The installer creates the runtime identities and their bindings. Your team does not create or distribute long-lived cloud keys. If an Azure Policy restricts which issuers a federated identity credential may trust, allow https://token.actions.githubusercontent.com for the Greenlight resource group.
What the installer checks before anything is created
Section titled “What the installer checks before anything is created”Installing from Microsoft Marketplace, the Azure portal’s form makes the same capacity checks before you can select Review + create. It lists the node sizes the installer uses, and it refuses a region that cannot run PostgreSQL 16, a size the region does not offer your subscription, or sizes your vCPU quota cannot start, using the installer’s own words. It warns when quota falls short of the app pool’s maximum. If Azure does not answer one of those reads, the form lets you continue and the deployment checks again.
On an interactive first install, before its first question, the installer reads the signed-in identity’s effective permissions on the subscription — custom roles, groups, and inherited and management-group assignments all count — and compares them with the list below, which ships in the release archive as first-install-permissions.json. If the subscription-level answer is short and the resource group you then name already exists, it reads the permissions on that group too, where creating the group is no longer needed.
| Permission | Granted by |
|---|---|
Microsoft.Resources/subscriptions/resourceGroups/write |
Contributor (not needed when the resource group already exists) |
Microsoft.Resources/deployments/write |
Contributor |
Microsoft.Authorization/roleAssignments/write |
Owner, User Access Administrator, or Role Based Access Control Administrator |
Microsoft.Authorization/locks/write |
Owner, User Access Administrator, or Role Based Access Control Administrator (not needed with --no-resource-locks) |
Microsoft.ManagedIdentity/userAssignedIdentities/write |
Contributor |
Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials/write |
Contributor |
Microsoft.Network/virtualNetworks/write |
Contributor |
Microsoft.Network/dnsZones/write |
Contributor |
Microsoft.Network/privateDnsZones/write |
Contributor |
Microsoft.ContainerService/managedClusters/write |
Contributor |
Microsoft.DBforPostgreSQL/flexibleServers/write |
Contributor |
Microsoft.KeyVault/vaults/write |
Contributor |
Microsoft.Storage/storageAccounts/write |
Contributor |
Microsoft.ContainerRegistry/registries/write |
Contributor |
Microsoft.Quota/quotas/write |
Quota Request Operator (optional: only for the installer to request vCPU quota for you) |
A missing permission is named at once, with a role that grants it. The questions go on so your answers are kept; once they are answered, and before anything is created, you can check again after the role is granted, sign in again and check, or stop. If someone else has to run the install, they run it on their own computer. If the cloud cannot answer exactly — the identity may not run the check, or the read fails — the installer says it could not check and continues. Updates and --non-interactive runs skip the check: the updater’s identity is deliberately narrower than a first install’s.
What the installer runs
Section titled “What the installer runs”These are all the commands the installer can run against your cloud, the new cluster, or the computer it runs on, with when each one runs and why. The same list ships in the release archive as commands.json, and the release’s tests fail if the installer runs a cloud or cluster command that is not on it. ./install.sh --list-commands (or pwsh -File .\install.ps1 --list-commands on Windows) prints it without signing in to anything. On an interactive run, the installer shows the changes on this list before its first question, and the review shows the commands this run will execute with your values filled in. A word in angle brackets stands for a value your answers or the deployment supply, and brackets mark arguments a run adds only sometimes.
The list is a record of what the installer does, not steps to run yourself: the installer runs each command, and never asks you to run one of its own steps and come back.
Every command the installer can run
Reads before any confirmation
Section titled “Reads before any confirmation”az version --output json— reads · this computer · every run. Reads the Azure CLI version, for the dependency check.az account show --output json— reads · this computer · every run. Reads who the Azure CLI is signed in as.az account show --query <query> --output tsv— reads · this computer · every run. Reads the CLI’s default subscription and tenant.az login— changes · this computer · interactive runs. Signs you in to Azure when the CLI has no signed-in account, or when you choose to sign in as someone else; the sign-in is the Azure CLI’s own flow.az login --identity [--username <identity>] --allow-no-subscriptions --output none— changes · this computer · only when the updater or the Azure Marketplace bootstrap runs the installer under a managed identity. Signs in again as the run’s managed identity, so a role assignment made a moment ago reaches its token.az account list --query <query> --output tsv— reads · this computer · interactive runs. Lists the subscriptions you can use, for the subscription question.az account list --refresh --output none— reads · your cloud · every run · repeats until ready. Refreshes the CLI’s subscription list, so a subscription you were just given access to appears.az account set --subscription <subscriptionId>— changes · this computer · every run. Points the CLI at the chosen subscription for this run; the run puts your previous default back when it ends.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>?api-version=<apiVersion> --query state --output tsv— reads · your cloud · every run. Checks the subscription is enabled.az group show --subscription <subscriptionId> --name <resourceGroup> [--query <field>] [--output <format>]— reads · your cloud · every run. Checks whether the resource group exists, where it is, and whether a managed application owns it.az group list --subscription <subscriptionId> --output json— reads · your cloud · first installs. Lists the subscription’s resource groups, so the question offers only those that hold nothing but Greenlight.az resource list --subscription <subscriptionId> --output json— reads · your cloud · first installs. Reads what each resource group holds, in one call, so a group holding anything else is left out of the question.az resource list --subscription <subscriptionId> --resource-group <resourceGroup> --output json— reads · your cloud · first installs. Checks that an existing resource group holds nothing but Greenlight before a first install goes into it.az aks list --subscription <subscriptionId> --resource-group <resourceGroup> --query <query> --output tsv— reads · your cloud · every run. Looks for an existing Greenlight cluster in the resource group.az aks get-credentials --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --file <runKubeconfig> --overwrite-existing— changes · this computer · updates. Writes credentials for the existing cluster into this run’s own kubeconfig file, not yours, to read the settings it recorded.az deployment group show --subscription <subscriptionId> --resource-group <resourceGroup> --name <deploymentName> [--query <field>] [--output <format>]— reads · your cloud · updates. Reads the existing deployment’s outputs and parameters, for an installation that recorded no settings.az aks show --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --query <field> --output tsv— reads · your cloud · updates. Reads the existing cluster’s Kubernetes version, outbound type, network policy, power state, provisioning state, and its API server’s name and allowed ranges.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/<scope>providers/Microsoft.Authorization/permissions?api-version=<apiVersion> --output json— reads · your cloud · first installs. Reads the permissions your identity holds at the subscription, or the resource group, to check a first install can create what it needs.az account get-access-token --resource https://management.azure.com/ --query accessToken --output tsv— reads · this computer · only for an Azure Marketplace installation. Gets a short-lived token for one call to a Marketplace-managed resource group the CLI does not list; the token goes into a private file, never onto a command line.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/resourceGroups/<resourceGroup><path>?api-version=<apiVersion> [--query <field>] [--output <format>] --headers @<headerFile>— reads · your cloud · only for an Azure Marketplace installation. Reads a Marketplace installation’s managed resource group and its cluster, with that token.az rest --method POST --url https://management.azure.com/subscriptions/<subscriptionId>/resourceGroups/<resourceGroup>/providers/Microsoft.ContainerService/managedClusters/<aksClusterName>/listClusterUserCredential?api-version=<apiVersion> [--query <field>] [--output <format>] --headers @<headerFile>— reads · your cloud · only for an Azure Marketplace installation. Reads that cluster’s user credentials, with that token; Azure serves them through a POST that changes nothing.az account list-locations --output json— reads · your cloud · interactive runs. Lists the regions your subscription can use, for the region question.az provider list --subscription <subscriptionId> --query <query> --output tsv— reads · your cloud · every run. Checks which resource providers Greenlight needs are not registered on the subscription.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.Compute/skus?api-version=<apiVersion>&$filter=location <filter> --output json— reads · your cloud · every run. Reads which VM sizes the region offers, for the node sizes.az rest --method GET --url https://management.azure.com/providers/Microsoft.Compute/skus?<nextPage> --output json— reads · your cloud · every run. Reads the next page of that list.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.Compute/locations/<location>/usages?api-version=<apiVersion> --output json— reads · your cloud · every run. Reads the region’s CPU quotas, to check the cluster fits.az rest --method PUT --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.Compute/locations/<location>/providers/Microsoft.Quota/quotas/<quota>?api-version=<apiVersion> --body <body> --output json— changes · your cloud · interactive runs. Asks Azure to raise a vCPU quota the install needs, only after you choose to request it at the quota question. Named on the review once granted.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.Compute/locations/<location>/providers/Microsoft.Quota/quotaRequests?api-version=<apiVersion>&$filter=resourceName <filter> --output json— reads · your cloud · interactive runs. Reads back which quota request was just filed, since Azure names it only in a response header the CLI does not print.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.Compute/locations/<location>/providers/Microsoft.Quota/quotaRequests/<request>?api-version=<apiVersion> --output json— reads · your cloud · interactive runs · repeats until ready. Waits for Azure’s answer to the quota request, up to ten minutes.az rest --method GET --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.DBforPostgreSQL/locations/<location>/capabilities?api-version=<apiVersion> --output json— reads · your cloud · every run. Reads which PostgreSQL versions and sizes the region offers.az aks get-versions --subscription <subscriptionId> --location <location> --query <query> --output tsv— reads · your cloud · every run. Checks the region offers the Kubernetes version this release pins.az aks get-upgrades --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --query controlPlaneProfile.upgrades[].kubernetesVersion --output tsv— reads · your cloud · updates. Reads which Kubernetes versions an existing cluster can upgrade to, to walk it up to the pinned one.az aks nodepool list --subscription <subscriptionId> --resource-group <resourceGroup> --cluster-name <aksClusterName> --query <query> --output tsv— reads · your cloud · updates · repeats until ready. Waits for any operation already running on the cluster’s node pools.az aks nodepool list --subscription <subscriptionId> --resource-group <resourceGroup> --cluster-name <aksClusterName> --query <query> --output json— reads · your cloud · updates. Reads how many nodes each pool runs, when the run did not read its recorded settings, so a rerun’s quota check counts only the nodes it adds.az rest --method post --url https://management.azure.com/subscriptions/<subscriptionId>/providers/Microsoft.KeyVault/checkNameAvailability?api-version=<apiVersion> --body <body> --query nameAvailable --output tsv— reads · your cloud · first installs. Checks a Key Vault name is free, since names are global.az keyvault list --subscription <subscriptionId> --resource-group <resourceGroup> --query <query> --output tsv— reads · your cloud · every run. Reads the installation’s Key Vault name, so an update keeps it.az keyvault list-deleted --subscription <subscriptionId> --resource-type vault --output json— reads · your cloud · every run. Checks for a deleted vault still holding the name, which a new vault cannot reuse until it is purged.az bicep build-params --file <parameterFile> --stdout— reads · this computer · only when you pass a parameter file. Reads the parameter file you passed, so the review shows its values.az network vnet subnet show --subscription <subscriptionId> --resource-group <resourceGroup> --vnet-name vnet-greenlight --name snet-private-link --query name --output tsv— reads · your cloud · private installs. Checks whether the installation publishes a Private Link Service: before the review on a rerun with no recorded settings, which must then name its consumers, and again when an update whose consumer list was cleared builds its deployment.kubectl get clusterissuer letsencrypt-prod -o jsonpath={.spec.acme.email}— reads · the new cluster · only when something Greenlight creates already exists outside this installation’s state. Reads the certificate contact an installation that predates recorded settings uses.kubectl get deployment/<deployment> -n greenlight-system -o jsonpath=<field>— reads · the new cluster · only when something Greenlight creates already exists outside this installation’s state. Reads the images an installation that predates recorded settings runs.az aks nodepool show --subscription <subscriptionId> --resource-group <resourceGroup> --cluster-name <aksClusterName> --name system --query count --output tsv— reads · your cloud · only when something Greenlight creates already exists outside this installation’s state. Reads the system node count an installation that predates recorded settings runs.kubectl version --client -o json— reads · this computer · every run. Reads the kubectl version, for the dependency check.helm version --template {{.Version}}— reads · this computer · every run. Reads the Helm version, for the dependency check and the flags its major version takes.kubectl get configmap greenlight-install-inputs --namespace greenlight-updater [--ignore-not-found] -o go-template=<template>— reads · the new cluster · updates. Reads the settings this installation recorded, so an update reuses them.
Changes before the review, each named as it runs
Section titled “Changes before the review, each named as it runs”az provider register --namespace <namespace> --subscription <subscriptionId>— changes · your cloud · only when a resource provider is unregistered. Registers a resource provider Greenlight needs. Registration is a subscription setting, not a resource: it lets the subscription create that kind of resource, and Azure answers which databases and VM sizes a region offers only once it is done.az provider show --namespace <namespace> --subscription <subscriptionId> --query registrationState --output tsv— reads · your cloud · only when a resource provider is unregistered · repeats until ready. Waits until each registration reports Registered.az aks start --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --output none— changes · your cloud · only to finish a run that stopped part way. Starts a cluster that an earlier run stopped part way through moving its API server, after asking you first.
After the review’s yes
Section titled “After the review’s yes”az group create --subscription <subscriptionId> --name <resourceGroup> --location <location> --output none— changes · your cloud · first installs. Creates the resource group the installation lives in.az aks delete --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --yes --output none— changes · your cloud · only to finish a run that stopped part way. Deletes the cluster Azure refused for capacity on the last run and left Failed, when this run needs another node size or region, which Azure cannot change in place. It is asked first and named on the review, and it never ran Greenlight.az deployment group what-if --subscription <subscriptionId> --resource-group <resourceGroup> --name <deploymentName> --template-file <template> --parameters <parameterFile> [--parameters <parameter>=<value>] --output json --no-pretty-print— reads · your cloud · every run. Previews the change; the preview you confirm is Azure’s own what-if of this deployment, and the parameter file is generated from the review’s settings.
After the preview’s yes
Section titled “After the preview’s yes”az tag update --resource-id <clusterId> --operation <operation> --tags greenlightApiServerRestart=pending --output none— changes · your cloud · only when a run moves the cluster’s API server into the Greenlight network: on a private install, or on a public one given –move-api-server. Marks the cluster while its API server moves into the Greenlight network, and clears the mark when it is done, so a stopped run finishes the move next time.az network dns record-set a delete --subscription <subscriptionId> --resource-group <resourceGroup> --zone-name <controlPlaneHost> --name <record> --yes— changes · your cloud · private installs. Removes the public DNS records when switching an installation to private.az deployment group create --subscription <subscriptionId> --resource-group <resourceGroup> --name <deploymentName> --template-file <template> --parameters <parameterFile> [--parameters <parameter>=<value>] [--no-wait] --output none— changes · your cloud · every run. Deploys the template you previewed. This one command creates most of the installation: the resources the overview lists, with the managed identities and role assignments below.id-greenlight-runner, for the control plane, through workload identity: Azure Kubernetes Service Cluster Admin Role on the cluster; Azure Kubernetes Service RBAC Cluster Admin on the cluster; Key Vault Secrets Officer on the installation’s Key Vault; AcrPush on the container registry; AcrDelete on the container registry; Reader on the container registry; Storage Blob Data Contributor on the storage account; Storage Blob Delegator on the storage account; DNS Zone Contributor on the installation’s DNS zone; Managed Identity Contributor on the push identity; Contributor on the apps’ PostgreSQL server; Communication and Email Service Owner on the Communication Services resource that sends notification emailid-greenlight-updater, for the updater, through workload identity: Contributor on the resource group; Role Based Access Control Administrator on the resource groupid-greenlight-cert-manager, for cert-manager, through workload identity: DNS Zone Contributor on the installation’s DNS zoneid-greenlight-data, for the control plane’s data access, through workload identity: no role of its ownid-greenlight-push, for app image builds, through GitHub’s workload identity federation: AcrPush on the container registryid-greenlight-aks, for the cluster itself: Network Contributor on the virtual networkthe cluster's kubelet identity, for image pulls by the nodes: AcrPull on the container registryid-greenlight-bootstrap-<token>, for the deployment script that hands over the first install, on a Marketplace installation: Reader on the resource group; Azure Kubernetes Service Cluster User Role on the cluster
az deployment operation group list --subscription <subscriptionId> --resource-group <resourceGroup> --name <deploymentName> --query <field> --output json— reads · your cloud · only to explain a step that failed. Reads which of the deployment’s operations failed, so a refusal for capacity or vCPU quota on the cluster is named as one and remembered for the next run.az resource list --subscription <subscriptionId> --resource-group <resourceGroup> --query <field> --output tsv— reads · your cloud · only to explain a step that failed. Reads whether a deployment refused for capacity left anything tied to its region, so the next run knows whether it can offer another region.az network dns zone show --subscription <subscriptionId> --resource-group <resourceGroup> --name <controlPlaneHost> --query nameServers --output tsv— reads · your cloud · first installs · repeats until ready. Reads the DNS zone’s name servers the minute the zone exists, so you can delegate it while the cluster is still being created.az resource list --subscription <subscriptionId> --resource-group <resourceGroup> --query <query> --output tsv— reads · your cloud · first installs · repeats until ready. Reads which resources the deployment has created so far, so the install can show each one as it is created.az deployment group show --subscription <subscriptionId> --resource-group <resourceGroup> --name <deploymentName> [--query <field>] [--output <format>]— reads · your cloud · every run · repeats until ready. Reads when the last deployment finished, so a failure is read from this run’s operations only, then follows this deployment until it finishes and reads its outputs, or its error if it failed.az aks update --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --outbound-type userDefinedRouting --output none— changes · your cloud · private installs. Sends the cluster’s outbound traffic through your firewall on a private install.az aks update --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --enable-apiserver-vnet-integration --apiserver-subnet-id <subnetId> --output none— changes · your cloud · only when a run moves the cluster’s API server into the Greenlight network: on a private install, or on a public one given –move-api-server. Moves the cluster’s API server into the Greenlight network, after asking you first.az aks stop --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --output none— changes · your cloud · only when a run moves the cluster’s API server into the Greenlight network: on a private install, or on a public one given –move-api-server. Stops the cluster, which that move requires; Greenlight and every app are unavailable until it starts again.az aks get-credentials --subscription <subscriptionId> --resource-group <resourceGroup> --name <aksClusterName> --file <runKubeconfig> --overwrite-existing— changes · this computer · every run. Writes credentials for the new cluster into this run’s own kubeconfig file, not yours.kubectl config view --minify -o jsonpath=<field>— reads · this computer · private installs. Reads which API address this run’s kubeconfig points at.kubectl config set-cluster <cluster> --server <address>— changes · this computer · private installs. Points this run’s kubeconfig at the cluster’s private API address.az network nic show --subscription <subscriptionId> --resource-group <resourceGroup> --name nic-greenlight-ingress --output none— reads · your cloud · private installs. Checks whether the network interface holding the private ingress address exists.az network nic create --subscription <subscriptionId> --resource-group <resourceGroup> --name nic-greenlight-ingress --location <location> --vnet-name <vnet> --subnet <subnet> --private-ip-address <privateIngressAddress> --output none— changes · your cloud · private installs. Holds the private ingress address with a network interface until the routing layer’s load balancer takes it.az network private-link-service show --subscription <subscriptionId> --resource-group <nodeResourceGroup> --name pls-greenlight --query <field> --output tsv— reads · your cloud · private installs. Reads the Private Link Service’s alias to print, or checks it is gone before its subnet is removed. Only a run you start makes this call.az network private-link-service delete --subscription <subscriptionId> --resource-group <nodeResourceGroup> --name pls-greenlight --output none— changes · your cloud · private installs. Deletes a Private Link Service the cluster left behind when –private-link-consumers none clears the list.az postgres flexible-server show --subscription <subscriptionId> --resource-group <resourceGroup> --name <server> --query version --output tsv— reads · your cloud · every run. Reads the PostgreSQL major the database server runs, recorded with your settings as a fact the review shows.kubectl annotate svc -n traefik traefik service.beta.kubernetes.io/azure-pls-create- service.beta.kubernetes.io/azure-pls-name- service.beta.kubernetes.io/azure-pls-ip-configuration-subnet- service.beta.kubernetes.io/azure-pls-visibility- service.beta.kubernetes.io/azure-pls-auto-approval-— changes · the new cluster · private installs. Takes the Private Link Service’s annotations off the routing layer when –private-link-consumers none clears the list, so Azure deletes the service before its subnet goes.
Inside the new cluster
Section titled “Inside the new cluster”az network nic delete --subscription <subscriptionId> --resource-group <resourceGroup> --name nic-greenlight-ingress --output none— changes · your cloud · private installs. Releases that address to the load balancer.az identity show --subscription <subscriptionId> --resource-group <resourceGroup> --name id-greenlight-updater --query clientId --output tsv— reads · your cloud · every run. Reads the updater identity’s client id, for the release.kubectl get service kubernetes --namespace default -o jsonpath=<field>— reads · the new cluster · private installs. Reads the cluster’s API address as pods reach it, for the outbound network report.kubectl config view --raw— reads · this computer · every run. Merges this run’s cluster credentials with your own kubeconfig file, which it never edits.kubectl create namespace <namespace> --dry-run=client -o yaml— reads · this computer · every run. Writes out a namespace manifest for the apply below; creates nothing.kubectl apply -f -— changes · the new cluster · every run. Applies the manifests the installer renders: the namespaces greenlight-system, greenlight-updater, traefik and cert-manager, the updater’s database Secret, and the Let’s Encrypt cluster issuers.kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/<version>/standard-install.yaml— changes · the new cluster · every run. Installs the Kubernetes Gateway API resource definitions the routing layer uses.kubectl apply -f <manifest>— changes · the new cluster · every run. Applies the archive’s prerequisite manifests: the platform priority class, and on AWS private installs the bootstrap network policies.kubectl create service clusterip greenlight-admission-probe --namespace traefik --tcp=<port> --dry-run=server -o name— reads · the new cluster · every run · repeats until ready. Rehearses creating a Service, as a server-side dry run, until the add-ons’ admission webhooks answer.kubectl get svc -n traefik traefik -o json— reads · the new cluster · updates. Reads the routing layer’s live Service, to refuse a load balancer change a private-link publication would not survive.helm template traefik traefik --repo https://traefik.github.io/charts --version <version> --namespace traefik --values <valuesFile> [--set <chartValue>=<value>] [--set-string <chartValue>=<value>] --show-only templates/service.yaml— reads · this computer · updates. Renders the routing layer’s Service as this release would install it, to compare with the live one.kubectl create --dry-run=client -o json -f -— reads · this computer · updates. Normalizes that rendered Service for the comparison; creates nothing.helm upgrade --install traefik traefik --repo https://traefik.github.io/charts --version <version> --namespace traefik --create-namespace --values <valuesFile> [--values <cloudValuesFile>] [--set <chartValue>=<value>] [--set-string <chartValue>=<value>] --wait --timeout 5m— changes · the new cluster · every run. Installs Traefik, the routing layer, and its load balancer.kubectl wait --for=condition=Accepted gatewayclass/traefik --timeout=120s— reads · the new cluster · every run. Waits until Traefik accepts the gateway class.helm upgrade --install cert-manager oci://quay.io/jetstack/charts/cert-manager --namespace cert-manager --create-namespace --version <version> --set <chartValue>=<value> [--values <valuesFile>] [--server-side=true] [--force-conflicts] --wait --timeout 5m— changes · the new cluster · every run. Installs cert-manager, which requests and renews the HTTPS certificate.kubectl wait --for=condition=Ready clusterissuer/letsencrypt-prod --timeout=120s— reads · the new cluster · every run. Waits until the Let’s Encrypt issuer is ready.helm get values greenlight --namespace greenlight-system -o json— reads · the new cluster · updates. Reads the running release’s settings, to refuse replacing an updater-managed release with one that names no release.kubectl create secret generic <secret> --namespace <namespace> --from-file=<key>=<file> --dry-run=client -o yaml— reads · this computer · every run. Writes out the updater’s database credential and certificate authority as Secret manifests for the apply; the files are read from the installer’s private work directory.kubectl get configmap greenlight-release-validation-state -n greenlight-system --ignore-not-found -o jsonpath={.data.enabled}— reads · the new cluster · updates. Reads whether release validation is on, so an update keeps the setting.kubectl get configmap greenlight-bootstrap-route-state -n greenlight-system --ignore-not-found -o jsonpath={.data.closed}— reads · the new cluster · every run. Reads whether the setup wizard’s bootstrap address has closed, so the release does not reopen it.helm status greenlight --namespace greenlight-system— reads · the new cluster · every run. Checks whether the Greenlight release is already installed.helm upgrade --install greenlight <chart> --namespace greenlight-system --create-namespace --set <chartValue>=<value> --values <cloudValuesFile> [--set-string <chartValue>=<value>] [--values <pullSecretValuesFile>] [--reset-then-reuse-values] --dry-run=server— reads · the new cluster · every run. Renders the Greenlight release against the cluster as a dry run, so a value the chart rejects stops the run before anything changes.helm upgrade --install greenlight <chart> --namespace greenlight-system --create-namespace --set <chartValue>=<value> --values <cloudValuesFile> [--set-string <chartValue>=<value>] [--values <pullSecretValuesFile>] [--reset-then-reuse-values] [--atomic] [--rollback-on-failure] --wait --timeout 10m— changes · the new cluster · every run. Installs or upgrades the Greenlight release, and rolls it back if it does not become healthy.kubectl get deployments -n greenlight-system -l app.kubernetes.io/part-of=greenlight -o <format>— reads · the new cluster · every run · repeats until ready. Lists the control plane’s deployments and how many replicas are still starting.kubectl get deployments -n greenlight-system -o json -l app.kubernetes.io/part-of=greenlight— reads · the new cluster · every run · repeats until ready. Reads how many control-plane replicas are still starting.kubectl rollout status deployment/<deployment> -n greenlight-system --timeout=5m— reads · the new cluster · every run. Waits for each control-plane deployment to finish rolling out.kubectl get svc -n traefik traefik -o jsonpath=<field>— reads · the new cluster · every run · repeats until ready. Reads the load balancer’s address, until the cloud assigns one.kubectl exec -i --namespace greenlight-system deployment/greenlight --container server -- node --input-type=module - <url> [--connect <address>]— reads · the new cluster · every run · repeats until ready. Requests the control plane’s health address from inside the cluster, for a private install or when this computer cannot reach the load balancer.kubectl wait --for=condition=Ready certificate/greenlight-control-plane-tls -n greenlight-system --timeout=<seconds>— reads · the new cluster · updates. Waits for the HTTPS certificate to be issued.kubectl get certificate/greenlight-control-plane-tls -n greenlight-system -o jsonpath=<field>— reads · the new cluster · updates. Reads whether the HTTPS certificate is ready.kubectl get ingress -n greenlight-system -o name— reads · the new cluster · every run. Stops the run if an Ingress left from an older routing layer would answer for the control plane.kubectl version— reads · the new cluster · only to explain a step that failed. Checks the cluster answers before printing routing evidence for a failed step.kubectl get gatewayclass— reads · the new cluster · only to explain a step that failed. Prints the gateway classes, as routing evidence for a failed step.kubectl get gateway -n greenlight-system -o wide— reads · the new cluster · only to explain a step that failed. Prints the gateway, as routing evidence for a failed step.kubectl describe gateway greenlight-gateway -n greenlight-system— reads · the new cluster · only to explain a step that failed. Prints the gateway’s conditions, as routing evidence for a failed step.kubectl get httproute -n greenlight-system— reads · the new cluster · only to explain a step that failed. Prints the HTTP routes, as routing evidence for a failed step.kubectl get ingress -A— reads · the new cluster · only to explain a step that failed. Prints every Ingress, as routing evidence for a failed step.helm list -A— reads · the new cluster · only to explain a step that failed. Prints the installed Helm releases, as routing evidence for a failed step.helm version --short— reads · this computer · only to explain a step that failed. Prints the Helm version, as evidence for a failed step.kubectl create configmap greenlight-install-inputs --namespace greenlight-updater --from-literal=<setting>=<value> --dry-run=client -o yaml— reads · this computer · every run. Writes out the run’s settings as a ConfigMap manifest for the apply, so the updater and the next run reuse them; no secret is among them.kubectl get configmap greenlight-install-inputs --namespace greenlight-updater -o go-template=<template>— reads · the new cluster · every run. Reads the recorded settings back, to confirm they were stored.
Install inputs
Section titled “Install inputs”The installing administrator supplies only values specific to the customer’s environment:
| Input | Required | Purpose |
|---|---|---|
| Azure CLI session | Yes | Who installs, and in which tenant. The subscription is a question listing the subscriptions it can see; --subscription answers it |
--resource-group |
No | The one resource group Greenlight owns. Created if it does not exist; defaults to rg-greenlight. On a first install, an existing group must hold nothing else |
--location |
Yes | Azure region where Greenlight resources are created. Omit it in an interactive run and the installer offers the regions your subscription can use that can host this release |
--control-plane-host |
Yes | Delegated DNS zone and final Greenlight hostname |
--certificate-contact |
No | Contact address on the certificate issuer. Derived from your host name when omitted |
--network-range |
Only when needed | Replaces the release default when it overlaps a peered network |
--access-profile |
No | public (the default) or private. See Private network access |
--private-link-consumers |
No | Subscription IDs that may reach a private install through Azure Private Link, or none |
--kubernetes-api-allowed-ranges |
No | Public IPv4 ranges the Kubernetes API accepts calls from, or none. See Kubernetes API allowed ranges |
--key-vault-name |
Recovery only | Supplies a new globally unique name when Azure still reserves the default Key Vault name from a deleted install |
Every input above except --access-profile, --private-link-consumers and --kubernetes-api-allowed-ranges has a matching prompt: run ./install.sh with no arguments and it explains and asks for each one, so the flags are only needed for scripted runs — which must also pass --non-interactive: it requires every input up front, stops rather than prompting when one is missing, and is the only thing that skips the two confirmations. A run with no terminal that omits it stops at the first confirmation rather than proceeding on its own.
Resource names, VM sizes, database SKUs, Kubernetes versions, and Greenlight image digests are derived or pinned by the release. They are not install inputs. The release owns the Kubernetes version: each update moves the cluster toward the release’s pinned minor, and every run records the minor the cluster runs and the PostgreSQL major beside your settings, so the review and your Greenlight contact can see what the installation runs.
Run the install
Section titled “Run the install”Authenticate the Azure CLI, select the intended subscription, extract the release archive, and run the installer inside it:
az login --tenant <tenant-id>
unzip greenlight-azure.zipcd greenlight-azure-v<version>
./install.shOn Windows, from PowerShell 7:
az login --tenant <tenant-id>
Expand-Archive greenlight-azure.zip -DestinationPath .Set-Location greenlight-azure-v<version>
pwsh -File .\install.ps1If this machine has no Azure CLI of its own, skip the two az lines and run the installer first: it prepares its own copy, stops at the sign-in check, and prints the full path of the az to run login with. Sign in with that, then run the installer again.
install.ps1 takes the same flags as install.sh, asks the same questions, and records the same settings, so a Windows first install and a later rerun from macOS or the automatic updater are one installation. If Windows refuses to run it because it is not signed — files extracted by Explorer from a downloaded zip carry a download marker that the default RemoteSigned policy stops — run Get-ChildItem -Recurse | Unblock-File in that folder first, or start it as pwsh -ExecutionPolicy Bypass -File .\install.ps1, which applies to that one process only.
The installer names the subscription it works in on every Azure call it makes, so switching subscriptions in another terminal while it runs does not affect it. It reaches the cluster through a kubeconfig of its own, so changing your kubectl context in another terminal while it runs does not redirect it, and it also adds the new cluster to your kubeconfig as the current context, so kubectl points at it afterwards.
Run it with no arguments. The installer first says who is signed in and asks which subscription to use: every enabled subscription this sign-in can see, the active one as the default. Its last choice, Sign in as someone else, runs az login and asks again; it is offered even when your sign-in sees no subscription at all. It then explains and asks for the region, the resource group, and the host name. The resource-group question lists this subscription’s groups that hold nothing else, each with its region, and a last choice to create a new one. A group that already holds a Greenlight installation keeps the region that installation was created in, and the installer says so. Groups that hold anything else are left out, and the installer says how many. The installer then asks whether Greenlight is public (the default) or private, with the private questions that follow, and then offers the standard private network 10.240.0.0/16 as a yes/no — that range cannot be changed after install and cannot be peered if it overlaps a network you will connect later; only a “no” asks for a different range. For the region it shows a numbered list, grouped the way Azure groups its regions, of the regions this subscription can use; enter a number, or type a region name if you already know which one you want. The chosen region is checked before anything is created; if it cannot host this release’s database or does not yet offer the Kubernetes version this release runs, the installer says which region and why, and asks you to pick another — nothing has been created, so a region that does not work costs you one more question. Working out that list changes nothing in your subscription, and if it cannot be worked out the installer says so and asks you to type a name instead. To answer up front instead, pass the flags from the table above. You never write or edit an ARM parameter file; the installer generates one from your answers.
-
Validate the target subscription. Confirm the active Azure tenant and subscription before running the command. As soon as the subscription is selected, before anything else is asked, the installer checks it is enabled: a disabled or deleted subscription stops the run with the steps to reactivate it, and a subscription Azure marks Warned or PastDue gets a warning and the install continues. Before creating the resource group, the installer checks every required tool and version, verifies it can pull the release’s pinned public images, registers any missing resource providers — a subscription setting, not a resource, which ARM needs before it answers the checks below — and checks that the region offers PostgreSQL 16 with a database size this release can use, the Kubernetes version this release pins, and each requested node size for this subscription, including that your subscription has the vCPU quota for it. If something is missing it names the one setting it rejected, and it distinguishes “this region does not offer that” from “the check itself could not run” — so a sign-in that has expired never sends you looking for another region. A default node size that does not fit is replaced on its own. Otherwise an interactive run asks you for another region or another node size rather than stopping; a scripted run stops, having nobody to ask.
-
Confirm twice. The installer shows a review screen — subscription, resource group, region, host name, networking, node sizes, the Kubernetes and PostgreSQL versions this release runs, and the exact image digests — and waits for you to confirm before the resource group is touched. It then shows what the deployment would change and asks you to confirm yes or no. That is a short summary of Azure’s own change preview — one line for each resource it would create or delete, the properties it would change on the ones it modifies, the resources Azure cannot analyze before the deployment, and a count of everything that stays as it is — rather than the thousands of lines of JSON the preview returns. It hides only the differences Azure itself does not count as changes, and says how many it hid; set
GREENLIGHT_INSTALL_VERBOSE=1to see the raw preview as well. The deployment that runs uses the same template and the same settings it previewed. -
Let the deployment complete. It creates the Azure resources, obtains AKS credentials, then applies the release-pinned Gateway API, Traefik, cert-manager, and Greenlight Helm releases. It links the install guide once when it starts, prints a phase name before each operation, and for each step that can run for minutes without output shows what it is waiting for and how long it has been waiting — so a long silence is never ambiguous. It can be rerun safely after a partial failure. On a first install the Azure DNS zone’s name servers are printed the moment the zone exists, a few minutes in, so you can start step 5 while the cluster is still being created, and each part of the installation the overview listed is named as Azure creates it (
Created: Virtual network).Running it from a pipeline, or prefer plain output? Pass
--no-color(or setNO_COLOR) for text only, with no colour and no progress indicator. Redirected output is already plain. SetGREENLIGHT_INSTALL_VERBOSE=1to see every command’s full output instead of a progress indicator. -
Open the install wizard. The install prints
Finish setup in the browser:and a temporary URL that works before your final DNS name and certificate are ready, and stops working for good once the wizard moves you onto the HTTPS name. Its first screen creates your organization and asks for your work email, which fills in the initial org admin and the contact we reach about your trial; you can change either one on its own screen. The wizard then shows the Azure DNS nameservers to delegate. This step belongs to a first install: a rerun of an installation you have already set up ends on its ownhttps://address and the version now running instead. -
Delegate the zone. Create the NS records for the name servers the installer printed (the wizard shows them too) in your parent DNS. Greenlight creates the control-plane, wildcard app, and certificate-challenge records inside the delegated zone, then checks on a repeating timer until delegation, the certificate, and HTTPS are live — nothing to click while you wait.
-
Finish the wizard. Configure OIDC, test sign-in, install the GitHub App, bootstrap the plugin marketplace (the form starts on a private repository; create a public one too if your agents clone it from GitHub), name the administrator we should contact about your trial and subscription (changed later in Settings → Organization), optionally save a payment method on a secure Stripe page if you bought Greenlight directly from Shift (saving a card charges nothing then; skip it and add one in Settings → Billing within seven days, or new building pauses until you do — running apps are unaffected), and confirm the organization and initial administrator.
-
Verify the installation. Open the final HTTPS URL and run the installation-health checks.
If Azure runs out of capacity during the deployment
Section titled “If Azure runs out of capacity during the deployment”The checks before the review read what your subscription may create, not whether Azure has room at that moment. If Azure refuses a node size partway through the deployment, the installer stops, naming the size, the region and Azure’s code (SkuNotAvailable, AllocationFailed or ZonalAllocationFailed). Run the same command again: the rerun says what stopped the last one and works around it before anything else is created.
More detail: what the rerun does
- A size the release chose moves to an equal size the release has vetted, and the installer says which.
- A size you named (
--control-node-size,--app-node-size) is never replaced. A scripted run stops, naming the flag. An interactive run lists the sizes that fit, with the refused one last and marked; choose it to try again. - Another region is offered only when the failed attempt created nothing in its region. Otherwise the installer says what has to be removed first. It never changes region by itself.
- A cluster Azure left Failed has to be deleted before another size can be used, because Azure cannot change an existing cluster’s node sizes. An interactive run asks, names the deletion on the review and deletes it after you confirm. A scripted run stops with the
az aks deletecommand. The cluster never ran Greenlight and holds no data. - Quota used up between the check and the deployment is named as quota. Raise it, then rerun; the installer reads it again first.
- The refusal is kept in
deployment/capacity-refusal/beside the installer until a deployment finishes. To retry a refused size you named from a script, delete that file. - The automatic updater never changes a recorded size. An update Azure refuses for capacity is tried again on the next schedule.
If Azure still holds a soft-deleted Key Vault from a previous install in the same resource group, a first install uses the same name with four characters appended instead, shows it on the review as Key Vault recovery name, and records it so later runs keep it. If the installer cannot choose a free name itself, the preview stops before creating Greenlight resources and tells you to rerun with a new --key-vault-name. If the preview fails for another reason after creating a new resource group, its failure summary reports that group instead of claiming nothing changed.
Kubernetes API allowed ranges
Section titled “Kubernetes API allowed ranges”The cluster’s Kubernetes API has a public, authenticated endpoint that you and the installer use from outside Azure. You can limit it to the address ranges your own people and automation connect from. The cluster’s nodes and Greenlight’s own services reach the API server at its address inside the Greenlight network, which the list does not limit, so the list holds only your ranges, never Greenlight’s.
- Set the list at first install or on any rerun:
--kubernetes-api-allowed-ranges 203.0.113.0/24,198.51.100.7/32. - Change it by rerunning with the new list, which replaces the old one.
- Clear it with
--kubernetes-api-allowed-ranges none. The endpoint then accepts calls from any address, the default. - Keep it by leaving the flag out. A run that names no list and has none recorded keeps the cluster’s current list, including one set earlier with
az aks update.
The review screen shows the list this run applies, open (no list), or kept as the cluster has it. On a cluster whose API server is not in the Greenlight network yet, it says the run leaves the list alone.
Include the address of every machine that runs the installer, kubectl, or other tools against the cluster for you. The installer records the list, and automatic updates apply the same list: only a run you start changes it. A change takes about two minutes, touches no nodes or running apps, and applies to new connections.
More detail
What the installer refuses before it changes anything.
- An entry that is not a public IPv4 range in CIDR form: a private, shared, loopback, link-local, multicast, or reserved range, or an address with host bits set, such as
203.0.113.5/24for203.0.113.0/24. - A list on an installation whose API server is not in the Greenlight network yet, because its nodes still call the public endpoint. Move the API server with a run that passes
--move-api-serverand no list, then rerun with the list. - More than 2,000 ranges, the most AKS takes.
- A list on a run that does not apply it: one with
--parameter-file,--phase kubernetesor--phase prerequisites, or a--reconcile-modeother thanalways. A normal run applies it. A parameter-file run keeps the list the cluster already has, and a parameter file may not setaksApiServerAuthorizedIpRangesitself. - A list that leaves out the machine running the installer. The run names the address to add.
If a run starts outside the list. When you run the installer from an address the cluster’s list leaves out, it cannot reach the cluster, so it stops, names this machine’s address, and prints an az aks update --api-server-authorized-ip-ranges command that adds it. If it could not look up this machine’s address, it says so and prints the same command with a placeholder. That command goes through Azure Resource Manager, which the list does not limit, so you cannot lock yourself out for good. Run it, wait about two minutes, then rerun the installer with the address added to --kubernetes-api-allowed-ranges.
Checking the list. az aks show --resource-group <resource-group> --name <cluster> --query apiServerAccessProfile shows authorizedIpRanges, and enableVnetIntegration as true.
Private network access
Section titled “Private network access”A private install has no public address. The control plane and every app are served by one internal load balancer. The default install stays public. A first install opts in at the installer’s web access question, and then asks whether all outbound traffic should go through a firewall you control: answer yes and the installer tells you to peer the networks and rerun with --egress-next-hop, the firewall’s private address; answer no and it asks which of your subscriptions connect through Azure Private Link. --access-profile private answers the question in a scripted run. An install from the Azure Marketplace is always public: its portal form has no web-access choice. To install privately, use the desktop app or the terminal installer.
People and agents reach a private install from your own network in one of two ways:
- Azure Private Link (recommended). Pass
--private-link-consumerswith the IDs of your subscriptions that should connect. Greenlight publishes its load balancer as a Private Link Service that only those subscriptions can see, and you create a private endpoint for it in a VNet you own. Nothing routes between your network and Greenlight’s, so your address ranges may overlap, and nothing of yours runs in the Greenlight VNet. - VNet peering. Peer the Greenlight VNet to your hub and reach the internal address directly. Routing egress through your firewall with
--egress-next-hopneeds a peering, because the route to your firewall runs over it, so an install with an egress next hop keeps its peering even when people reach it through Private Link.
Both can run side by side: adding consumers to a peered install leaves the internal address and the peered path as they are.
A new install can be private from its first run. The installer prints the internal address, the two names to publish in private DNS, the wizard’s address on the internal load balancer, and, with --private-link-consumers, the alias to create your private endpoint from. Connect your network first, then open the wizard from the company network. Its cluster is created with the API server projected into the Greenlight VNet, as every new cluster is, so Greenlight’s own calls to the Kubernetes API never leave the network. To move an existing public install, follow the switch below.
Connect your network to a private install has the steps for your network team in one place. From the desktop installer app, a private install ends with Connect your network, which walks your network team through the same steps: the names to publish in private DNS, the private endpoint (from the address it shows) or the peering, and then a connection test from your computer on the company network or VPN. When egress goes through your firewall, it then asks for the firewall’s private address, runs the egress switch with --egress-next-hop (you confirm its review and change as on any run), shows each destination the egress check reaches or finds blocked, checks again after you change the firewall, and tests the connection once more. The app holds your answers for the setup wizard until the test passes, then sends them.
Do the switch in this order, inside a maintenance window. The switch changes the load balancer, the DNS records, the egress route, and the cluster’s API server, and it restarts the cluster. The public address stops answering before private DNS is published, so plan for that gap, and for several minutes in which Greenlight and every app are down while the cluster restarts.
- Reserve. On the existing public install, run
./install.sh --print-private-address. It holds a static private address with a placeholder network interface, prints that address, and prints the control-plane name and the*.<host>wildcard. It does not change the install. - Stage the network. For Private Link, choose the subnet in your VNet that the private endpoint will sit in; you point both names at its address once you create it in step 4. For peering, peer the Greenlight VNet to your hub and point both names at the printed address. Do not publish a public
Arecord for either name. Leave the cluster’s own resolver on the public view of the zone: a private zone that answers the control-plane name with the internal address makes certificate renewal miss the DNS-01 challenge. If you will pass--egress-next-hop, prepare the firewall for your users too, in both directions: route the Greenlight network range from every entry point into your hub (the VPN or ExpressRoute gateway subnet, your core) to the firewall’s private address, and allow TCP 443 from your user networks and VPN pools to that range, plus TCP 80 for a first private install’s bootstrap page. Without the route, the dashboard times out over the VPN as soon as the hop is in place: requests reach Greenlight over the peering, but replies leave through the firewall, which drops them as flows it never saw begin. - Switch. Rerun with
--access-profile private, adding--private-link-consumers <subscription-id>[,<subscription-id>...]for Private Link. Add--egress-next-hop <firewall-address>when node egress should go through your firewall. Without that hop, egress stays on the platform load balancer. The installer accepts the hop only on a rerun of a private install. That run attaches the route table, then switches the cluster’s outbound traffic to it, which pauses outbound connections for a moment. After the change preview, and before changing anything else, it records the private profile and deletes the publicArecords, and stops if it cannot. From then on, a run that stops partway is retried as private by the next run, the updater’s included. A rerun that reads no recorded settings, because it passes every setting on the review screen as a flag, because it could not read them, or because there are none, must also pass--access-profile,--egress-next-hoponce the cluster routes through your firewall, and--private-link-consumersonce the install publishes through Private Link. A run with--parameter-filerecords nothing, so it cannot make the switch or set the consumers. It then moves the cluster’s API server into the Greenlight VNet, unless an earlier run already has: the preview names the restart, the run asks you to confirm it, and the cluster stops and starts again. The API server’s public address changes; its name does not. The move is one-way, and Azure checks the region has capacity for the restart first; if it refuses, the run stops with the cluster unchanged, and you run the same command again later. Then it removes the placeholder interface and binds the internal load balancer to the reserved address. It does not recreate the cluster, the VNet, the DNS zone, or the GitHub App, and it does not change the network range. Automatic updates never move the API server: an update of a private install whose API server has only its public endpoint says so and leaves it to you. - Connect. For Private Link, the switch ends by printing the Private Link Service’s alias. In one of the listed subscriptions, create a private endpoint in your VNet: in the Azure portal, under Resource, choose Connect to an Azure resource by resource ID or alias and paste the alias. An endpoint from a listed subscription is approved on its own. The endpoint can sit in any region.
- Publish. Confirm the two names resolve on the company network to your private endpoint, or to the internal address when you peer, and that they do not resolve on the public internet. Clients need TCP 443, and TCP 80 for the wizard.
- Clear the webhook. In the GitHub App’s settings, clear the webhook Active checkbox. GitHub cannot reach a private install, and a delivery log full of failures is the symptom of leaving it on.
More detail: changing the consumers, and what Private Link changes
Changing the consumers. Rerun with a new --private-link-consumers list. The alias stays the same. Taking a subscription off the list refuses its new connections; an endpoint it already connected keeps working until you delete that endpoint, or reject its connection on the service in the cluster’s node resource group:
az network private-link-service show --resource-group <node-resource-group> \ --name pls-greenlight --query 'privateEndpointConnections[].name'az network private-link-service connection update --resource-group <node-resource-group> \ --service-name pls-greenlight --name <connection-name> --connection-status Rejectedaz aks show --resource-group <resource-group> --name <cluster> --query nodeResourceGroup names the node resource group. --private-link-consumers none stops publishing: the run removes the Private Link Service, which closes every endpoint’s connection, and then its subnet. Publishing again later creates a new service with a new alias, so each consumer recreates their endpoint against the alias the run prints.
Who touches the service. The Private Link Service lives in the cluster’s node resource group, where the updater has no role. Only an installer run you start reads its alias there. Each such run reads it again, so a service Azure recreated prints its new alias, and records it. Automatic updates keep the service as it is and make no call in the node resource group.
What Private Link changes. Greenlight sees each connection arrive from the service’s NAT address in snet-private-link, not from the client, so all Private Link traffic shares one per-source rate limit, as it does behind any VPN that translates addresses. Sign-in, permissions, and audit identify people by their credentials, not by address. The service carries no PROXY protocol, and the peered path to the internal address is unchanged.
Keep both directions of every connection on your firewall. With --egress-next-hop, Greenlight’s subnets send all their traffic to the firewall, replies to VPN users included, and ignore the routes your VPN gateway advertises. On the hub side, route traffic for the Greenlight network range from your gateway through the firewall too, so a stateful firewall sees each request and its reply, and allow your users’ networks to reach that range on TCP 443 (and 80 for the bootstrap page). A dashboard that times out over the VPN right after the hop switch, while the installer’s own checks pass, is the sign that this route is missing. Nothing on the internet calls in, so GitHub needs no return path.
What your firewall must allow
Section titled “What your firewall must allow”Every private run ends by checking egress from inside the control plane and naming each destination as reachable or blocked. Run ./install.sh --egress-preflight to check again after a firewall change. Once the install is running, an organization administrator can check without the installer: with --egress-next-hop set, Internal Health → Cluster health has an Egress section listing every destination below, plus your storage account, your identity provider, and each registered integration’s upstream, with whether each was reachable at the last check and the stage that failed for one that was not. Opening it checks again when the last check is over a minute old, and Check again runs one on demand; nothing checks on a schedule, so your firewall logs carry no standing traffic from it. The installer’s check probes every destination below except your identity provider and the *.blob.core.windows.net storage domain, which have no fixed name it can try. It checks the cluster API server at the address pods use, and by its public name only until the API server has moved into the Greenlight VNet; after that the name is not probed, because nothing Greenlight runs in the cluster dials it, and a firewall that denies the public address is the intended end state. Allow these destinations through your firewall:
| Destination | Why |
|---|---|
api.github.com |
The GitHub App, pipeline run discovery, and the release feed the updater reads |
github.com, release-assets.githubusercontent.com, objects.githubusercontent.com |
Release downloads and the Gateway API definitions the installer applies |
fleet.greenlight.shifthq.ai |
Fleet reporting. After seven days with no report delivered, new builds pause until one arrives |
claude.ai |
Claude Code’s sign-in. Greenlight reads Claude Code’s client metadata from there; an agent that signs in with its own metadata address needs that host too |
acme-v02.api.letsencrypt.org |
Certificate issuance and renewal |
<registry>.azurecr.io, *.blob.core.windows.net |
Image pulls for this install, which the registry serves from Azure Storage. The same storage domain serves this install’s app storage and the pipeline evidence GitHub hands back, so allow the whole domain |
traefik.github.io |
The routing chart an update installs |
quay.io, cdn.quay.io, cdn01.quay.io, cdn02.quay.io, cdn03.quay.io, cdn04.quay.io, cdn05.quay.io, cdn06.quay.io |
The certificate manager’s chart and images |
registry-1.docker.io, auth.docker.io, production.cloudfront.docker.com |
Greenlight’s own images and the routing layer’s images |
www.githubstatus.com, status.anthropic.com, status.openai.com, status.cloud.google.com, status.cursor.com |
Upstream status. Greenlight reads GitHub’s and the coding-agent vendors’ status pages to say when an outage there is why something looks broken; a blocked page shows as unknown and nothing else stops |
| Your identity provider | Sign-in. For Entra ID, login.microsoftonline.com is already on Microsoft’s AKS list, and graph.microsoft.com gives group display names; sign-in still works without it |
| The cluster API server | Only installer runs from outside the Greenlight network, by name. Nothing inside the cluster uses the public address once the API server sits in the VNet: Greenlight’s own calls and AKS’s kube-system components reach it at its VNet address, so the firewall needs no rule for the API server’s public IP |
Each integration you register is a destination too. With --egress-next-hop, Greenlight’s calls to an integration’s upstream go through your firewall like everything else, so registering one checks the upstream from inside the cluster first. If your firewall blocks it, registration is refused and names the host and port to allow from the Greenlight network.
Every Greenlight subnet on a private install carries Greenlight’s own network security group, nsg-greenlight. It holds Azure’s default rules: inbound from your connected networks and from Azure’s load-balancer health probes, every other inbound connection denied, and all outbound allowed, so your firewall decides what leaves. It adds two rules that keep the cluster’s own node and pod traffic open, and one that opens TCP 80 and 443 on the internal address to any source that can reach it, so your VPN and on-premises clients get in by name rather than through Azure’s defaults, and the two Microsoft requires for the API server’s subnet: TCP 443 and 4443 from the cluster subnets, and TCP 9988 from Azure’s load balancer. It satisfies policies that require every subnet to have a network security group. Don’t attach your own network security group to Greenlight’s subnets: the next run puts nsg-greenlight back. Filter traffic at your firewall instead.
For the platform itself, allow what Microsoft lists as required outbound traffic for AKS clusters. This cluster uses Azure CNI overlay; its API server keeps a public endpoint for operators and sits in the Greenlight VNet for everything inside the cluster. Microsoft’s list has network rules as well as names. On Azure Firewall, the AzureKubernetesService FQDN tag covers the names, and the network rules (NTP, and the ports Microsoft lists) go in separately. The API server’s public IP is not among them: once the API server sits in the VNet, nothing in the cluster dials it. On another firewall, use Microsoft’s published list rather than copying it by hand.
These clients work on a private install: Claude Code CLI and the Desktop Code tab, Cursor desktop, and Codex desktop. Claude.ai, connector clients, and cloud agents cannot reach it. When the control-plane name does not resolve, the CLI tells the operator to connect to the company network.
Post-install verification
Section titled “Post-install verification”Installation health verifies that:
- The final URL resolves over HTTPS.
- SSO resolves to a real IdP user with the required role mapping.
- The GitHub App can reach at least one repository.
- A plugin marketplace repository has its initial generated commit.
- The policy bundle and audit log are active.
- A test app can register and pass the policy check.
The deployment is idempotent: rerunning the same version reconciles the existing installation.
Upgrades
Section titled “Upgrades”Greenlight upgrades through a customer-owned updater inside the same Azure boundary. It applies the versioned Bicep and Helm release using customer-controlled permissions. See Upgrades.
An installation created before Cilium
The first update that turns Cilium on reimages every node at once, so apps and the dashboard restart and are briefly unavailable. Nothing is replaced. Plan that update for a quiet time.
An installation whose API server is not yet in the VNet
A public install whose cluster was created without API Server VNet Integration, before Greenlight turned it on for every install, reaches the Kubernetes API at its public address. It keeps working that way until you move it, and automatic updates never do. To keep the cluster’s own calls inside the VNet, run this in a maintenance window:
./install.sh --move-api-serverThe run deploys the API server’s subnet, names the restart in its preview, and asks you to confirm it. The cluster then stops and starts again, so Greenlight and every app are down for several minutes. The move is one-way. The API server’s public address changes; its name does not, and you keep reaching the cluster by that name. Azure checks the region has capacity for the restart first; if it refuses, the run stops with the cluster unchanged, and you run the same command again later. If a run stops after the move but before the restart, the next run of the installer finishes the restart.
A Marketplace installation cannot be moved this way, because its deny assignment refuses the restart. A portal install created since then already has the API server in the VNet.
Adopting an existing installation
Section titled “Adopting an existing installation”Every successful install records its settings inside the cluster, and a rerun reads them back so you only supply what is changing. An installation created before the installer recorded settings — or whose first run stopped before the final recording step — has no such record, so a rerun stops rather than guess values that would reshape live infrastructure. Adopt it once:
./install.sh --adopt-existingAdoption reads the installation’s current settings from the deployment and the cluster, shows you what it found together with a change preview proving nothing would change, and records it. Names the installation was created with — a pinned cluster name, pinned database server names, a recovered Key Vault name, the deployment name it was deployed under — are carried over automatically, so later runs address the same resources rather than deriving new ones. The Greenlight node count is read off the cluster’s system pool; if that pool cannot be read, adoption stops and asks you to pass --system-node-count with the number of nodes it runs. If the preview would create or delete anything, an unattended run stops without recording; at a terminal you see the counts and decide. From then on, reruns behave like any other upgrade.
An installation created under a deployment name other than the default is adopted with that name:
./install.sh --adopt-existing --deployment-name <name>