In the first part of this series, we scaffolded a Backstage app, wired up Microsoft Entra ID sign-in, and imported users and groups with Microsoft Graph. That all ran locally. In this post, we'll deploy that same app to Azure Kubernetes Service (AKS) behind HTTPS with a real hostname, installed from a Helm chart.
Sounds like a lot, but that's what AI is for, right? 😅
Here's the plan:
- Create an AKS Automatic cluster with a free managed domain.
- Point the Entra app registration at the new hostname.
- Have GitHub Copilot write the Dockerfile and Helm chart.
- Deploy Backstage and sign in.
When we're done, a request to https://backstage.<your-default-domain> resolves through DNS records that AKS manages. It lands on a Gateway run by the managed Istio, which terminates TLS with a wildcard certificate AKS issues and routes the request to the Backstage pod. Backstage keeps its catalog in an in-cluster PostgreSQL and talks to Entra ID for sign-in and the Microsoft Graph import, just like it did locally.
We'll build the image and install the chart by hand. I want to prove the whole thing works end to end before automating image and chart publishing with CI/CD. Debugging a deployment is a lot easier when there isn't also a GitHub Actions run in the mix. Once it works, I'll leave you with pointers for handing it off to CI/CD.
If you haven't completed part one, start there. Everything below runs from the root of that Backstage app (backstage-demo, which already has the entra-setup directory in it).
Prerequisites
You'll need everything from part one, plus:
- An Azure subscription where you can create resources and role assignments (Owner, or Contributor plus User Access Administrator).
- Azure CLI 2.86.0 or later.
- GitHub CLI 2.103.3 or later, signed in with
gh auth login. - OpenTofu. Terraform works too; replace
tofuwithterraform. - Docker or Podman.
- kubectl and kubelogin.
- Helm 3.8 or later.
- A GitHub account for pushing the image to GitHub Container Registry (GHCR) - it's free 🤑.
If you want the agent to write the deployment files, you'll also need GitHub Copilot CLI, plus kubeconform and actionlint so it can validate its work. Otherwise, you can grab my files in Step 3.
All the commands in this post are Bash and were tested on macOS. On Windows, run them in WSL, the same as part one. Git Bash, which comes with Git for Windows, should also work, as long as the CLIs above are on your
PATH. PowerShell and Command Prompt won't work as-is.
Step 1: Create the AKS cluster
I did say Azure, right?
Sign in to Azure
Sign in to the tenant that owns your Azure subscription. It doesn't have to be the Entra test tenant from part one.
az login
az account set --subscription "<your-subscription-id>"
export ARM_SUBSCRIPTION_ID="$(az account show --query id -o tsv)"The AzureRM provider reads ARM_SUBSCRIPTION_ID when you sign in with the Azure CLI.
Register the default domain preview
Okay, so this one is literally "hot off the press"... AKS now has a managed default domain feature that makes it super easy to get a trusted domain and certificate for your cluster 🚀
The AKS managed default domain feature gives the cluster a domain like <id>.<region>.aksapp.io, a trusted wildcard certificate, and managed DNS records. No domain to buy, no DNS zone, and no certificate to manage. Check out the announcement post for more background.
As of this writing, the default domain is in public preview, so you need to register a feature flag before using it. Once it's generally available, you can skip this section. Check the docs for the current status.
az feature register --namespace Microsoft.ContainerService --name DefaultDomainPreviewRegistration takes a few minutes. Check it until it prints Registered, then refresh the resource provider.
az feature show --namespace Microsoft.ContainerService --name DefaultDomainPreview --query properties.state -o tsv
az provider register --namespace Microsoft.ContainerServiceProvision the cluster
Create an azure-setup directory beside entra-setup and grab my OpenTofu configuration. The .gitignore keeps state files, which contain sensitive values, out of Git.
mkdir azure-setup && cd azure-setup
curl -fsSLO https://raw.githubusercontent.com/pauldotyu/backstage-demo/2338052cce935817962210d768af031ffe53c564/azure-setup/main.tf
curl -fsSLO https://raw.githubusercontent.com/pauldotyu/backstage-demo/2338052cce935817962210d768af031ffe53c564/azure-setup/.gitignoreOpen the main.tf file and inspect the contents.
The cluster type I chose to use is AKS Automatic, because it handles node provisioning, scaling, upgrades, and most of the cluster configuration for us. Since AKS handles most of the heavy lifting, the cluster definition is less than 10 lines of code!
resource "azurerm_kubernetes_automatic_cluster" "example" {
name = "aks-backstage-demo"
resource_group_name = azurerm_resource_group.example.name
location = azurerm_resource_group.example.location
identity {
type = "SystemAssigned"
}
}But the AzureRM provider doesn't support all the new routing features yet, so I lean on the AzApi provider's azapi_update_resource to turn them on. It installs the managed Gateway API CRDs, enables the application routing add-on's Istio-based Gateway API implementation, and enables the default domain.
ingressProfile = {
gatewayAPI = {
installation = "Standard"
}
webAppRouting = {
enabled = true
defaultDomain = {
enabled = true
}
gatewayApiImplementations = {
appRoutingIstio = {
mode = "Enabled"
}
}
}
}The configuration also gives you the Azure Kubernetes Service RBAC Cluster Admin role on the cluster. AKS Automatic uses Azure RBAC for Kubernetes authorization, so being the subscription owner isn't enough on its own.
Apply it. The cluster lands in westus3 by default; pass -var region=<region> to pick one of the supported regions.
tofu init
tofu applyOnce it's done, connect to the cluster and check that the approuting-istio GatewayClass is there.
az aks get-credentials \
--resource-group "$(tofu output -raw rg_name)" \
--name "$(tofu output -raw aks_name)" \
--overwrite-existing
kubectl get gatewayclassAlso make sure the default domain is correctly set up by running:
tofu output -raw default_domainBackstage will live at backstage.<default-domain>.
If
kubectlreturnsForbidden, the role assignment is still propagating. Give it a few minutes.
Step 2: Update the Entra app registration
The app registration from part one redirects to localhost. We'll point it at the HTTPS hostname, reading the default domain straight from the azure-setup state.
cd ../entra-setupOpen main.tf and add this near the top:
data "terraform_remote_state" "azure" {
backend = "local"
config = {
path = "${path.module}/../azure-setup/terraform.tfstate"
}
}Update the web block in the azuread_application resource:
web {
homepage_url = "https://backstage.${data.terraform_remote_state.azure.outputs.default_domain}"
redirect_uris = ["https://backstage.${data.terraform_remote_state.azure.outputs.default_domain}/api/auth/microsoft/handler/frame"]
}Want to keep signing in locally with
yarn start? Keephttp://localhost:7007/api/auth/microsoft/handler/frameinredirect_uristoo.
The app-config.local.yaml file from part one never leaves your machine, so the cluster will need to get the Entra values from a Kubernetes Secret instead. Add these outputs to the bottom of the file so we can create it:
output "client_id" {
value = azuread_application.example.client_id
}
output "client_secret" {
value = tolist(azuread_application.example.password).0.value
sensitive = true
}
output "tenant_id" {
value = data.azuread_client_config.current.tenant_id
}
output "group_id" {
value = azuread_group.backstage_users.object_id
}If something doesn't line up, compare against my complete main.tf.
If your Entra test tenant is different from your subscription's tenant, run
az login --tenant "<entra-tenant-id>" --allow-no-subscriptionsbefore applying. Afterward, rerun the sign-in commands from Step 1 to get back to the subscription's tenant, because kubelogin uses your Azure CLI sign-in.
Then apply.
tofu init
tofu apply
cd ..Step 3: Prep the repo with GitHub Copilot
Next let's hand over the job of creating the deployment pieces to GitHub Copilot CLI and stay engaged in the loop to test, redirect, and approve. By the end of the session, you should get a fully prepared deployment setup for your Backstage app with:
- A multi-stage
Dockerfilethat builds everything inside Docker and runs as a non-root user. It replaces the scaffolded one, which needed a host build first. - A Helm chart with an in-cluster PostgreSQL for demos, Gateway API routing, and a
DefaultDomainCertificatefor TLS. - A GitHub Actions workflow that publishes the image and chart to GHCR. It's there for when the manual deployment checks out.
There are two ways through this step: run the agent yourself, or grab my files. The agent's output varies from run to run, and Step 4 depends on the values and resource names in my chart. If you just want a working deployment, grab my files. If you want to see how the agent handles it, run the agent and then compare its output against mine.
Set up the agent
The model I used for this session was Claude Opus 5.5; however, I understand not everyone has access to frontier models like it, whether that comes down to your plan, cost, or what your organization has approved. If you are using a different model, the steps should be similar, but the output may vary. In any case, you could provide the agent with some tools and/or skills to help it generate the deployment pieces accurately. I added two MCP servers before handing over the job. The Microsoft Learn MCP server lets the agent check current Azure docs instead of guessing. It's how the chart got the right GatewayClass name (approuting-istio) instead of a made-up one. The Containerization Assist MCP server gives the agent purpose-built tools to analyze a repo, generate and fix Dockerfiles, generate Kubernetes manifests, and scan images.
You can add the MCP servers to your Copilot setup with the following commands:
copilot mcp add --transport http microsoft-learn https://learn.microsoft.com/api/mcp
copilot mcp add containerization-assist -- npx -y containerization-assist-mcp startSend the prompt
From the root of your Backstage app, start copilot, pick a model with /model, and send a prompt like this. It's a cleaned-up version of what I asked for, with the details I added over several follow-ups folded in.
Prep this Backstage repo for deployment to AKS with Helm.
Deliverables:
- A root multi-stage Dockerfile that builds the backend entirely in Docker (no host build step). Run as a non-root user. Keep local config, secrets, and OpenTofu state out of the build context.
- A Helm chart in charts/backstage:
- appConfig values rendered to a ConfigMap and loaded after app-config.production.yaml.
- Secrets passed as ${ENV_VAR} placeholders via an existingSecret.
- Optional in-cluster PostgreSQL for demos, externalDatabase for real use.
- Gateway API (not Ingress) using gatewayClassName approuting-istio, with an HTTPRoute, HTTP-to-HTTPS redirect, and an AKS app routing DefaultDomainCertificate for TLS termination (see the example custom resource below).
- A GitHub Actions workflow that builds and pushes the image to GHCR and publishes the chart to GHCR as an OCI artifact. Pin the chart appVersion to the image built from the same commit.
- README instructions.
Context:
- My local Entra config is in app-config.local.yaml. Do not commit real IDs or secrets; use placeholders in examples.
- My cluster infrastructure is in azure-setup/main.tf and the Entra app registration is in entra-setup/main.tf. Read both before designing the Gateway and TLS pieces.
Validate with helm lint, kubeconform (strict, with CRD schemas), and actionlint. Build and smoke-test the image locally against Postgres before committing.
Example DefaultDomainCertificate:
apiVersion: approuting.kubernetes.azure.com/v1alpha1
kind: DefaultDomainCertificate
metadata:
name: cert
namespace: hello-world
spec:
target:
secret: certTwo parts of that prompt made the biggest difference. Pointing the agent at the OpenTofu configuration let it see the cluster, domain, and redirect URI before designing anything. Asking for validation up front meant my review time went to design decisions instead of YAML typos.
Review the diff and compare it with my files before moving on.
Or grab my files
Don't want to run the agent and just want the code? Pull the same files into your repo. This replaces the scaffolded packages/backend/Dockerfile with the root one:
SHA=2338052cce935817962210d768af031ffe53c564
git fetch --depth 1 https://github.com/pauldotyu/backstage-demo.git "$SHA"
git checkout "$SHA" -- Dockerfile .dockerignore charts/backstage .github/workflows/publish.yaml hack/kubeconform-schemas
git rm -q packages/backend/DockerfileStep 4: Deploy Backstage
This is the manual run. We'll build and push the image from our workstation, then install the chart straight from the repo. No workflow runs and no published chart yet. I want to make sure everything works end-to-end before automating it.
Build and push the image
We'll let the GitHub CLI handle the token, so there's no personal access token to create or paste. If you haven't signed in yet, run gh auth login and follow the prompts. Then add the write:packages scope to your gh credentials, since GHCR needs it to push.
gh auth refresh -h github.com -s write:packages
gh auth statusgh auth status should list write:packages under the token scopes.
Run the rest from the root of your Backstage app. Your username and token both come from gh, so nothing gets typed or saved in your shell history.
export GHCR_USER="$(gh api user --jq .login)"
export GHCR_OWNER="$(printf '%s' "$GHCR_USER" | tr '[:upper:]' '[:lower:]')"
export IMAGE_TAG="local-$(date -u +%Y%m%d%H%M%S)"
export IMAGE="ghcr.io/${GHCR_OWNER}/backstage-demo:${IMAGE_TAG}"
gh auth token | docker login ghcr.io --username "$GHCR_USER" --password-stdin
docker build --platform linux/amd64 -t "$IMAGE" .
docker push "$IMAGE"Build for linux/amd64 even on an Apple silicon Mac, since that's what the AKS nodes run. Docker Desktop and Podman Desktop handle the emulation for you. Expect it to take a while. On an arm64 Linux machine, install QEMU emulation first, either with docker run --privileged --rm tonistiigi/binfmt --install amd64 or with your distro's qemu-user-static package. The timestamp tag means you don't have to commit just to get a tag, and every rebuild gets a fresh one so nodes don't run a cached image. With Podman, swap docker for podman.
Create the Secrets
The cluster needs two Secrets. backstage-secrets holds the Entra values, which come straight from the OpenTofu outputs so the client secret never gets printed or saved in a file. ghcr-pull lets the nodes pull the image, since new GHCR packages are private by default and your workstation login doesn't carry over.
If you've updated your container image to be public, you can skip creating the
ghcr-pullsecret and drop theimagePullSecretsflag from the Helm install below.
export NAMESPACE=backstage
kubectl create namespace "$NAMESPACE"
kubectl -n "$NAMESPACE" create secret generic backstage-secrets \
--from-literal=AZURE_CLIENT_ID="$(tofu -chdir=entra-setup output -raw client_id)" \
--from-literal=AZURE_CLIENT_SECRET="$(tofu -chdir=entra-setup output -raw client_secret)" \
--from-literal=AZURE_TENANT_ID="$(tofu -chdir=entra-setup output -raw tenant_id)" \
--from-literal=AZURE_GROUP_ID="$(tofu -chdir=entra-setup output -raw group_id)"
kubectl -n "$NAMESPACE" create secret docker-registry ghcr-pull \
--docker-server=ghcr.io \
--docker-username="$GHCR_USER" \
--docker-password="$(gh auth token)"Your gh token carries every scope your CLI has, not just packages, and it now sits in a cluster Secret. That's fine for a demo. For anything longer-lived, make the package public or give the cluster a dedicated token with only read:packages. Also, if gh issued an expiring token, pulls fail once it expires. Run gh auth refresh -h github.com -s write:packages --expiring=false before creating the Secret, or recreate the Secret when that happens.
Install the chart
Build the hostname from the default domain. It has to be backstage because that's what we registered in Entra. It also has to sit exactly one label below the default domain, because that's what the wildcard certificate covers.
If you have enabled public access for your container image, you can skip creating the
ghcr-pullsecret. Otherwise, make sure the secret exists and contains valid credentials.
export BACKSTAGE_HOST="backstage.$(tofu -chdir=azure-setup output -raw default_domain)"
helm upgrade --install backstage ./charts/backstage \
--namespace "$NAMESPACE" \
--set-string image.repository="${GHCR_OWNER}/backstage-demo" \
--set-string image.tag="$IMAGE_TAG" \
--set-string 'imagePullSecrets[0].name=ghcr-pull' \
--set-string gateway.hostname="$BACKSTAGE_HOST" \
--set-string existingSecret=backstage-secrets \
--wait --timeout 10mHere's how the chart puts it together:
- Config: the
backstage.appConfighelper buildsapp.baseUrl,backend.baseUrl, andbackend.cors.originfromgateway.hostname, then merges theappConfigblock fromvalues.yamlon top.configmap.yamlwrites the result to a ConfigMap asapp-config.helm.yaml. - Loading order:
deployment.yamlmounts that ConfigMap at/app/configand starts Backstage with--config app-config.yaml --config app-config.production.yaml --config /app/config/app-config.helm.yaml. The last file wins. - Secrets: the config only holds
${AZURE_CLIENT_ID}-style placeholders. The Deployment loadsexistingSecretwithenvFrom, so each Secret key becomes an environment variable, and Backstage fills in the placeholders at startup. - TLS:
gateway.yamlcreates aDefaultDomainCertificate, so AKS writes the wildcard certificate into thebackstage-tlsSecret. The Gateway's HTTPS listener references that Secret.
The Deployment also carries a checksum of the rendered config, so any config change rolls the pods on the next helm upgrade.
One gotcha if you write this yourself: the cluster runs with auth.environment: production, so the Microsoft provider block needs to sit under production, not development like in the local file.
Sign in
Wait for the certificate and Gateway, then check the endpoint.
kubectl -n "$NAMESPACE" wait --for=condition=Available defaultdomaincertificate/backstage --timeout=5m
kubectl -n "$NAMESPACE" wait --for=condition=Programmed gateway/backstage --timeout=5m
curl -sS -o /dev/null -w "HTTP %{http_code} verify=%{ssl_verify_result}\n" "https://${BACKSTAGE_HOST}/"HTTP 200 verify=0 means it's up with a trusted certificate. If the hostname doesn't resolve yet, give the managed external-dns a minute.
The cluster starts with an empty catalog, so wait for the first Microsoft Graph import before signing in. Watch the logs until you see a Committed line with your users and groups, then press Ctrl+C.
kubectl -n "$NAMESPACE" logs deployment/backstage -f | grep -i msgraphOpen https://$BACKSTAGE_HOST in a browser window and sign in with an account in backstage-users. If all went well, you're signed in to Backstage on AKS 🥳
If not:
ImagePullBackOffwith401 Unauthorized: Theghcr-pullSecret is missing, or the token in it expired or lacks the packages scope. Rungh auth refresh -h github.com -s write:packages, then delete and recreateghcr-pull.AADSTS50011redirect URI mismatch: Reruntofu applyinentra-setup, and confirm the hostname label isbackstage.- Signed in to Microsoft, but Backstage can't find you: The catalog import hasn't finished, or you're not a direct member of
backstage-users. If you switched touserIdMatchingUserEntityAnnotationin part one, rerun thehelm upgradewith--set-string 'appConfig.auth.providers.microsoft.production.signIn.resolvers[0].resolver=userIdMatchingUserEntityAnnotation'.
Add your own config
Any Backstage setting can go under appConfig in a values file, with no image rebuild. For example, to register software templates from GitHub, create my-values.yaml:
appConfig:
catalog:
locations:
- type: url
target: https://github.com/backstage/software-templates/blob/main/scaffolder-templates/react-ssr-template/template.yaml
rules:
- allow: [Template]Then rerun the same helm upgrade --install command with -f my-values.yaml added. Helm deep-merges maps, so the chart's auth and microsoftGraphOrg settings stay in place. Lists don't merge, though, in Helm or in Backstage: a locations list here replaces any list from an earlier config file.
The rules entry matters. By default, the catalog only accepts Component, API, and Location entities, so a template location without allow: [Template] gets rejected. Scoping the rule to the location, instead of a global catalog.rules, means templates only come from sources you list. That's what the Backstage threat model recommends, since templates run actions on the backend. Reading from GitHub without a token also hits rate limits quickly. The base app-config.yaml already reads ${GITHUB_TOKEN}, so add it to the Secret and restart:
kubectl -n "$NAMESPACE" patch secret backstage-secrets --type merge \
-p "{\"stringData\":{\"GITHUB_TOKEN\":\"$(gh auth token)\"}}"
kubectl -n "$NAMESPACE" rollout restart deployment/backstageThe restart is needed because the config checksum doesn't cover Secret changes.
Lessons learned
AI doesn't always get things right on the first try and a clean lint wasn't enough. The real checks were building the image, deploying it, and signing in. Here's what came up and what I'd do differently:
- Give the agent your infrastructure early. The GatewayClass, default domain, and redirect URI all live in the OpenTofu config. The first chart used Ingress until I pointed the agent at the cluster setup, and managed NGINX is on its way out anyway.
- Watch label inheritance on Gateways. The
backstageService was routing to the Istio gateway pods. Istio copies a Gateway's labels onto the proxy Deployment it creates, and the chart reused the app's selector labels on the Gateway. The fix was a separateapp.kubernetes.io/component: gatewaylabel. - Register
https://redirect URIs from the start. A scheme or hostname mismatch gets youAADSTS50011even when everything else works. - A successful
docker pushdoesn't mean AKS can pull it. Private packages need a pull Secret passed to the chart. - Keep secrets out of the agent's commits. Ask for placeholders and check the diff. Local config, tokens, and OpenTofu state don't belong in the image or the repo.
Hand it off to CI/CD
Now that the manual run works, let the publish workflow take over. Commit the new files and push your app to a GitHub repo with a main branch. I won't walk through it here, but here's what you need to know:
- Pushes to
mainandv*.*.*tags publish. The image goes toghcr.io/<owner>/<repo>taggedsha-<short-sha>, and the chart goes tooci://ghcr.io/<owner>/charts/backstage-demo. Pull requests only lint and build. - The chart is pinned to its image. The workflow sets the chart's
appVersionto the image tag from the same commit, so you don't need to passimage.tag. - Install from GHCR instead of the repo. Swap
./charts/backstageforoci://ghcr.io/<owner>/charts/backstage-demo, add--versionwith the chart version from the run summary, and drop theimage.tagflag. Keepimage.repositorypointed at your image (the chart defaults to mine), along with the pull secret, hostname, andexistingSecretflags. - Fix
403errors on the first run. If you pushed the image by hand first, the workflow'sGITHUB_TOKENmay not have write access to that package. Open the package's Package settings, and under Manage Actions access, add your repo with the Write role.
The repo README has the full commands.
Cleanup
Keep everything running if you're continuing with the series. To tear it all down, destroy entra-setup first, because it reads from the azure-setup state. If you're in a new terminal, rerun the sign-in commands from Step 1 so ARM_SUBSCRIPTION_ID is set. If your Entra test tenant is separate, sign in to it for the first command and back to the subscription's tenant for the second.
Run the following commands to destroy the infrastructure:
tofu -chdir=azure-setup destroy
tofu -chdir=entra-setup destroyDeleting the cluster takes the namespace and Secrets with it. The image stays in GHCR until you delete the package from your GitHub Packages tab.
Conclusion
Backstage now runs on AKS Automatic behind the Gateway API, with a free managed domain, a trusted certificate, and Microsoft Entra ID sign-in. GitHub Copilot did most of the typing, but the proof was opening the app over HTTPS and signing in. From here, the publish workflow can keep the image and chart up to date on every commit.
One more thing before you run this for real: the in-cluster PostgreSQL is fine for a demo, but it isn't where a production catalog should live. The chart's externalDatabase values let you point Backstage at a managed database instead. I think I might visit that path in a future article.
See you in the next one ✌️
Community discussion