Azure Kubernetes DevOps Home

From Code to Cloud: A Complete AKS and ACR Deployment Walkthrough

A practitioner's step-by-step guide to pushing container images, standing up a Kubernetes cluster, and routing real traffic — all the way from your local terminal to a live Azure endpoint.

By Janelle · July 29, 2026 · ☕ 18 min read
Table of Contents

There's a specific kind of satisfaction that comes from watching your containerized application — the one you've been running locally with a simple docker compose up — suddenly become accessible from a public IP in the cloud. It doesn't happen by magic, though. Between "it works on my machine" and "it runs in production" lies a surprisingly navigable path through Azure Container Registry, Azure Kubernetes Service, Helm charts, and a handful of YAML manifests. I know this path well now, because I've just finished walking every inch of it.

This walkthrough documents a real end-to-end deployment: pushing a containerized API service (and frontend) to Azure Container Registry, standing up an AKS cluster, wiring the two together with managed identities, writing Deployment and Service manifests, installing an NGINX Ingress Controller, and verifying that actual HTTP traffic reaches the correct pods. I'll share the exact commands I ran, the YAML I wrote, the portal views I checked — and the gotchas that burned me along the way.

The target reader is a developer who is comfortable with Docker and has played with Azure at least a little, but hasn't yet deployed a full Kubernetes workload to the cloud. By the end, you'll have a repeatable mental model — and a command cheat sheet — for doing this yourself.


1. The Big Picture: What We're Building

Before diving into commands and YAML, let's be clear about the architecture we're assembling. There are five moving parts:

The overall flow is clean: your CI pipeline (or your local terminal, for now) builds a Docker image and pushes it to ACR. AKS pulls that image from ACR — securely, via managed identity — and runs it as pods. The Ingress Controller receives traffic from the public internet and routes it to the right pod.

📌 Why AKS + ACR?


2. Setting Up Azure Container Registry

ACR is where your Docker images live in the cloud. Think of it as Docker Hub, but private, fast from Azure's network, and wired directly into your AKS cluster. Setup is quick — the important thing is understanding the hierarchy: a Registry contains Repositories, each repository contains Tags, and each tag resolves to a Manifest.

Creating the Registry and Pushing Your First Image

bash
# Create a resource group if you don't have one
az group create --name myResourceGroup --location eastus

# Create the ACR (Basic SKU is fine for dev/staging)
az acr create --resource-group myResourceGroup \
  --name myAcrRegistry --sku Basic

# Login to ACR — this configures your local Docker daemon
az acr login --name myAcrRegistry

# Tag your local image for ACR
docker tag myapp:latest myAcrRegistry.azurecr.io/myapp/api:latest

# Push the image to ACR
docker push myAcrRegistry.azurecr.io/myapp/api:latest

# (Optional) Push a versioned tag alongside latest
docker tag myapp:latest myAcrRegistry.azurecr.io/myapp/api:v1.0
docker push myAcrRegistry.azurecr.io/myapp/api:v1.0

Once the push completes, head to the Azure Portal. Navigate to your ACR instance, then Repositories. You'll see your repo listed there, confirming the push succeeded.

ACR Repositories View

The Repositories blade in the Azure Portal lists all pushed repositories — e.g., myapp/api and myapp/frontend. Seeing your repo appear here is the first confirmation that your Docker push succeeded and ACR has received the image layers correctly.

ACR Tags View ACR Tags View

The Tags view within a repository shows every tagged version available — for example, latest, v1.0, and v1.1. This view is where you confirm multi-version tagging is working.

ACR Manifest View ACR Manifest View

The Manifest view shows the image digest (SHA256), OS and architecture (e.g., linux/amd64), compressed image size, and the timestamp of the push. The digest is what Kubernetes ultimately uses to guarantee it pulls the exact image you pushed.

💡 Tip

In production, latest is a trap. It's mutable — any new push can overwrite it — which makes rollbacks unreliable. Instead, tag with the Git commit SHA (myapp/api:a3f9c1d) or a semantic version (myapp/api:v2.3.1). Pin your Kubernetes manifests to an explicit, immutable tag. Your future self, debugging a bad deploy at 2 AM, will thank you.


3. Standing Up the AKS Cluster

With images safely in ACR, it's time to create the Kubernetes cluster that will run them. AKS takes care of the control plane — the API server, scheduler, etcd, controller manager — so you're really just defining the node pool.

bash
# Create a 2-node AKS cluster, attaching ACR at creation time
az aks create \
  --resource-group myResourceGroup \
  --name myAKSCluster \
  --node-count 2 \
  --node-vm-size Standard_B2s \
  --generate-ssh-keys \
  --attach-acr myAcrRegistry

# Merge AKS credentials into your local kubeconfig
az aks get-credentials \
  --resource-group myResourceGroup \
  --name myAKSCluster

# Confirm your nodes are Ready
kubectl get nodes

The kubectl get nodes output should show both nodes in Ready status within a couple of minutes. If a node shows NotReady, give it another 60 seconds — bootstrapping takes a moment after the cluster reports as created.

AKS Overview Blade

The AKS Overview blade surfaces the cluster name, resource group, Kubernetes version, node pool status (Running), and the API server endpoint. The health summary here is a fast sanity check before you start deploying workloads.

💡 Tip

Passing --attach-acr at cluster creation time (or running az aks update --attach-acr <acr-name> on an existing cluster) grants the AKS-managed identity the AcrPull role on the registry. This means every node in the cluster can pull images from your ACR without image pull secrets — fewer secrets to rotate, fewer ImagePullBackOff surprises.


4. Writing and Applying Kubernetes Manifests

This is where the real work happens. Kubernetes manifests are declarative YAML files that describe your desired state. There are two fundamental manifest types for every workload: a Deployment and a Service.

The Deployment Manifest

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-deployment
  labels:
    app: myapp-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: myapp-api
  template:
    metadata:
      labels:
        app: myapp-api
    spec:
      containers:
        - name: api
          image: myAcrRegistry.azurecr.io/myapp/api:v1.0
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
          env:
            - name: NODE_ENV
              value: "production"

The Service Manifest

yaml
apiVersion: v1
kind: Service
metadata:
  name: api-service
spec:
  type: ClusterIP
  selector:
    app: myapp-api       # Must match the Deployment's pod labels exactly
  ports:
    - protocol: TCP
      port: 80           # Port the Service exposes internally
      targetPort: 3000   # Port your container actually listens on

Applying the Manifests

bash
# Apply a single file
kubectl apply -f api-deployment.yaml
kubectl apply -f api-service.yaml

# Or apply an entire directory at once
kubectl apply -f ./manifests/

# Watch the rollout progress
kubectl rollout status deployment/api-deployment

# Check running pods
kubectl get pods -l app=myapp-api
Deployment and Service Overview

The YAML view of the deployed API Service shows the full spec: kind: Service, type: ClusterIP, port mappings (port: 80targetPort: 3000), and the selector labels. Cross-referencing this with your Deployment's pod labels is the fastest way to catch selector mismatches before they cause 503 errors.

⚠️ ImagePullBackOff Checklist

If your pods get stuck, the cause is almost always one of exactly three things:

  1. Wrong image path or tag — The fully-qualified image name must match what's in ACR exactly, including capitalization.
  2. ACR not attached to AKS — Run az aks update --attach-acr <acr-name> and wait for the role assignment to propagate.
  3. Typo in the tag — Run az acr repository show-tags --name <acr> --repository myapp/api to verify the exact tag string.

Run kubectl describe pod <pod-name> and read the Events section — it will tell you exactly which is the culprit.


5. Installing the NGINX Ingress Controller

If you have multiple services, do you really want a separate Azure Load Balancer — and a separate public IP — for each one? The answer is the Ingress Controller: a single pod that owns one external IP, receives all inbound HTTP(S) traffic, and routes it to the correct internal Service.

bash
# Add the ingress-nginx Helm repository
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

# Install the ingress controller into its own namespace
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.replicaCount=2

# Verify the controller pod is running
kubectl get pods -n ingress-nginx

# Get the external IP Azure assigned to the controller's LoadBalancer
kubectl get svc -n ingress-nginx

Wait a minute or two after installation for Azure to provision the public IP. The EXTERNAL-IP column will initially show <pending> — once it shows a real IP, you're ready to define routing rules.

Ingress Controller Pod Overview

The output of kubectl get pods -n ingress-nginx showing the ingress controller pod in Running state. This pod is the traffic gateway for every inbound HTTP request — if it's not running, nothing reaches your services regardless of how correct your Ingress rules are.

Ingress Controller Service Overview

The output of kubectl get svc,ingress -A showing all services across namespaces with their cluster IPs and external IPs, alongside all Ingress resources with their hostnames. This is your bird's-eye view of the entire networking topology.


6. Defining Ingress Rules

An Ingress manifest is the routing table for the NGINX controller. It maps incoming request paths (or hostnames) to the Kubernetes Services that should handle them.

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
  namespace: default
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/use-regex: "true"
spec:
  ingressClassName: nginx
  rules:
    - http:
        paths:
          - path: /api(/|$)(.*)
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80
          - path: /(.*)
            pathType: Prefix
            backend:
              service:
                name: frontend-service
                port:
                  number: 80

💡 Understanding rewrite-target

The rewrite-target: /$2 annotation tells NGINX to strip the path prefix before forwarding the request to the backend. Without it, a request to /api/users would be forwarded as /api/users to your API — which will return 404s if your API is mounted at /users. Always test this behavior with curl -v before assuming it's working correctly.

Host-Based Routing (Optional but Clean)

yaml
spec:
  rules:
    - host: api.myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port:
                  number: 80
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: frontend-service
                port:
                  number: 80

7. Verifying the Deployment End to End

Everything is deployed. Now let's prove it works. Follow a methodical verification sequence — bottom-up, from infrastructure to traffic.

bash
# 1. Confirm all Deployments have desired replicas running
kubectl get deployments

# 2. Confirm all pods are in Running state
kubectl get pods

# 3. Confirm services exist with correct port mappings
kubectl get svc

# 4. Confirm ingress shows a bound address and correct rules
kubectl get ingress

# 5. Hit the endpoint — replace with your actual external IP
curl -v http://<EXTERNAL-IP>/api/health

# Get detailed info on any resource
kubectl describe ingress myapp-ingress
kubectl describe pod <pod-name>

When that curl returns a 200 OK from your API's health endpoint, you've completed the full deployment circuit. Code → Docker image → ACR → AKS → Pod → Service → Ingress → Public IP → HTTP response.

🔧 Troubleshooting Quick Reference

Symptom Most Likely Cause First Command to Run
ImagePullBackOff Wrong image path/tag, or ACR not attached to AKS kubectl describe pod <name> — read Events
CrashLoopBackOff App is crashing at startup (bad env var, missing config) kubectl logs <pod-name> --previous
503 from Ingress Service selector doesn't match pod labels, or wrong targetPort kubectl describe svc <service-name>
Ingress IP shows <pending> Ingress controller not installed, or deployed to wrong namespace kubectl get pods -n ingress-nginx
404 from correct IP Path rewrite misconfigured, or ingressClassName missing kubectl describe ingress <name>

8. Lessons Learned and Next Steps

Key Takeaways

What to Build Next

Topic Description Complexity
Helm Charts Package all manifests into a versioned, parameterized chart Medium
GitHub Actions CI/CD Auto-build and push to ACR on every commit; trigger rollout automatically Medium
Horizontal Pod Autoscaler Scale pod replicas automatically based on CPU/memory metrics Low–Medium
cert-manager + Let's Encrypt Automatic TLS certificate provisioning for your Ingress Medium
Azure Monitor + Log Analytics Container-level logging, metrics dashboards, alerting Low
Kustomize Overlays Manage environment-specific config without duplicating manifests Medium

The gap between "containerized locally" and "running in the cloud" feels wide when you're standing at the edge of it. But it's actually a very walkable bridge — ACR for image storage, AKS for orchestration, a handful of YAML manifests, and NGINX Ingress for routing. Once you've made the crossing once, the mental model clicks into place and subsequent deployments feel much more fluent.

If you're working through your own AKS deployment right now: go section by section. Don't skip the verification steps in between. Check your label selectors twice. And when something goes sideways — and something will — kubectl describe and kubectl logs are almost always sufficient to identify the root cause within a few minutes. The cluster is more transparent than it seems; it just speaks YAML.

This is the first post in what will become a series. Next up: building a GitHub Actions pipeline that automatically builds, tags with the commit SHA, pushes to ACR, and rolls out to AKS — the full CI/CD loop with no manual steps.

✅ Quick Reference: The Full Command Sequence

  1. az acr create → Create the registry
  2. az acr login → Authenticate Docker to ACR
  3. docker tag + docker push → Push your image
  4. az aks create --attach-acr → Create the cluster, wired to ACR
  5. az aks get-credentials → Connect kubectl
  6. kubectl apply -f ./manifests/ → Deploy workloads
  7. helm install ingress-nginx → Install the Ingress Controller
  8. kubectl apply -f ingress.yaml → Define routing rules
  9. kubectl get pods,svc,ingress → Verify everything is healthy
  10. curl http://<EXTERNAL-IP>/api/health → Confirm live traffic