<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rahimah Sulayman</title>
    <description>The latest articles on DEV Community by Rahimah Sulayman (@rahimah_dev).</description>
    <link>https://dev.to/rahimah_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3673037%2Fd084c1b1-6c80-43de-8b42-c48bd0cad2f8.png</url>
      <title>DEV Community: Rahimah Sulayman</title>
      <link>https://dev.to/rahimah_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rahimah_dev"/>
    <language>en</language>
    <item>
      <title>Inside the AWS Default VPC (and Why You'll Want Your Own)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Sun, 15 Nov 2026 19:30:23 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/inside-the-aws-default-vpc-and-why-youll-want-your-own-148n</link>
      <guid>https://dev.to/rahimah_dev/inside-the-aws-default-vpc-and-why-youll-want-your-own-148n</guid>
      <description>&lt;p&gt;Introduction: A guided tour of the default VPC that comes with every AWS account: subnets, route table, internet gateway, NACL and security group. Then how to create your own VPC.&lt;/p&gt;

&lt;p&gt;Every AWS account comes with a network you never asked for. It's already there, already wired to the internet, and it's the reason your first EC2 instance "just worked." It's called the &lt;strong&gt;default VPC&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Before building a VPC from scratch, it helps to know what AWS builds for you. So this article is a tour: first the default VPC and everything attached to it, then how to create your own. By the end, you'll know what each piece does and how they connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's a VPC?
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Virtual Private Cloud (VPC)&lt;/strong&gt; is your own isolated network inside AWS. You choose its IP range, split it into subnets, and control what can talk to what. A VPC lives in one region, and mine is in Europe (Stockholm) &lt;code&gt;eu-north-1&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Coming from Azure?&lt;/strong&gt; A VPC is the equivalent of a &lt;strong&gt;VNet&lt;/strong&gt;, and both live in a single region. The difference is the subnet: in AWS, a subnet lives in &lt;strong&gt;one Availability Zone&lt;/strong&gt;, so the subnet you pick decides which zone your resource runs in. In Azure, a subnet spans the whole region and you choose the zone per resource.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Meet the default VPC
&lt;/h2&gt;

&lt;p&gt;In the console, search for &lt;strong&gt;VPC&lt;/strong&gt; and open &lt;strong&gt;Your VPCs&lt;/strong&gt;. My account lists exactly one, and the &lt;strong&gt;Default VPC&lt;/strong&gt; column says &lt;strong&gt;Yes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bf2f79itpso25nhyeze.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bf2f79itpso25nhyeze.png" alt="rt, NACL, igw" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A few things stand out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IPv4 CIDR &lt;code&gt;172.31.0.0/16&lt;/code&gt;:&lt;/strong&gt; the range of private IP addresses this network can hand out. The &lt;code&gt;/16&lt;/code&gt; means 65,536 addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenancy: default:&lt;/strong&gt; your resources run on shared AWS hardware, which is what you want in almost every case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Main route table&lt;/strong&gt; and &lt;strong&gt;Main network ACL:&lt;/strong&gt; two IDs we'll visit shortly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Scroll the table to the right and you'll find the &lt;strong&gt;VPC ID&lt;/strong&gt;, the &lt;strong&gt;State&lt;/strong&gt; (Available) and &lt;strong&gt;Block Public Access&lt;/strong&gt; (Off).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsmep42nmxgwxn0yus8va.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsmep42nmxgwxn0yus8va.png" alt="The VPC ID, state and Block Public Access columns" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select the row and a details panel opens at the bottom. &lt;strong&gt;DNS resolution&lt;/strong&gt; and &lt;strong&gt;DNS hostnames&lt;/strong&gt; are both &lt;strong&gt;Enabled&lt;/strong&gt;, which is why instances in this VPC get DNS names out of the box.&lt;/p&gt;

&lt;p&gt;For the big picture, click the VPC ID and open the &lt;strong&gt;Resource map&lt;/strong&gt; tab. It shows 3 subnets, 1 route table and 1 network connection, which is an internet gateway. Everything below is just us zooming into this map.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mw1yx6xhi27boc88nqa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mw1yx6xhi27boc88nqa.png" alt="Resource map of the default VPC" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Subnets: one per Availability Zone
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;Subnets&lt;/strong&gt; in the left menu. There are three, one per Availability Zone, all &lt;strong&gt;Available&lt;/strong&gt;, with ranges &lt;code&gt;172.31.0.0/20&lt;/code&gt;, &lt;code&gt;172.31.16.0/20&lt;/code&gt; and &lt;code&gt;172.31.32.0/20&lt;/code&gt;. Each &lt;code&gt;/20&lt;/code&gt; holds 4,096 addresses.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F302k4smuroiie760xf8q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F302k4smuroiie760xf8q.png" alt="The three default subnets" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Open one and check its details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Availability Zone:&lt;/strong&gt; &lt;code&gt;eu-north-1b&lt;/code&gt;. The zone &lt;em&gt;name&lt;/em&gt; is mapped per account, so the &lt;strong&gt;AZ ID&lt;/strong&gt; (&lt;code&gt;eun1-az2&lt;/code&gt;) is the one that always points to the same physical zone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Available IPv4 addresses: 4091.&lt;/strong&gt; AWS reserves 5 addresses in every subnet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default subnet: Yes&lt;/strong&gt; and &lt;strong&gt;Auto-assign public IPv4 address: Yes.&lt;/strong&gt; That last setting is why default subnets are "public": anything you launch here gets a public IP automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd70o43z15s2jf3b1lgbp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd70o43z15s2jf3b1lgbp.png" alt="Details of a default subnet" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The route table: how traffic finds its way
&lt;/h2&gt;

&lt;p&gt;Still inside the subnet, click the &lt;strong&gt;Route table&lt;/strong&gt; tab. There are only two routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;172.31.0.0/16&lt;/code&gt; → &lt;code&gt;local&lt;/code&gt;: traffic inside the VPC stays inside the VPC.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0.0.0.0/0&lt;/code&gt; → &lt;code&gt;igw-...&lt;/code&gt;: everything else goes to the internet gateway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcuiafuomc9rwn2aq4z9m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcuiafuomc9rwn2aq4z9m.png" alt="Route table tab of the subnet showing the local and internet gateway routes" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That second route is what makes a subnet public. Open &lt;strong&gt;Route tables&lt;/strong&gt; in the left menu to see the table itself. It's marked &lt;strong&gt;Main: Yes&lt;/strong&gt;, and it has no explicit subnet associations. Subnets without an explicit association automatically use the main route table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-The%2520main%2520route%2520table%2520and%2520its%2520two%2520routesuploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fvi1vhiowfzjghrcu9m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-The%2520main%2520route%2520table%2520and%2520its%2520two%2520routesuploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4fvi1vhiowfzjghrcu9m.png" alt="The main route table and its two routes" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The internet gateway: the door
&lt;/h2&gt;

&lt;p&gt;Under &lt;strong&gt;Internet gateways&lt;/strong&gt;, you'll find one, with its state set to &lt;strong&gt;Attached&lt;/strong&gt; to our VPC. Think of it this way: the gateway is the door, and the route is the sign pointing to it. Without both, nothing reaches the internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkysokhw19bstaypcp6y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxkysokhw19bstaypcp6y.png" alt="The internet gateway attached to the default VPC" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two layers of firewall: NACL and security group
&lt;/h2&gt;

&lt;p&gt;Back on the subnet, the &lt;strong&gt;Network ACL&lt;/strong&gt; tab says this subnet uses the VPC's default network ACL. Its inbound and outbound rules are the same: rule &lt;code&gt;100&lt;/code&gt; allows all traffic, and rule &lt;code&gt;*&lt;/code&gt; denies everything else. Rules are checked from the lowest number up, so the allow always wins and the deny never triggers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1ujp6lg8n8jnufau6e3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1ujp6lg8n8jnufau6e3.png" alt="Network ACL tab of the subnet with allow and deny rules" width="799" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The NACL's own page shows it's associated with all &lt;strong&gt;3 subnets&lt;/strong&gt; and is the &lt;strong&gt;default&lt;/strong&gt; one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mob0ac7d9649k4n0fn7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8mob0ac7d9649k4n0fn7.png" alt="The default network ACL associated with 3 subnets" width="800" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then there's the &lt;strong&gt;default security group&lt;/strong&gt;. Its outbound rule allows all traffic to anywhere (&lt;code&gt;0.0.0.0/0&lt;/code&gt;), so anything inside can reach out freely.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F097189byk33nuqia44d6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F097189byk33nuqia44d6.png" alt="The default security group's outbound rules" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The difference between the two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;network ACL&lt;/strong&gt; works at the &lt;strong&gt;subnet&lt;/strong&gt; level and is &lt;strong&gt;stateless&lt;/strong&gt;: return traffic needs its own rule.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;security group&lt;/strong&gt; works at the &lt;strong&gt;resource&lt;/strong&gt; level (like an instance) and is &lt;strong&gt;stateful&lt;/strong&gt;: return traffic is allowed automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Azure comparison:&lt;/strong&gt; both roles are played by &lt;strong&gt;Network Security Groups (NSGs)&lt;/strong&gt;, which can be attached to a subnet or to a network interface.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Create your own VPC
&lt;/h2&gt;

&lt;p&gt;Now the fun part. Here are the steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;VPC&lt;/strong&gt; &amp;gt; &lt;strong&gt;Your VPCs&lt;/strong&gt; and click &lt;strong&gt;Create VPC&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3hakcq2a2hrug2130rl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff3hakcq2a2hrug2130rl.png" alt="create VPC" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Under &lt;strong&gt;Resources to create&lt;/strong&gt;, pick one of two options:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC and more:&lt;/strong&gt; a wizard that builds the VPC, subnets, route tables and more in one go. The preview shows &lt;code&gt;project-vpc&lt;/code&gt;, 4 subnets across 2 AZs and 3 route tables, with a default range of &lt;code&gt;10.0.0.0/16&lt;/code&gt;. It's quick and convenient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC only:&lt;/strong&gt; just the network itself, with nothing else. I chose this one, because I want to build each piece by hand.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyubd0u6i8011yjzt49ab.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyubd0u6i8011yjzt49ab.png" alt="The VPC and more option with its preview of subnets and route tables" width="799" height="367"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fill in the form. I set the &lt;strong&gt;Name tag&lt;/strong&gt; to &lt;code&gt;test&lt;/code&gt;, chose &lt;strong&gt;IPv4 CIDR manual input&lt;/strong&gt; with &lt;code&gt;192.168.0.0/16&lt;/code&gt;, selected &lt;strong&gt;No IPv6 CIDR block&lt;/strong&gt;, and kept &lt;strong&gt;Tenancy&lt;/strong&gt; on &lt;strong&gt;Default&lt;/strong&gt;. I left &lt;strong&gt;VPC encryption control&lt;/strong&gt; on &lt;strong&gt;None&lt;/strong&gt;, because the other modes carry additional charges.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create VPC&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mbvm7113gj7fhbu1hgs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2mbvm7113gj7fhbu1hgs.png" alt="The VPC only form filled in with name test and CIDR 192.168.0.0/16" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Picking a CIDR:&lt;/strong&gt; the block size must be between &lt;code&gt;/16&lt;/code&gt; and &lt;code&gt;/28&lt;/code&gt;. Choose a range that doesn't overlap with your other networks (this is why I avoided &lt;code&gt;172.31.0.0/16&lt;/code&gt;), because overlapping ranges make peering or VPN connections painful later.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What you actually get
&lt;/h2&gt;

&lt;p&gt;AWS confirms with a green banner, and the details tell the story. Compared to the default VPC, &lt;strong&gt;Default VPC&lt;/strong&gt; is now &lt;strong&gt;No&lt;/strong&gt;, and &lt;strong&gt;DNS hostnames&lt;/strong&gt; is &lt;strong&gt;Disabled&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Resource map&lt;/strong&gt; is nearly empty:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Subnets (0)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route tables (1):&lt;/strong&gt; the main route table AWS creates automatically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Network connections (0)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcljapu8m48vho5xnog6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqcljapu8m48vho5xnog6.png" alt="The new VPC's details and resource map with zero subnets" width="800" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are no subnets and no internet gateway, so nothing can run in this VPC yet and nothing can reach the internet. To prove the new VPC didn't sneak any subnets in, I opened &lt;strong&gt;Subnets&lt;/strong&gt; again: the list still shows only the 3 default ones.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81yu2vo208zhrcx5opkc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81yu2vo208zhrcx5opkc.png" alt="The Subnets list still showing only the three default subnets" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;default VPC&lt;/strong&gt; is a ready-made network: one subnet per AZ, a route to an internet gateway, and permissive default rules.&lt;/li&gt;
&lt;li&gt;A subnet is &lt;strong&gt;public&lt;/strong&gt; because its route table points to an &lt;strong&gt;internet gateway&lt;/strong&gt;, not because of its name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NACLs&lt;/strong&gt; guard subnets (stateless), and &lt;strong&gt;security groups&lt;/strong&gt; guard resources (stateful).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPC only&lt;/strong&gt; gives you an empty shell, while &lt;strong&gt;VPC and more&lt;/strong&gt; builds a full layout for you.&lt;/li&gt;
&lt;li&gt;A new VPC starts with &lt;strong&gt;no subnets&lt;/strong&gt;, so nothing can be deployed into it yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;NOTE&lt;/strong&gt;: The number of default subnets equals the number of Availability Zones in the region, so it varies by region.&lt;br&gt;
My region is Stockholm (eu-north-1), which has 3 AZs, so you get 3 default subnets.&lt;br&gt;
N. Virginia (us-east-1) has 6 AZs, so its default VPC has 6 default subnets. Most tutorials and screenshots use that region.&lt;br&gt;
Other regions have 2, 3 or 4 AZs, so the count differs again.&lt;br&gt;
A default VPC is per region. Every region has its own, each with its own 172.31.0.0/16 range. If you switch the region selector in the top bar, you’ll see a different default VPC and a different set of default subnets.&lt;br&gt;
You can check this by switching to N. Virginia and opening Subnets. You should see 6, one for each AZ.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Next up in this series, I'll create subnets inside this new VPC and start turning an empty network into a working one.&lt;/p&gt;

&lt;p&gt;Which would you rather see after that: an internet gateway and public subnet, or a private subnet with a NAT gateway? Let me know in the comments.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>vpc</category>
      <category>networking</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Building an Automated CI/CD Pipeline with GitHub Actions: From Testing to Continuous Deployment</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Tue, 10 Nov 2026 08:17:51 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/building-an-automated-cicd-pipeline-with-github-actions-from-testing-to-continuous-deployment-he9</link>
      <guid>https://dev.to/rahimah_dev/building-an-automated-cicd-pipeline-with-github-actions-from-testing-to-continuous-deployment-he9</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Modern software delivery requires more than writing code and pushing it to a repository. Applications need reliable processes for testing, building, and deploying changes with minimal manual intervention.&lt;/p&gt;

&lt;p&gt;In this project, I built an automated CI/CD pipeline using GitHub Actions. What started as a basic workflow evolved into a multi-stage pipeline that validates repository changes, executes a custom reusable composite action, generates a dynamic status card, and deploys a website to GitHub Pages.&lt;/p&gt;

&lt;p&gt;During implementation, I encountered and resolved several issues, including a Bash syntax error, an intentional CI validation failure, a temporary GitHub Actions server error, and a YAML configuration mistake.&lt;/p&gt;

&lt;p&gt;This article documents the implementation, troubleshooting process, and final deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;The objective was to build a working CI/CD pipeline that automates repository validation, website generation, and deployment.&lt;/p&gt;

&lt;p&gt;The project covered five major areas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creating a GitHub repository and configuring a basic workflow.&lt;/li&gt;
&lt;li&gt;Implementing automated repository checks.&lt;/li&gt;
&lt;li&gt;Building and integrating a custom GitHub composite action.&lt;/li&gt;
&lt;li&gt;Creating a multi-stage CI/CD pipeline.&lt;/li&gt;
&lt;li&gt;Deploying the generated website to GitHub Pages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Technologies used:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Git and GitHub&lt;/li&gt;
&lt;li&gt;GitHub Actions&lt;/li&gt;
&lt;li&gt;YAML&lt;/li&gt;
&lt;li&gt;Bash&lt;/li&gt;
&lt;li&gt;HTML and CSS&lt;/li&gt;
&lt;li&gt;GitHub Pages&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Repository Setup and the First GitHub Actions Workflow
&lt;/h2&gt;

&lt;p&gt;I started by creating a public GitHub repository named &lt;code&gt;pipeit-tutorial&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0wnjmq9ffvvpz00b8eb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0wnjmq9ffvvpz00b8eb.png" alt="creating a public GitHub repository named  raw `pipeit-tutorial` endraw " width="800" height="773"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first task was to establish a basic GitHub Actions workflow and understand how workflow execution is triggered by repository events.&lt;/p&gt;

&lt;p&gt;I created &lt;code&gt;.github/workflows/hello.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;say-hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Say hello&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Hello! My first robot is working."&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Show the date and time&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxguxycdrrodqhnq8d9zx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxguxycdrrodqhnq8d9zx.png" alt="created workflow" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow runs whenever code is pushed to the repository. I also included &lt;code&gt;workflow_dispatch&lt;/code&gt;, which allows it to be started manually from the GitHub Actions interface.&lt;/p&gt;

&lt;p&gt;After committing and pushing the file, GitHub Actions detected the workflow and executed its job on an Ubuntu runner.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycgvvertb34aa1v3nedr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycgvvertb34aa1v3nedr.png" alt="job" width="800" height="342"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvjda0e0v59gacyfxlon.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpvjda0e0v59gacyfxlon.png" alt="hello.yml" width="799" height="381"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow successfully printed the greeting and displayed the execution date and time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xtmqa7k2lmbciepxvhu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3xtmqa7k2lmbciepxvhu.png" alt="say hello" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3mare4vtxl7mplgsa3r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3mare4vtxl7mplgsa3r.png" alt="show time and date" width="799" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This established the foundation for the automation that followed.&lt;/p&gt;

&lt;p&gt;In addition to automatic execution on code pushes, I tested the pipeline using GitHub Actions' manual trigger (workflow_dispatch).&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps followed:
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Opened the &lt;strong&gt;pipeit-tutorial&lt;/strong&gt; repository on GitHub.&lt;/li&gt;
&lt;li&gt;Navigated to the &lt;strong&gt;Actions&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Selected the &lt;code&gt;hello workflow&lt;/code&gt; from the workflow list.&lt;/li&gt;
&lt;li&gt;Clicked &lt;strong&gt;Run workflow&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Selected the main branch.&lt;/li&gt;
&lt;li&gt;Clicked &lt;strong&gt;Run workflow&lt;/strong&gt; to initiate execution.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fka0ndfiux5rbexiyah48.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fka0ndfiux5rbexiyah48.png" alt="workflow dispatch" width="799" height="275"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opened the resulting workflow run to inspect the execution logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwstgepbxa757y0edb8j0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwstgepbxa757y0edb8j0.png" alt="workflow dispatch log" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verified that all steps completed successfully, displaying the greeting message and execution date and time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This confirmed that the workflow could be triggered on demand without requiring a new code push.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The workflow_dispatch event provides flexibility by allowing workflows to be executed manually whenever needed, alongside their automated triggers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. Implementing Continuous Integration Checks
&lt;/h2&gt;

&lt;p&gt;The next objective was to introduce a basic validation process.&lt;/p&gt;

&lt;p&gt;A CI pipeline should not simply execute commands, it should also verify that repository changes meet defined requirements.&lt;/p&gt;

&lt;p&gt;For this project, I created a Bash script to check whether the README file contained a Markdown heading.&lt;/p&gt;

&lt;p&gt;I created &lt;code&gt;tests/check.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; -e

&lt;span class="k"&gt;if&lt;/span&gt; grep -q &lt;span class="s1"&gt;'^#'&lt;/span&gt; README.md&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ README has a title"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"❌ README.md needs a title line that starts with #"&lt;/span&gt;
    &lt;span class="nb"&gt;exit&lt;/span&gt; 1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqd5sw1x99v54k5km12x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flqd5sw1x99v54k5km12x.png" alt="bash script" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The script checks whether the README contains a Markdown heading. If the heading exists, the check passes. Otherwise, the script prints an error message and exits with a non-zero status.&lt;/p&gt;

&lt;p&gt;I then created &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ci&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Get my code&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run my checks&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash tests/check.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgqn8iy5ncqppm0pl22y7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgqn8iy5ncqppm0pl22y7.png" alt="ci.yml" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This workflow runs on both pushes and pull requests.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;actions/checkout&lt;/code&gt; step retrieves the repository contents so that the runner can access the files. The next step executes the Bash validation script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Testing the Failure Scenario
&lt;/h3&gt;

&lt;p&gt;To verify that the validation was meaningful, I deliberately removed the Markdown heading from the README file and pushed the change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuwgi1z89h8xvs2ir85bo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuwgi1z89h8xvs2ir85bo.png" alt="deliberate fail" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47kfwuv7q09fw8sjc7fh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F47kfwuv7q09fw8sjc7fh.png" alt="commit changes" width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow failed as expected.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2txg3hezsux5sta672yl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2txg3hezsux5sta672yl.png" alt="workflow failed" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The log displayed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ README.md needs a title line that starts with #
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed that the pipeline was enforcing the condition defined in the test script.&lt;/p&gt;

&lt;p&gt;I restored the README heading and pushed the correction. The next CI execution passed successfully.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxhfb2awjrrr5i1yows3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxhfb2awjrrr5i1yows3.png" alt="restored README heading" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkphw8gpqex0ilbufdmg5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkphw8gpqex0ilbufdmg5.png" alt="successful run" width="799" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A useful CI pipeline must be capable of detecting invalid changes rather than simply reporting success.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Creating a Custom GitHub Composite Action
&lt;/h2&gt;

&lt;p&gt;After establishing the CI checks, I moved on to reusable automation.&lt;/p&gt;

&lt;p&gt;GitHub Actions supports custom actions, allowing teams to package repeated workflow steps into reusable components.&lt;/p&gt;

&lt;p&gt;For this project, I created a custom composite action that generates a dynamic HTML status card.&lt;/p&gt;

&lt;p&gt;The action was created in &lt;code&gt;.github/actions/build-card/action.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wsiri4eglb4ymxrgfrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wsiri4eglb4ymxrgfrm.png" alt="custom composite action" width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa4ufa72znt1b3t0d2dcp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa4ufa72znt1b3t0d2dcp.png" alt="commit" width="800" height="365"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6vfa9m8uqns0unwc9gg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6vfa9m8uqns0unwc9gg.png" alt="successful run" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It accepts a name and an optional message, generates a website inside the &lt;code&gt;site&lt;/code&gt; directory, and writes a summary to the GitHub Actions run page.&lt;/p&gt;

&lt;p&gt;The generated page includes information about the build, such as the commit reference, workflow run number, triggering event, and build time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3cmbomfm3y5dda8s47qo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3cmbomfm3y5dda8s47qo.png" alt="status card" width="799" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The action uses a Bash script and an HTML template to generate the page dynamically. One important implementation detail was the use of an unquoted heredoc (&lt;code&gt;&amp;lt;&amp;lt;EOF&lt;/code&gt;) so that shell variables could be expanded while generating the HTML.&lt;/p&gt;

&lt;p&gt;I also configured the action to expose the generated page path as an output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Integrating the Custom Action into the CI Workflow
&lt;/h3&gt;

&lt;p&gt;Once the composite action was created, I integrated it into the existing CI workflow.&lt;/p&gt;

&lt;p&gt;The additional step was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Try my own action&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/build-card&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rahimah Sulayman&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allowed the workflow to invoke the action directly from the repository.&lt;/p&gt;

&lt;p&gt;After pushing the changes, the CI workflow executed successfully. The custom action generated the HTML status card and displayed its summary in the workflow run.&lt;/p&gt;

&lt;p&gt;I also corrected the generated page footer to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Built with GitHub Actions&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This demonstrated how reusable composite actions can simplify workflows and package related operations into a single component.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Building the Complete CI/CD Pipeline
&lt;/h2&gt;

&lt;p&gt;With the validation script and custom action working, I moved on to the main pipeline.&lt;/p&gt;

&lt;p&gt;The goal was to combine testing, website generation, and deployment into a single automated workflow.&lt;/p&gt;

&lt;p&gt;Before configuring deployment, I enabled GitHub Pages in the repository settings and selected GitHub Actions as the deployment source.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faq40q2nsbaykz0x6gk36.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faq40q2nsbaykz0x6gk36.png" alt="github pages" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I then created &lt;code&gt;.github/workflows/pipeline.yml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The workflow was configured with three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;test&lt;/code&gt;: checks the repository.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;build&lt;/code&gt;: generates the website and packages it for deployment.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deploy&lt;/code&gt;: publishes the website to GitHub Pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pipeline also included a scheduled trigger and a manual trigger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline Configuration
&lt;/h3&gt;

&lt;p&gt;The workflow uses the following structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pipeline&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'0 8 * * *'&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pages&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run my checks&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash tests/check.sh&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build my status card with my own action&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/build-card&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rahimah Sulayman&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Updated automatically through continuous deployment&lt;/span&gt;

      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Package the website&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-pages-artifact@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;site&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-pages&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.deployment.outputs.page_url }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Publish to GitHub Pages&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deployment&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/deploy-pages@v5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6w3rv9mj4goj696phnv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft6w3rv9mj4goj696phnv.png" alt="pipeline.yml" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftdathtxphphh9cnwgizj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftdathtxphphh9cnwgizj.png" alt="commit" width="799" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the Job Dependencies
&lt;/h3&gt;

&lt;p&gt;One important feature of this pipeline is the use of the &lt;code&gt;needs&lt;/code&gt; keyword.&lt;/p&gt;

&lt;p&gt;The build job depends on the successful completion of the test job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similarly, deployment depends on the build job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This establishes the execution order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test → Build → Deploy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The deployment process cannot proceed unless the preceding stages complete successfully.&lt;/p&gt;

&lt;p&gt;The workflow also defines permissions required for GitHub Pages deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;concurrency&lt;/code&gt; configuration ensures that only the relevant Pages deployment workflow runs at a time, cancelling an older in-progress run when a newer one takes its place.&lt;/p&gt;

&lt;p&gt;The scheduled trigger runs the pipeline daily at 08:00 UTC, while &lt;code&gt;workflow_dispatch&lt;/code&gt; allows manual execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Deploying the Website to GitHub Pages
&lt;/h2&gt;

&lt;p&gt;After configuring the pipeline, I pushed the workflow to the repository.&lt;/p&gt;

&lt;p&gt;GitHub Actions executed the three jobs in sequence.&lt;/p&gt;

&lt;p&gt;The test job validated the repository, the build job generated the website and packaged the &lt;code&gt;site&lt;/code&gt; directory, and the deploy job published the artifact to GitHub Pages.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rec8ciaukmxe1f2zcif.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rec8ciaukmxe1f2zcif.png" alt="test, build, deploy" width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The workflow completed successfully.&lt;/p&gt;

&lt;p&gt;The deployed website was available at:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://rahimahisah17.github.io/pipeit-tutorial/" rel="noopener noreferrer"&gt;https://rahimahisah17.github.io/pipeit-tutorial/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwquf1oa5cjoa9nmgydk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnwquf1oa5cjoa9nmgydk.png" alt="deployed website" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At this point, the project had moved beyond basic CI automation. It now had a workflow that could validate changes, generate a website, and deploy the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Verifying Automatic Deployment
&lt;/h2&gt;

&lt;p&gt;A successful initial deployment was not enough. I also needed to confirm that subsequent changes would automatically reach the live website.&lt;/p&gt;

&lt;p&gt;To test this, I updated the custom action's message input in the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Updated automatically through continuous deployment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqx1msi52r7w58yzhjq0d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqx1msi52r7w58yzhjq0d.png" alt="commit updated .yml" width="800" height="357"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I committed and pushed the change to the &lt;code&gt;main&lt;/code&gt; branch.&lt;/p&gt;

&lt;p&gt;This triggered the pipeline automatically.&lt;/p&gt;

&lt;p&gt;GitHub Actions executed the test, build, and deployment jobs again. After the deployment completed, I refreshed the live website and confirmed that the updated content was visible.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faxojpnj08u2br6n429un.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faxojpnj08u2br6n429un.png" alt="successful run" width="799" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5u0u8uk3onedqmbtzre.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5u0u8uk3onedqmbtzre.png" alt="website" width="800" height="304"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This verified the continuous deployment process.&lt;/p&gt;

&lt;p&gt;The final workflow supported both initial publication and automatic updates whenever changes were pushed to the configured branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Troubleshooting and Lessons Learned
&lt;/h2&gt;

&lt;p&gt;The implementation was not entirely error-free. Several issues occurred during development, and resolving them was an important part of completing the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 1: Bash Syntax Error
&lt;/h3&gt;

&lt;p&gt;During the initial implementation of &lt;code&gt;tests/check.sh&lt;/code&gt;, the conditional statement contained a syntax error.&lt;/p&gt;

&lt;p&gt;The script did not execute as expected because the &lt;code&gt;if&lt;/code&gt; statement was not structured correctly.&lt;/p&gt;

&lt;p&gt;I corrected the conditional block and ensured that the &lt;code&gt;then&lt;/code&gt;, &lt;code&gt;else&lt;/code&gt;, and &lt;code&gt;fi&lt;/code&gt; keywords were properly placed.&lt;/p&gt;

&lt;p&gt;The corrected script subsequently executed successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Shell scripts require careful attention to syntax. A small structural mistake can cause an entire CI job to fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 2: Deliberate README Validation Failure
&lt;/h3&gt;

&lt;p&gt;I intentionally removed the heading from the README to test whether the CI workflow could detect an invalid repository change.&lt;/p&gt;

&lt;p&gt;The workflow failed and displayed the expected validation message.&lt;/p&gt;

&lt;p&gt;This was not an unexpected pipeline defect. It was a controlled test to confirm that the validation script was working.&lt;/p&gt;

&lt;p&gt;After restoring the heading, the workflow passed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Testing failure conditions is just as important as testing successful execution. A pipeline should fail when its defined requirements are not met.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 3: GitHub Actions Internal Server Error
&lt;/h3&gt;

&lt;p&gt;During the execution of the initial workflow, GitHub Actions returned a temporary internal server error.&lt;/p&gt;

&lt;p&gt;The issue was not caused by the repository's workflow configuration.&lt;/p&gt;

&lt;p&gt;I reran the failed jobs, and the workflow completed successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Not every CI/CD failure originates from application code or configuration. External platform issues can also affect workflow execution, so it is important to inspect the error details before changing working code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issue 4: Duplicate YAML Key
&lt;/h3&gt;

&lt;p&gt;While updating the message passed to the custom action, I accidentally introduced a duplicate key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;message: Updated automatically through continuous deployment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This resulted in a YAML configuration error.&lt;/p&gt;

&lt;p&gt;I corrected the line to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Updated automatically through continuous deployment&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After committing the correction, the pipeline executed successfully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; YAML configuration must be structured carefully. Duplicate or incorrectly formatted keys can prevent a workflow from being parsed and executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Repository Structure
&lt;/h2&gt;

&lt;p&gt;The completed repository is organized as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pipeit-tutorial/
│
├── .github/
│   ├── actions/
│   │   └── build-card/
│   │       └── action.yml
│   │
│   └── workflows/
│       ├── hello.yml
│       ├── ci.yml
│       └── pipeline.yml
│
├── tests/
│   └── check.sh
│
├── README.md
└── site/
    └── index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;site/index.html&lt;/code&gt; file is generated by the custom action during the build process.&lt;/p&gt;

&lt;p&gt;The repository structure separates workflow definitions, reusable actions, validation scripts, documentation, and generated website content.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Project Release and Documentation
&lt;/h2&gt;

&lt;p&gt;After completing the implementation, I updated the repository documentation to explain the project, its architecture, technologies, and deployment process.&lt;/p&gt;

&lt;p&gt;I also published a GitHub release:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;code&gt;v1.0.0&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This release marks the completed version of the project and provides a reference point for the implementation.&lt;/p&gt;

&lt;p&gt;The repository includes the workflow definitions, custom composite action, validation script, and documentation required to understand how the pipeline works.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What This Project Demonstrates
&lt;/h2&gt;

&lt;p&gt;The project provided practical experience with several important DevOps and CI/CD concepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous Integration:&lt;/strong&gt; Automatically validating repository changes through GitHub Actions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflow Triggers:&lt;/strong&gt; Running automation in response to pushes, scheduled events, and manual requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusable Automation:&lt;/strong&gt; Packaging repeated operations into a custom composite action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job Dependencies:&lt;/strong&gt; Controlling pipeline execution using &lt;code&gt;needs&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Artifact Management:&lt;/strong&gt; Packaging generated website content for deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous Deployment:&lt;/strong&gt; Automatically publishing updates to GitHub Pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Troubleshooting:&lt;/strong&gt; Diagnosing shell script errors, YAML configuration issues, validation failures, and external platform errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also reinforced the importance of designing pipelines that provide clear feedback and stop execution when validation requirements are not satisfied.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building this CI/CD pipeline was a practical exercise in bringing together the essential stages of automated software delivery.&lt;/p&gt;

&lt;p&gt;What started as a simple GitHub Actions workflow evolved into a multi-stage pipeline capable of validating repository changes, executing reusable automation, generating dynamic website content, and deploying updates automatically.&lt;/p&gt;

&lt;p&gt;One of the most valuable aspects of the project was troubleshooting the issues encountered during implementation. Understanding why a workflow fails, identifying whether the problem comes from the script, configuration, or external platform, and applying the appropriate correction are important parts of working with automation.&lt;/p&gt;

&lt;p&gt;The completed project demonstrates how GitHub Actions can be used to build structured and reusable CI/CD workflows without requiring a complex deployment environment.&lt;/p&gt;

&lt;p&gt;It also provides a foundation for future improvements, such as introducing more comprehensive automated tests, adding security and dependency scanning, and extending the deployment process to a containerized application.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline is built, tested, and deployed.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/rahimahisah17/pipeit-tutorial" rel="noopener noreferrer"&gt;https://github.com/rahimahisah17/pipeit-tutorial&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live Website:&lt;/strong&gt; &lt;a href="https://rahimahisah17.github.io/pipeit-tutorial/" rel="noopener noreferrer"&gt;https://rahimahisah17.github.io/pipeit-tutorial/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Release:&lt;/strong&gt; &lt;a href="https://github.com/rahimahisah17/pipeit-tutorial/releases/tag/v1.0.0" rel="noopener noreferrer"&gt;https://github.com/rahimahisah17/pipeit-tutorial/releases/tag/v1.0.0&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This project demonstrates how GitHub Actions can automate a complete software delivery workflow, from code validation to deployment.&lt;/p&gt;

&lt;p&gt;By the end of the implementation, I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Created GitHub Actions workflows for basic automation and continuous integration.&lt;/li&gt;
&lt;li&gt;Implemented a Bash script to validate repository documentation.&lt;/li&gt;
&lt;li&gt;Built a reusable custom composite action to generate a dynamic HTML status card.&lt;/li&gt;
&lt;li&gt;Configured a multi-job pipeline with testing, building, and deployment stages.&lt;/li&gt;
&lt;li&gt;Enabled automated deployment to GitHub Pages.&lt;/li&gt;
&lt;li&gt;Verified that changes pushed to the &lt;code&gt;main&lt;/code&gt; branch triggered a new deployment.&lt;/li&gt;
&lt;li&gt;Documented troubleshooting experiences and published the project as a GitHub release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a working CI/CD implementation that demonstrates workflow automation, reusable actions, pipeline dependencies, and continuous deployment using GitHub Actions.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>automation</category>
    </item>
    <item>
      <title>From Zero to Live: Containerise a Python App, Deploy It to Azure Container Apps and Automate It with GitHub Actions</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:25:20 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/from-zero-to-live-containerise-a-python-app-deploy-it-to-azure-container-apps-and-automate-it-4g9g</link>
      <guid>https://dev.to/rahimah_dev/from-zero-to-live-containerise-a-python-app-deploy-it-to-azure-container-apps-and-automate-it-4g9g</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I recently completed the &lt;strong&gt;ShipIt Weekend: Deploy Your First App to Azure&lt;/strong&gt; lab. It has three missions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build it:&lt;/strong&gt; write a small web app, package it in a Docker image and run it locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship it:&lt;/strong&gt; publish the image to GitHub Container Registry (GHCR) and run it on Azure Container Apps with a public HTTPS URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate it:&lt;/strong&gt; add a GitHub Actions pipeline that builds, pushes and deploys on every push to &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I worked through everything from a Linux terminal (Ubuntu on WSL, on a Windows laptop). This is not a polished "everything worked first time" tutorial. I made a few mistakes along the way: I named my repository differently from what the lab expected, my pipeline failed on its first run, and my first credential claim was rejected. I've kept those moments in, because that is where most of the learning happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A GitHub account&lt;/li&gt;
&lt;li&gt;An Azure account with an active subscription&lt;/li&gt;
&lt;li&gt;Docker, Git and the Azure CLI installed in a Linux-style terminal (WSL works well on Windows)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Mission 1: Build it, containerise a web app
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create the project and a Git repository
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkdir -p ~/shipit-app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/shipit-app
git init -b main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mkdir -p&lt;/code&gt; creates the folder (and doesn't complain if it already exists), and &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; runs &lt;code&gt;cd&lt;/code&gt; only if that succeeded.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git init -b main&lt;/code&gt; turns the folder into a Git repository and names the first branch &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ssh6kvky95z17tqlncd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ssh6kvky95z17tqlncd.png" alt="Creating the project folder and initialising Git" width="800" height="93"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Write the web app
&lt;/h3&gt;

&lt;p&gt;The app uses only Python's standard library, so there is nothing to install and less that can break.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cat &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; app.py &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'EOF'&lt;/span&gt;
import os
from http.server import BaseHTTPRequestHandler, HTTPServer

MESSAGE &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Hello from ShipIt Weekend!"&lt;/span&gt;


class Handler&lt;span class="o"&gt;(&lt;/span&gt;BaseHTTPRequestHandler&lt;span class="o"&gt;)&lt;/span&gt;:
    def do_GET&lt;span class="o"&gt;(&lt;/span&gt;self&lt;span class="o"&gt;)&lt;/span&gt;:
        body &lt;span class="o"&gt;=&lt;/span&gt; f&lt;span class="s2"&gt;"&amp;lt;h1&amp;gt;{MESSAGE}&amp;lt;/h1&amp;gt;&amp;lt;p&amp;gt;Built with Docker, shipped to Azure, automated with GitHub Actions.&amp;lt;/p&amp;gt;"&lt;/span&gt;.encode&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        self.send_response&lt;span class="o"&gt;(&lt;/span&gt;200&lt;span class="o"&gt;)&lt;/span&gt;
        self.send_header&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Content-Type"&lt;/span&gt;, &lt;span class="s2"&gt;"text/html; charset=utf-8"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        self.send_header&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Content-Length"&lt;/span&gt;, str&lt;span class="o"&gt;(&lt;/span&gt;len&lt;span class="o"&gt;(&lt;/span&gt;body&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        self.end_headers&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        self.wfile.write&lt;span class="o"&gt;(&lt;/span&gt;body&lt;span class="o"&gt;)&lt;/span&gt;


HTTPServer&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0"&lt;/span&gt;, int&lt;span class="o"&gt;(&lt;/span&gt;os.environ.get&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"PORT"&lt;/span&gt;, &lt;span class="s2"&gt;"8080"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;, Handler&lt;span class="o"&gt;)&lt;/span&gt;.serve_forever&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cat &amp;gt; app.py &amp;lt;&amp;lt; 'EOF'&lt;/code&gt; pattern is a here-document: everything up to the &lt;code&gt;EOF&lt;/code&gt; line is written into &lt;code&gt;app.py&lt;/code&gt;. The quotes around &lt;code&gt;'EOF'&lt;/code&gt; stop the shell from interpreting anything inside. The server listens on port 8080 (or whatever &lt;code&gt;PORT&lt;/code&gt; says) and answers every request with a small HTML page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9vfrn1vdc6vqtx899yn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq9vfrn1vdc6vqtx899yn.png" alt="Writing app.py with a here-document" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Write the Dockerfile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cat &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; Dockerfile &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'EOF'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; python:3.12-slim
&lt;span class="k"&gt;WORKDIR&lt;/span&gt; /app
&lt;span class="k"&gt;COPY&lt;/span&gt; app.py .
&lt;span class="k"&gt;ENV&lt;/span&gt; &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8080
&lt;span class="k"&gt;EXPOSE&lt;/span&gt; 8080
&lt;span class="k"&gt;USER&lt;/span&gt; nobody
&lt;span class="k"&gt;CMD&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;, &lt;span class="s2"&gt;"app.py"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;FROM python:3.12-slim&lt;/code&gt; starts from a small official Python image.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WORKDIR /app&lt;/code&gt; and &lt;code&gt;COPY app.py .&lt;/code&gt; put the app in the container.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ENV PORT=8080&lt;/code&gt; and &lt;code&gt;EXPOSE 8080&lt;/code&gt; set and document the port.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;USER nobody&lt;/code&gt; runs the app as an unprivileged user instead of root. This is a small but important security habit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CMD&lt;/code&gt; is the command that runs when the container starts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4ell0m3wkkcf52azyrt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4ell0m3wkkcf52azyrt.png" alt="Writing the Dockerfile" width="799" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Build and run it locally
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build -t shipit-app .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker build&lt;/code&gt; reads the Dockerfile in the current folder (the &lt;code&gt;.&lt;/code&gt;) and &lt;code&gt;-t shipit-app&lt;/code&gt; names the image. The first build downloads the Python base image, so it took about 16 seconds for me.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v3an3vr2793pk23dpad.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1v3an3vr2793pk23dpad.png" alt="Building the Docker image" width="800" height="339"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run -d --rm -p 8080:8080 --name shipit shipit-app
curl http://localhost:8080
docker stop shipit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-d&lt;/code&gt; runs the container in the background, &lt;code&gt;--rm&lt;/code&gt; deletes it when it stops, &lt;code&gt;-p 8080:8080&lt;/code&gt; maps the port to your machine, and &lt;code&gt;--name shipit&lt;/code&gt; gives it a name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; fetches the page, and &lt;code&gt;docker stop&lt;/code&gt; shuts the container down.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app answered with the HTML heading, so the container worked.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pjhfvv02f5eb2fi9vdx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5pjhfvv02f5eb2fi9vdx.png" alt="Running the container, testing it with curl and stopping it" width="798" height="114"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Commit, create a GitHub repository and push
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add app.py Dockerfile
git commit -m &lt;span class="s2"&gt;"feat: containerised web app"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before pushing, I created an empty repository on GitHub. This is where I made my first mistake: &lt;strong&gt;I named it &lt;code&gt;shipit&lt;/code&gt;, but the lab expects &lt;code&gt;shipit-app&lt;/code&gt;&lt;/strong&gt;. I didn't notice until much later, and it caused a confusing problem at the end (more on that in Mission 3). I connected my local repo to it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin git@github.com:rahimahisah17/shipit.git
git push -u origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git remote add origin &amp;lt;url&amp;gt;&lt;/code&gt; registers the GitHub repo under the nickname &lt;code&gt;origin&lt;/code&gt;. I used the SSH URL because I already had SSH keys set up.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git push -u origin main&lt;/code&gt; uploads the &lt;code&gt;main&lt;/code&gt; branch and links it to &lt;code&gt;origin/main&lt;/code&gt;, so a plain &lt;code&gt;git push&lt;/code&gt; works from now on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqpz1vrpey7qgmq3i814.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frqpz1vrpey7qgmq3i814.png" alt="Committing, adding the remote and pushing to the shipit repository" width="799" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; If you follow the lab, name your GitHub repository &lt;code&gt;shipit-app&lt;/code&gt; from the start. The final credential check expects that name.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Mission 2: Ship it, GHCR and Azure Container Apps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Set your GitHub username and link the image to the repo
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;GH_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rahimahisah17
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LABEL org.opencontainers.image.source=https://github.com/$GH_USER/shipit"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; Dockerfile
git commit -am &lt;span class="s2"&gt;"chore: link image to repository"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;export GH_USER=...&lt;/code&gt; stores your username in a variable, so later commands don't need it typed out. It must be lowercase.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;LABEL&lt;/code&gt; line adds the repo's URL to the image as metadata, so GitHub can link the package to the repository. &lt;code&gt;&amp;gt;&amp;gt;&lt;/code&gt; appends to the file (a single &lt;code&gt;&amp;gt;&lt;/code&gt; would overwrite it).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git commit -am&lt;/code&gt; stages and commits changes to tracked files in one step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Small mistake #1:&lt;/strong&gt; When I first ran &lt;code&gt;echo $GH_USER&lt;/code&gt; it printed nothing. I had opened a new terminal session, and environment variables don't survive that. I ran &lt;code&gt;export&lt;/code&gt; again and it was fine. If a command suddenly produces strange image names or URLs, check that your variables are still set.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw20bewzlk605b13f6zve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw20bewzlk605b13f6zve.png" alt="Setting GH_USER, adding the LABEL and pushing" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Log in to GitHub Container Registry
&lt;/h3&gt;

&lt;p&gt;GHCR is free for public images. Docker needs a personal access token to log in:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Developer settings&lt;/strong&gt; → &lt;strong&gt;Personal access tokens&lt;/strong&gt; → &lt;strong&gt;Tokens (classic)&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Generate a token with only the &lt;strong&gt;&lt;code&gt;write:packages&lt;/code&gt;&lt;/strong&gt; scope and a short expiry.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; YOUR_TOKEN &lt;span class="o"&gt;|&lt;/span&gt; docker login ghcr.io -u &lt;span class="nv"&gt;$GH_USER&lt;/span&gt; --password-stdin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--password-stdin&lt;/code&gt; makes Docker read the token from the pipe instead of from the command line, which is safer. I got &lt;code&gt;Login Succeeded&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw3wfymp5jkfuu2p8sk3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsw3wfymp5jkfuu2p8sk3.png" alt="Logging in to ghcr.io (token blurred)" width="800" height="49"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; Treat the token like a password. Don't share it, don't screenshot it unblurred, and delete it when you've finished.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Build, tag and push the image
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build -t ghcr.io/&lt;span class="nv"&gt;$GH_USER&lt;/span&gt;/shipit-app:v1 .
docker push ghcr.io/&lt;span class="nv"&gt;$GH_USER&lt;/span&gt;/shipit-app:v1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag has four parts: the registry (&lt;code&gt;ghcr.io&lt;/code&gt;), the owner (your username), the image name (&lt;code&gt;shipit-app&lt;/code&gt;) and the version (&lt;code&gt;v1&lt;/code&gt;). Because the layers were cached, the build took 2.6 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphdug1pufso0yj5wvcwd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphdug1pufso0yj5wvcwd.png" alt="Building the image with the GHCR tag" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The push uploaded each layer and ended with the image digest.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjqgsxewidio93234v0x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjqgsxewidio93234v0x.png" alt="Pushing the image to GHCR" width="800" height="237"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Make the package public
&lt;/h3&gt;

&lt;p&gt;A new GHCR package is &lt;strong&gt;private by default&lt;/strong&gt;, as the badge on its page shows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa18iazqor9d3l6smtfhm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa18iazqor9d3l6smtfhm.png" alt="The package page showing it is Private" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Azure has to pull the image without a password, so it must be public. In the package's &lt;strong&gt;Package settings&lt;/strong&gt;, go to the &lt;strong&gt;Danger Zone&lt;/strong&gt;, click &lt;strong&gt;Change visibility&lt;/strong&gt;, choose &lt;strong&gt;Public&lt;/strong&gt; and type the package name to confirm.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6h7h9yi6ul6sekbyu0b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs6h7h9yi6ul6sekbyu0b.png" alt="Changing the package visibility to Public" width="800" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Afterwards the Danger Zone says "This package is currently public."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4442fgdi3qb0mp35pnvs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4442fgdi3qb0mp35pnvs.png" alt="The package is now public" width="800" height="371"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Sign in to Azure and prepare the subscription
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az login --use-device-code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--use-device-code&lt;/code&gt; gives you a code and a URL to open in a browser, which works well in WSL, where a browser often can't open automatically. You'll see a couple of yellow warnings ("Attempted too soon" and a note about a tenant without subscriptions). They look alarming but are harmless: the login still completes and lists your subscription.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx4m1lt90s9zshew110m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzx4m1lt90s9zshew110m.png" alt="Signing in with a device code" width="800" height="312"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.OperationalInsights --wait
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The first command installs the CLI extension that provides the &lt;code&gt;az containerapp&lt;/code&gt; commands. Mine was already installed and up to date, which is fine.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;provider register&lt;/code&gt; commands switch on the services Container Apps needs (the Container Apps service itself and Log Analytics for logs). A new subscription must do this once. With &lt;code&gt;--wait&lt;/code&gt; they print nothing when they succeed, and the prompt simply returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5m2osrv69dvia3pb8as.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq5m2osrv69dvia3pb8as.png" alt="Installing the extension and registering the providers" width="799" height="168"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Create a resource group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create --name shipit-rg --location westeurope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A resource group is a folder-like container for everything in this lab, which makes cleanup easy at the end. The JSON output showed &lt;code&gt;"provisioningState": "Succeeded"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83shm4jx5gtic112ye8w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F83shm4jx5gtic112ye8w.png" alt="Creating the resource group" width="799" height="247"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Deploy to Azure Container Apps
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az containerapp up \
  --name shipit-app \
  --resource-group shipit-rg \
  --location westeurope \
  --image ghcr.io/&lt;span class="nv"&gt;$GH_USER&lt;/span&gt;/shipit-app:v1 \
  --ingress external \
  --target-port 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one command creates a Container Apps environment, a Log Analytics workspace and the app itself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--image&lt;/code&gt; points at the image in GHCR (this works because the package is public).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--ingress external&lt;/code&gt; gives the app a public address.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--target-port 8080&lt;/code&gt; tells Azure which port inside the container receives traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;It's slow.&lt;/strong&gt; Mine ran for over five minutes on a spinner while Azure created the environment and workspace. Don't press Ctrl+C: stopping halfway can leave half-created resources behind. When it finished, it printed the public URL.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Few2wn1thaiwzj17f46zy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Few2wn1thaiwzj17f46zy.png" alt="Deploying with az containerapp up" width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Test the live app
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://&lt;span class="nv"&gt;$(&lt;/span&gt;az containerapp show --name shipit-app --resource-group shipit-rg --query properties.configuration.ingress.fqdn -o tsv&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$APP_URL&lt;/span&gt;
curl &lt;span class="nv"&gt;$APP_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;az containerapp show&lt;/code&gt; fetches the app's details, and &lt;code&gt;--query ... -o tsv&lt;/code&gt; picks out only the hostname as plain text.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$(...)&lt;/code&gt; drops that result into the variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The warning about the extension altering the command's behaviour is harmless. &lt;code&gt;curl&lt;/code&gt; returned my page from the public internet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdx3lgna4s87x9xg2bb1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdx3lgna4s87x9xg2bb1.png" alt="Reading the app URL and testing it with curl" width="799" height="145"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Mission 3: Automate it, CI/CD with GitHub Actions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Give GitHub Actions access, with least privilege
&lt;/h3&gt;

&lt;p&gt;The pipeline needs permission to update the app, but only that app's resource group.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="kd"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;SUB_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$(&lt;/span&gt;az account show --query id -o tsv&lt;span class="o"&gt;)&lt;/span&gt;
az ad sp create-for-rbac --name shipit-github --role contributor \
  --scopes /subscriptions/&lt;span class="nv"&gt;$SUB_ID&lt;/span&gt;/resourceGroups/shipit-rg --json-auth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;az ad sp create-for-rbac&lt;/code&gt; creates a &lt;strong&gt;service principal&lt;/strong&gt;, an identity for automation (like a robot account).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--role contributor&lt;/code&gt; lets it create and update resources.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--scopes ...&lt;/code&gt; limits that to the &lt;code&gt;shipit-rg&lt;/code&gt; resource group only. This is the least-privilege idea in practice.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json-auth&lt;/code&gt; prints the credentials in the JSON format the GitHub login action expects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output contains a &lt;code&gt;clientSecret&lt;/code&gt;. You'll see a warning about it, plus a deprecation notice for &lt;code&gt;--json-auth&lt;/code&gt; (it still worked for me).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feq82cz1zhkfr7z0cfiuz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feq82cz1zhkfr7z0cfiuz.png" alt="Creating the service principal (secrets blurred)" width="800" height="175"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Copy the whole JSON and save it as a GitHub secret: your repo → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Secrets and variables&lt;/strong&gt; → &lt;strong&gt;Actions&lt;/strong&gt; → &lt;strong&gt;New repository secret&lt;/strong&gt;, with the name &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fti20ab7ujhl5p55u93ou.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fti20ab7ujhl5p55u93ou.png" alt="Saving the JSON as the AZURE_CREDENTIALS secret (blurred)" width="800" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Write the pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkdir -p .github/workflows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The folder must be named exactly &lt;code&gt;.github/workflows&lt;/code&gt;, because that's where GitHub looks for workflows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcipksyjqgbovnbabtcpl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcipksyjqgbovnbabtcpl.png" alt="Creating the workflows folder" width="775" height="59"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cat &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .github/workflows/deploy.yml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s1"&gt;'EOF'&lt;/span&gt;
name: deploy

on:
  push:
    branches: &lt;span class="o"&gt;[&lt;/span&gt;main&lt;span class="o"&gt;]&lt;/span&gt;
  workflow_dispatch:

permissions:
  contents: &lt;span class="nb"&gt;read&lt;/span&gt;
  packages: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log &lt;span class="k"&gt;in&lt;/span&gt; to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: &lt;span class="nv"&gt;${{ github.actor }&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
          password: &lt;span class="nv"&gt;${{ secrets.GITHUB_TOKEN }&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

      - name: Build and push the image
        run: &lt;span class="o"&gt;|&lt;/span&gt;
          &lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ghcr.io/&lt;span class="nv"&gt;${GITHUB_REPOSITORY_OWNER,,}&lt;/span&gt;/shipit-app:&lt;span class="nv"&gt;${GITHUB_SHA::7}&lt;/span&gt;
          docker build -t &lt;span class="s2"&gt;"$IMAGE"&lt;/span&gt; .
          docker push &lt;span class="s2"&gt;"$IMAGE"&lt;/span&gt;
          &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"IMAGE=$IMAGE"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"$GITHUB_ENV"&lt;/span&gt;

      - name: Log &lt;span class="k"&gt;in&lt;/span&gt; to Azure
        uses: azure/login@v2
        with:
          creds: &lt;span class="nv"&gt;${{ secrets.AZURE_CREDENTIALS }&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

      - name: Deploy to Azure Container Apps
        run: az containerapp update --name shipit-app --resource-group shipit-rg --image &lt;span class="s2"&gt;"$IMAGE"&lt;/span&gt;
EOF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the workflow does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on:&lt;/code&gt;&lt;/strong&gt; runs it on every push to &lt;code&gt;main&lt;/code&gt;, and lets you start it manually (&lt;code&gt;workflow_dispatch&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;permissions:&lt;/code&gt;&lt;/strong&gt; lets it read the code and write packages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log in to GHCR:&lt;/strong&gt; uses a temporary built-in token, &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, so no personal token is needed in the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build and push:&lt;/strong&gt; tags the image with the first 7 characters of the commit ID, so every deployment is traceable to a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log in to Azure:&lt;/strong&gt; uses the &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy:&lt;/strong&gt; &lt;code&gt;az containerapp update&lt;/code&gt; points the app at the new image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9okkrtxrv4o9905kee3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9okkrtxrv4o9905kee3.png" alt="Writing the workflow file" width="800" height="531"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The terminal in this screenshot looks jumbled because of how it displayed my paste, but the file itself was fine, as the workflow runs below prove.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3: Change the app and push
&lt;/h3&gt;

&lt;p&gt;I changed the app's message so the pipeline had something new to deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sed -i.bak &lt;span class="s1"&gt;'s/Hello from ShipIt Weekend!/Shipped automatically by GitHub Actions!/'&lt;/span&gt; app.py &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; rm app.py.bak
grep MESSAGE app.py
git add .github/workflows/deploy.yml app.py
git commit -m &lt;span class="s2"&gt;"ci: deploy to Azure on every push"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sed -i.bak 's/old/new/'&lt;/code&gt; does an in-place find-and-replace and keeps a backup, which &lt;code&gt;rm&lt;/code&gt; then deletes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep MESSAGE app.py&lt;/code&gt; confirms the change before I committed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzp9s0rwiizv2b8sp96g7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzp9s0rwiizv2b8sp96g7.png" alt="Editing app.py, committing and pushing" width="800" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The first pipeline run fails
&lt;/h3&gt;

&lt;p&gt;I opened the &lt;strong&gt;Actions&lt;/strong&gt; tab and the run had failed after just 19 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhpzdggq76c1otzpda6fq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhpzdggq76c1otzpda6fq.png" alt="The first workflow run fails" width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I opened the failing job and found the red step: &lt;strong&gt;Build and push the image&lt;/strong&gt;. The image had built fine, but the log ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;denied: permission_denied: write_package
Error: Process completed with exit code 1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it happened:&lt;/strong&gt; I had first pushed the &lt;code&gt;shipit-app&lt;/code&gt; image from my laptop using my personal token, so the package belongs to my account. The workflow's built-in &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is a different identity, and it had no permission to write to that existing package.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The fix, give the repository write access to the package
&lt;/h3&gt;

&lt;p&gt;In the package's &lt;strong&gt;Package settings&lt;/strong&gt; → &lt;strong&gt;Manage Actions access&lt;/strong&gt;, I clicked &lt;strong&gt;Add Repository&lt;/strong&gt;, chose my repo and gave it the &lt;strong&gt;Write&lt;/strong&gt; role. That grants only this one repository permission to push to this one package.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fglsgjtirgtejwhjn9r68.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fglsgjtirgtejwhjn9r68.png" alt="Giving the repository Write access to the package" width="799" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then I clicked &lt;strong&gt;Re-run all jobs&lt;/strong&gt; on the failed run, and this time it passed in 45 seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7wgkbek76u9vgtg8l9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff7wgkbek76u9vgtg8l9q.png" alt="The re-run succeeds" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Verify the deployment
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nv"&gt;$APP_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response now showed the new heading, &lt;strong&gt;"Shipped automatically by GitHub Actions!"&lt;/strong&gt;, which proves the pipeline built, pushed and deployed the change without me touching Azure.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftp5fj1iu6w7tbia57ku6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftp5fj1iu6w7tbia57ku6.png" alt="curl shows the new message; the remote URL is updated after the repo rename" width="800" height="101"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And in the browser:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvqnxkkxu84jqw33xu7k5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvqnxkkxu84jqw33xu7k5.png" alt="The live app in the browser" width="800" height="316"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: The repository name problem and claiming the credential
&lt;/h3&gt;

&lt;p&gt;The lab ends by asking you to claim a verified credential, using your live URL and the repository &lt;code&gt;https://github.com/&amp;lt;your-username&amp;gt;/shipit-app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My repository was called &lt;code&gt;shipit&lt;/code&gt;, not &lt;code&gt;shipit-app&lt;/code&gt;. My first claim attempt failed with the message &lt;strong&gt;"We couldn't reach your live URL"&lt;/strong&gt;. The app was up and &lt;code&gt;curl&lt;/code&gt; returned the page, so I first suspected Azure had scaled the app to zero while idle and the checker gave up before it woke. I checked the app with &lt;code&gt;curl&lt;/code&gt; and got a successful response.&lt;/p&gt;

&lt;p&gt;The real problem turned out to be the repository name. The form still contained &lt;code&gt;.../shipit&lt;/code&gt;. After I &lt;strong&gt;renamed the repository to &lt;code&gt;shipit-app&lt;/code&gt;&lt;/strong&gt; and submitted the matching URL, the claim worked. The error message pointed at the wrong thing, so the lesson is: when a validation message doesn't make sense, re-check every input against the instructions, not just the one the message mentions.&lt;/p&gt;

&lt;p&gt;[Screenshot placeholder: the failed claim showing "We couldn't reach your live URL"]&lt;/p&gt;

&lt;p&gt;To rename the repo, I went to the repo's &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;General&lt;/strong&gt; → &lt;strong&gt;Repository name&lt;/strong&gt;, entered &lt;code&gt;shipit-app&lt;/code&gt; and clicked &lt;strong&gt;Rename&lt;/strong&gt;. GitHub redirects the old name, and the secret, workflow and Actions history all move with the repo. Then I updated my local remote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote set-url origin git@github.com:rahimahisah17/shipit-app.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git remote set-url&lt;/code&gt; changes the address stored for &lt;code&gt;origin&lt;/code&gt;, so future pushes go to the renamed repo.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg7ifn2x1nwx8n3zsdkxs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg7ifn2x1nwx8n3zsdkxs.png" alt="failed claim" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Clean up
&lt;/h3&gt;

&lt;p&gt;Deleting what you no longer need is how you make sure nothing is ever charged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete --name shipit-rg --yes --no-wait
az ad sp delete --id &lt;span class="nv"&gt;$(&lt;/span&gt;az ad sp list --display-name shipit-github --query &lt;span class="s2"&gt;"[0].id"&lt;/span&gt; -o tsv&lt;span class="o"&gt;)&lt;/span&gt;
az group exists --name shipit-rg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;az group delete&lt;/code&gt; removes the resource group and everything in it: the app, its environment and the logs. &lt;code&gt;--yes&lt;/code&gt; skips the prompt and &lt;code&gt;--no-wait&lt;/code&gt; returns immediately while Azure deletes in the background.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;az ad sp delete&lt;/code&gt; removes the service principal. Deleting the resource group does &lt;strong&gt;not&lt;/strong&gt; remove it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;az group exists&lt;/code&gt; prints &lt;code&gt;true&lt;/code&gt; while the group is still there and &lt;code&gt;false&lt;/code&gt; once it's gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deletion takes a few minutes. I got &lt;code&gt;true&lt;/code&gt; twice, then &lt;code&gt;Deleting&lt;/code&gt; from &lt;code&gt;az group show&lt;/code&gt;, then finally &lt;code&gt;false&lt;/code&gt;. I also deleted the &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; secret and the personal access token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkctf684qrdzmq1r2usb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkctf684qrdzmq1r2usb.png" alt="Cleaning up the Azure resources" width="798" height="195"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;echo $GH_USER&lt;/code&gt; printed nothing&lt;/td&gt;
&lt;td&gt;I opened a new terminal, and variables don't persist between sessions&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;export GH_USER=...&lt;/code&gt; again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package couldn't be pulled by Azure&lt;/td&gt;
&lt;td&gt;GHCR packages are private by default&lt;/td&gt;
&lt;td&gt;Set the package visibility to Public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;az containerapp up&lt;/code&gt; seemed stuck&lt;/td&gt;
&lt;td&gt;Creating the environment and Log Analytics workspace takes several minutes&lt;/td&gt;
&lt;td&gt;Wait, and don't press Ctrl+C&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipeline failed with &lt;code&gt;permission_denied: write_package&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The package was first pushed with my personal token, so the workflow's &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; couldn't write to it&lt;/td&gt;
&lt;td&gt;Add the repo under &lt;strong&gt;Manage Actions access&lt;/strong&gt; with the Write role, then re-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential claim said "couldn't reach your live URL"&lt;/td&gt;
&lt;td&gt;My repo was named &lt;code&gt;shipit&lt;/code&gt;, not &lt;code&gt;shipit-app&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rename the repo to &lt;code&gt;shipit-app&lt;/code&gt; and use the matching URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Lessons learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Follow naming conventions in guided labs.&lt;/strong&gt; My &lt;code&gt;shipit&lt;/code&gt; vs &lt;code&gt;shipit-app&lt;/code&gt; mismatch caused the longest detour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the failing step, not just the red X.&lt;/strong&gt; The Actions log told me exactly what was wrong: &lt;code&gt;write_package&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege pays off.&lt;/strong&gt; The service principal can only touch one resource group, and the repo has write access to only one package.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secrets in code.&lt;/strong&gt; The Azure credentials live in an encrypted GitHub secret, and the pipeline's registry login uses a temporary token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean up.&lt;/strong&gt; Deleting the resource group, the service principal, the secret and the token means nothing keeps running or stays exposed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing I'd still tidy up: my Dockerfile's &lt;code&gt;LABEL&lt;/code&gt; line still points at the old repository name (&lt;code&gt;shipit&lt;/code&gt;). GitHub redirects the old name, so nothing broke, but updating it would be cleaner.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In one weekend-sized lab I went from an empty folder to a live HTTPS app on Azure with a working CI/CD pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I wrote a tiny Python web server and packaged it in a Docker image that runs as a non-root user.&lt;/li&gt;
&lt;li&gt;I pushed the image to GitHub Container Registry, made it public, and deployed it to Azure Container Apps with the Azure CLI.&lt;/li&gt;
&lt;li&gt;I created a least-privilege service principal and a GitHub Actions workflow, so every push to &lt;code&gt;main&lt;/code&gt; builds, pushes and deploys automatically.&lt;/li&gt;
&lt;li&gt;I debugged a package permission error and a repository naming mismatch, then cleaned up all the Azure resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app itself is simple, but the workflow behind it is the real thing: containers, a registry, cloud hosting, least-privilege access and automated deployment. If you're learning DevOps or cloud, I'd recommend trying it yourself, mistakes included.&lt;/p&gt;

&lt;p&gt;The code is on GitHub: &lt;a href="https://github.com/rahimahisah17/shipit-app" rel="noopener noreferrer"&gt;rahimahisah17/shipit-app&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading! If you've hit similar errors in your own pipelines, I'd love to hear how you solved them in the comments.&lt;/p&gt;

</description>
      <category>azure</category>
      <category>docker</category>
      <category>githubactions</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads - Publishing to GitHub and Cleaning Up Azure Resources (Part 9/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 20:50:19 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-publishing-to-github-and-cleaning-up-azure-2fh8</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-publishing-to-github-and-cleaning-up-azure-2fh8</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Building a cloud-native environment is only part of the engineering process. A complete project also needs version-controlled implementation artifacts, a reproducible record of the work, and a deliberate cleanup strategy that prevents unused cloud resources from continuing to incur charges.&lt;/p&gt;

&lt;p&gt;In this final mission, I published the Kubernetes manifests to GitHub, verified that the lab namespace could be removed, deleted the Azure resource group, checked the status of the associated resource groups, and removed the obsolete Kubernetes context from the local kubeconfig.&lt;/p&gt;

&lt;p&gt;The objective was to preserve the implementation as portfolio evidence while ensuring that the temporary Azure environment was decommissioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Committing the Kubernetes manifests to Git.&lt;/li&gt;
&lt;li&gt;Publishing the repository to GitHub.&lt;/li&gt;
&lt;li&gt;Verifying namespace removal.&lt;/li&gt;
&lt;li&gt;Deleting the Azure resource group.&lt;/li&gt;
&lt;li&gt;Checking that the AKS-managed resource group was removed.&lt;/li&gt;
&lt;li&gt;Deleting the obsolete Kubernetes context.&lt;/li&gt;
&lt;li&gt;Confirming what remains available after the cloud environment is deleted.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Review the Kubernetes Manifests
&lt;/h2&gt;

&lt;p&gt;The project contains nine Kubernetes manifest files covering the workloads and resources implemented throughout the lab.&lt;/p&gt;

&lt;p&gt;The files are organized under the &lt;code&gt;manifests/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;01-pod.yaml
02-deployment.yaml
03-service-clusterip.yaml
04-service-loadbalancer.yaml
05-configmap.yaml
06-statefulset.yaml
07-daemonset.yaml
08-job.yaml
09-cronjob.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The manifests capture the main resources from the project:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Manifest&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;01-pod.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standalone Pod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;02-deployment.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;03-service-clusterip.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Internal ClusterIP Service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;04-service-loadbalancer.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;LoadBalancer Service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;05-configmap.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ConfigMap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;06-statefulset.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Headless Service and StatefulSet with persistent storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;07-daemonset.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DaemonSet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;08-job.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Batch Job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;09-cronjob.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Scheduled CronJob&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping these resources in version control preserves the configuration used throughout the lab. The manifests provide a reference for the implementation and can be reviewed independently of the Azure environment in which they were deployed.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Commit the Manifests to Git
&lt;/h2&gt;

&lt;p&gt;I committed the workload manifests with a message describing the implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: AKS workloads (pods, deployment, services, config, statefulset, daemonset, job, cronjob)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5zmo9c9n1vja5nzmfv7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5zmo9c9n1vja5nzmfv7.png" alt="commit" width="798" height="151"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Git confirmed the commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[main 610883d] feat: AKS workloads (pods, deployment, services, config, statefulset, daemonset, job, cronjob)
9 files changed, 206 insertions(+)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commit created the nine manifest files in the repository.&lt;/p&gt;

&lt;p&gt;A descriptive commit message makes the change easier to identify in the project history. It also establishes a clear record of the implementation being preserved for the portfolio.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Configure the GitHub Remote
&lt;/h2&gt;

&lt;p&gt;I configured the local repository to use the GitHub repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://github.com/rahimahisah17/aks-kubernetes-lab.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command used was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin https://github.com/rahimahisah17/aks-kubernetes-lab.git 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; git remote set-url origin https://github.com/rahimahisah17/aks-kubernetes-lab.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jundzlxca9fyjoprjei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3jundzlxca9fyjoprjei.png" alt="git remote add" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This command adds &lt;code&gt;origin&lt;/code&gt; when it does not already exist or updates its URL when the remote has already been configured.&lt;/p&gt;

&lt;p&gt;Using a remote allows the local Git repository to publish commits to GitHub and maintain a shared record of the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Push the Project to GitHub
&lt;/h2&gt;

&lt;p&gt;With the remote configured, I pushed the &lt;code&gt;main&lt;/code&gt; branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;-u&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyj8o21ankwxs4dml9ynp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyj8o21ankwxs4dml9ynp.png" alt="git push" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The command completed successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Enumerating objects: 19, done.
Counting objects: 100% (19/19), done.
Compressing objects: 100% (18/18), done.
Writing objects: 100% (19/19), 3.70 KiB | 84.00 KiB/s, done.
Total 19 (delta 2), reused 0 (delta 0), pack-reused 0

To https://github.com/rahimahisah17/aks-kubernetes-lab.git
 * [new branch]      main -&amp;gt; main
branch 'main' set up to track 'origin/main'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The push published the branch and configured it to track &lt;code&gt;origin/main&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The project repository is available here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/rahimahisah17/aks-kubernetes-lab" rel="noopener noreferrer"&gt;GitHub — aks-kubernetes-lab&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Publishing the manifests ensures that the configuration remains accessible even after the cluster and its cloud resources have been deleted.&lt;/p&gt;

&lt;p&gt;The repository also serves as portfolio evidence of the project implementation, including the manifest structure and version-control history.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Delete the Kubernetes Lab Namespace
&lt;/h2&gt;

&lt;p&gt;After the GitHub push succeeded, I removed the namespace containing the lab workloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete namespace k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace "k8s-lab" deleted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then inspected the remaining namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get namespaces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output listed the remaining namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME              STATUS   AGE
default           Active   5h9m
kube-node-lease   Active   5h9m
kube-public       Active   5h9m
kube-system       Active   5h9m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpy635hl62h5wxxybqq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvpy635hl62h5wxxybqq0.png" alt="delete ns" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;k8s-lab&lt;/code&gt; namespace was no longer present.&lt;/p&gt;

&lt;p&gt;Deleting a namespace removes the Kubernetes resources contained in it, subject to their deletion and finalization behavior. This removed the dedicated lab namespace and its namespaced workloads from the cluster.&lt;/p&gt;

&lt;p&gt;However, deleting a Kubernetes namespace does not itself decommission the AKS cluster or remove every Azure resource associated with it. The Azure resource group therefore needed to be deleted separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Delete the Azure Resource Group
&lt;/h2&gt;

&lt;p&gt;The Azure environment used the following configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESOURCE_GROUP=rg-aks-lab
CLUSTER_NAME=aks-lab
LOCATION=westeurope
NODE_SIZE=Standard_D2s_v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I first attempted to delete the resource group using the environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--no-wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;argument --name/-n/--resource-group/-g: expected one argument
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The variable was not populated in that shell because I switched to another terminal, so Azure CLI did not receive a resource group name.&lt;/p&gt;

&lt;p&gt;I then supplied the resource group name explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group delete &lt;span class="nt"&gt;--name&lt;/span&gt; rg-aks-lab &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--no-wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8m8i0fa33ncx0lecl48i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8m8i0fa33ncx0lecl48i.png" alt="delete resources" width="800" height="259"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This issued the resource group deletion request.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--yes&lt;/code&gt; option skips the interactive confirmation, while &lt;code&gt;--no-wait&lt;/code&gt; returns without waiting for the deletion operation to finish. As a result, the command's return does not, on its own, establish that the resources have already disappeared.&lt;/p&gt;

&lt;p&gt;For cleanup operations, verification is essential because Azure resource deletion can continue asynchronously after the command returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Verify the Azure Resource Group Cleanup
&lt;/h2&gt;

&lt;p&gt;The AKS deployment had a primary resource group and an AKS-managed resource group. I checked for both using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?starts_with(name, 'rg-aks-lab') || starts_with(name, 'MC_rg-aks-lab')].name"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An initial check still showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rg-aks-lab
MC_rg-aks-lab_aks-lab_westeurope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbr896vpfhdzvzymwel8o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbr896vpfhdzvzymwel8o.png" alt="Initial deletion" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This indicated that the deletion process had not yet removed both resource groups from the listing.&lt;/p&gt;

&lt;p&gt;I repeated the check after the deletion process had progressed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group list &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[?starts_with(name, 'rg-aks-lab') || starts_with(name, 'MC_rg-aks-lab')].name"&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subsequent output was empty, confirming that neither matching resource group remained in the returned list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n8cjo3voa6pb2yug7re.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n8cjo3voa6pb2yug7re.png" alt="successful deletion" width="800" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The verification covered both the main lab resource group and the resource group created by AKS to manage supporting infrastructure.&lt;/p&gt;

&lt;p&gt;This distinction matters because AKS can create Azure resources outside the visible workload definitions in the Kubernetes manifests. A complete cleanup check should account for the managed infrastructure, rather than checking only the primary resource group.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Remove the Obsolete Kubernetes Context
&lt;/h2&gt;

&lt;p&gt;With the AKS environment decommissioned, I removed its local Kubernetes context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config delete-context aks-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;warning: this removed your active context, use "kubectl config use-context" to select a different one
deleted context aks-lab from /home/rahimahisah/.kube/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyq4nuizvdtun3lzqx9mb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyq4nuizvdtun3lzqx9mb.png" alt="delete context" width="800" height="153"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The context had stored the connection configuration used by &lt;code&gt;kubectl&lt;/code&gt; to access the &lt;code&gt;aks-lab&lt;/code&gt; cluster.&lt;/p&gt;

&lt;p&gt;Removing it cleans up the local kubeconfig after the cluster has been deleted. The warning also notes that the removed context had been active, so another context would need to be selected before using &lt;code&gt;kubectl&lt;/code&gt; against a different cluster.&lt;/p&gt;

&lt;p&gt;This step removes the local connection entry; it does not delete the local repository, manifests, or GitHub project.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What Remains After Cleanup?
&lt;/h2&gt;

&lt;p&gt;The cleanup separates the cloud environment from the project artifacts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Final state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;k8s-lab&lt;/code&gt; namespace&lt;/td&gt;
&lt;td&gt;Deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main Azure resource group &lt;code&gt;rg-aks-lab&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No longer present in the verification listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AKS-managed resource group&lt;/td&gt;
&lt;td&gt;No longer present in the verification listing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local &lt;code&gt;aks-lab&lt;/code&gt; kubeconfig context&lt;/td&gt;
&lt;td&gt;Deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local project directory&lt;/td&gt;
&lt;td&gt;Preserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes YAML manifests&lt;/td&gt;
&lt;td&gt;Preserved locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Git commit history&lt;/td&gt;
&lt;td&gt;Preserved in the repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub repository&lt;/td&gt;
&lt;td&gt;Published and available&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This separation is important for cloud labs. The resources used to demonstrate the implementation can be removed without discarding the configuration, documentation, and version-control history that make the work reproducible and useful as portfolio evidence.&lt;/p&gt;

&lt;p&gt;Although the Azure resource groups were removed from the verification listing, the final Azure bill remains the authoritative source for confirming any outstanding or previously accrued charges. Deletion stops the continued use of the deleted resources, but it does not erase charges already incurred.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This final mission brought together version control, cloud resource lifecycle management, and verification.&lt;/p&gt;

&lt;p&gt;The main takeaways were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Version-controlled infrastructure:&lt;/strong&gt; Kubernetes manifests provide a durable record of the resource configuration used in the lab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repository publishing:&lt;/strong&gt; Pushing the project to GitHub preserves the implementation beyond the lifetime of the AKS cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace cleanup:&lt;/strong&gt; Removing the lab namespace deletes its dedicated Kubernetes workloads and resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure resource cleanup:&lt;/strong&gt; Deleting the primary resource group initiates removal of the cluster and related Azure resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asynchronous deletion:&lt;/strong&gt; Commands using &lt;code&gt;--no-wait&lt;/code&gt; require a separate verification step to confirm the resources have disappeared.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Managed-resource awareness:&lt;/strong&gt; AKS can create a separate managed resource group that should be included in cleanup verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local configuration hygiene:&lt;/strong&gt; Removing an obsolete kubeconfig context prevents local configuration from retaining an unnecessary connection entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost awareness:&lt;/strong&gt; Cloud cleanup should be verified rather than assumed, and actual billed amounts should be checked independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion: From Cluster Provisioning to Decommissioning
&lt;/h2&gt;

&lt;p&gt;The nine-part series brought together AKS provisioning, Kubernetes workloads, networking, configuration, persistent storage, batch processing, scheduled automation, and cost optimization.&lt;/p&gt;

&lt;p&gt;In this final mission, I committed the nine workload manifests, pushed the project to GitHub, deleted the &lt;code&gt;k8s-lab&lt;/code&gt; namespace, issued the Azure resource group deletion request, verified that the primary and AKS-managed resource groups were no longer listed, and removed the obsolete Kubernetes context.&lt;/p&gt;

&lt;p&gt;The result is a preserved project repository without the temporary cloud infrastructure used to run it.&lt;/p&gt;

&lt;p&gt;The central operational principle is straightforward: &lt;strong&gt;infrastructure work is not complete until its implementation has been preserved and its temporary resources have been deliberately decommissioned.&lt;/strong&gt; Version-controlled manifests retain the engineering evidence, while verified cleanup helps prevent unnecessary ongoing resource consumption.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 9 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>azure</category>
      <category>devops</category>
      <category>github</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads - FinOps: Measure, Price and Cut Cluster Costs (Part 8/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 20:26:27 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-finops-measure-price-and-cut-cluster-costs-4be1</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-finops-measure-price-and-cut-cluster-costs-4be1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Cloud-native infrastructure is not production-ready simply because its workloads are running. It must also be measurable, cost-aware, and configured to avoid paying for unnecessary capacity.&lt;/p&gt;

&lt;p&gt;In this mission, I evaluated the resource consumption of my Azure Kubernetes Service (AKS) cluster, examined Kubernetes resource requests and limits, queried the Azure Retail Prices API to estimate compute costs, and implemented workload and node-pool scaling controls. I also removed an externally exposed LoadBalancer Service, tested the AKS stop/start lifecycle, and documented the findings and optimization actions in &lt;code&gt;COST-REPORT.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The objective was to connect Kubernetes resource telemetry with Azure pricing and practical cost-control measures, while distinguishing observed utilization from requested capacity, configured limits, and estimated charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measuring node and Pod resource usage.&lt;/li&gt;
&lt;li&gt;Inspecting CPU and memory requests and limits.&lt;/li&gt;
&lt;li&gt;Installing &lt;code&gt;jq&lt;/code&gt; to process Azure pricing data.&lt;/li&gt;
&lt;li&gt;Querying the Azure Retail Prices API for the VM size used by the cluster.&lt;/li&gt;
&lt;li&gt;Estimating the monthly compute cost.&lt;/li&gt;
&lt;li&gt;Configuring workload autoscaling with a Horizontal Pod Autoscaler (HPA).&lt;/li&gt;
&lt;li&gt;Enabling cluster autoscaling for the AKS node pool.&lt;/li&gt;
&lt;li&gt;Removing the public LoadBalancer Service.&lt;/li&gt;
&lt;li&gt;Testing AKS stop/start operations.&lt;/li&gt;
&lt;li&gt;Creating and committing a cost report.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Measure Actual Resource Utilization
&lt;/h2&gt;

&lt;p&gt;I started with the node-level metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl top nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                                CPU(cores)   CPU(%)   MEMORY(bytes)   MEMORY(%)
aks-nodepool1-33590657-vmss000000   242m         12%      1479Mi          25%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlxkhjsnj1axlvcuqy8c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhlxkhjsnj1axlvcuqy8c.png" alt="top" width="799" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the time of measurement, the node was using approximately 12% of its reported CPU capacity and 25% of its memory capacity.&lt;/p&gt;

&lt;p&gt;These figures provide a snapshot of actual resource consumption. They are useful for identifying whether the node is under pressure or operating with substantial unused capacity, although a single observation does not establish the workload's peak or long-term requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspect the Pods consuming CPU
&lt;/h3&gt;

&lt;p&gt;I then inspected the Pods across namespaces, sorted by CPU usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl top pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cpu | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzk89gv2vurbijf4bgddc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzk89gv2vurbijf4bgddc.png" alt="kubectl top pods" width="799" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output included system components in &lt;code&gt;kube-system&lt;/code&gt; and application workloads in &lt;code&gt;k8s-lab&lt;/code&gt;, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;csi-azurefile-node&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;azure-cns&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metrics-server&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;konnectivity-agent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coredns&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kube-proxy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;web&lt;/code&gt; Deployment Pods&lt;/li&gt;
&lt;li&gt;&lt;code&gt;node-agent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;store-0&lt;/code&gt; and &lt;code&gt;store-1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The displayed CPU consumption of the application Pods was low at the time of measurement. This helped establish that the cluster's workloads were not consuming a substantial share of the node's available CPU during the observed interval.&lt;/p&gt;

&lt;p&gt;The distinction matters because a Kubernetes cluster can have a relatively low current workload while still reserving significant capacity through its configured resource requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Compare Resource Requests, Limits and Actual Usage
&lt;/h2&gt;

&lt;p&gt;To understand how much capacity the workloads had requested or been permitted to use, I inspected the node's allocated resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe nodes | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 8 &lt;span class="s2"&gt;"Allocated resources"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqpuirc08y6wozoav5ur.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftqpuirc08y6wozoav5ur.png" alt="kubectl describe nodes" width="800" height="159"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Allocated resources:
  (Total limits may be over 100 percent, i.e., overcommitted.)
  Resource           Requests       Limits
  --------           --------       ------
  cpu                1187m (62%)    10692m (562%)
  memory             1330Mi (22%)   13443360Ki (226%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The figures reveal three different aspects of resource management.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Observed node utilization&lt;/td&gt;
&lt;td&gt;12%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allocated requests&lt;/td&gt;
&lt;td&gt;62%&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Allocated limits&lt;/td&gt;
&lt;td&gt;562%&lt;/td&gt;
&lt;td&gt;226%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Actual utilization&lt;/strong&gt; represents observed consumption at the time of measurement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Requests&lt;/strong&gt; represent the capacity declared by workloads for scheduling purposes. CPU requests of 62% indicate that workloads had reserved a significant proportion of the node's allocatable CPU capacity, even though observed CPU utilization was only 12%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limits&lt;/strong&gt; define upper resource boundaries for individual containers. Their aggregated percentages can exceed 100% because Kubernetes permits resource overcommitment. The reported CPU limit of 562% therefore does not mean that the node was actually using more than five times its CPU capacity.&lt;/p&gt;

&lt;p&gt;These measurements should not be interpreted interchangeably. Low current utilization can indicate an opportunity to review requests, but reducing them requires an understanding of the workload's peak behavior, performance requirements, and reliability objectives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the Deployment's resource settings
&lt;/h3&gt;

&lt;p&gt;I inspected the container configuration for the &lt;code&gt;web&lt;/code&gt; Deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deployment web &lt;span class="nt"&gt;-n&lt;/span&gt; k8s-lab &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.spec.template.spec.containers[] |
        {name: .name, requests: .resources.requests, limits: .resources.limits}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nginx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requests"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"50m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"64Mi"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"limits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"200m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"128Mi"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each NGINX container requested &lt;code&gt;50m&lt;/code&gt; CPU and &lt;code&gt;64Mi&lt;/code&gt; memory, with limits of &lt;code&gt;200m&lt;/code&gt; CPU and &lt;code&gt;128Mi&lt;/code&gt; memory.&lt;/p&gt;

&lt;p&gt;With three replicas, those declared requests contribute to the Deployment's aggregate resource reservations. Repeating this comparison across workloads helps identify where requests may be substantially higher than typical consumption or where capacity may be constrained.&lt;/p&gt;

&lt;p&gt;The goal is not to minimize requests indiscriminately. It is to align resource allocation with actual workload requirements while preserving sufficient headroom for bursts and normal operating variation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Install jq for Pricing Data Analysis
&lt;/h2&gt;

&lt;p&gt;To process the JSON returned by Azure's pricing endpoint, I installed &lt;code&gt;jq&lt;/code&gt; in the Linux environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu128umxwf1wcr0l1x9jm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu128umxwf1wcr0l1x9jm.png" alt="install" width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The package manager installed &lt;code&gt;jq&lt;/code&gt; and its required dependencies successfully.&lt;/p&gt;

&lt;p&gt;This utility made it easier to filter the Azure Retail Prices API response and extract fields such as the product name, meter, price, unit of measure, region, and price type.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Retrieve VM Pricing from the Azure Retail Prices API
&lt;/h2&gt;

&lt;p&gt;The cluster configuration identified the following environment values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESOURCE_GROUP=rg-aks-lab
CLUSTER_NAME=aks-lab
LOCATION=westeurope
NODE_SIZE=Standard_D2s_v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I queried the Azure Retail Prices API for virtual machine pricing matching the region and VM size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sG&lt;/span&gt; &lt;span class="s2"&gt;"https://prices.azure.com/api/retail/prices"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;filter=serviceName eq 'Virtual Machines' and armRegionName eq '&lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;' and armSkuName eq '&lt;/span&gt;&lt;span class="nv"&gt;$NODE_SIZE&lt;/span&gt;&lt;span class="s2"&gt;' and priceType eq 'Consumption'"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Items[]
    | select(.meterName | test("Spot|Low Priority"; "i") | not)
    | [.productName, .skuName, .meterName, .retailPrice, .unitOfMeasure, .armRegionName, .priceType]
    | @tsv'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o3rx0ugtay3toic5qe1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0o3rx0ugtay3toic5qe1.png" alt="cost" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The response contained multiple pricing records for the requested VM SKU. The available records included different operating-system products and pricing types, so the SKU alone was not sufficient to select the correct rate.&lt;/p&gt;

&lt;p&gt;I compared the returned fields to distinguish the applicable consumption rate from Windows-specific, DevTest, Spot, and Low Priority entries.&lt;/p&gt;

&lt;p&gt;For the cost report, I used an observed public retail consumption rate of &lt;strong&gt;$0.12 per hour&lt;/strong&gt; for the &lt;code&gt;Standard_D2s_v3&lt;/code&gt; VM in &lt;code&gt;westeurope&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Estimate the monthly VM compute cost
&lt;/h3&gt;

&lt;p&gt;Using a simplified month of 730 hours, the calculation was:&lt;/p&gt;

&lt;p&gt;[&lt;br&gt;
\text{Estimated monthly cost} =&lt;br&gt;
\text{Hourly rate} \times 730&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;[&lt;br&gt;
\$0.12 \times 730 = \$87.60&lt;br&gt;
]&lt;/p&gt;

&lt;p&gt;The resulting estimate was:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Estimate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VM size&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Standard_D2s_v3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Region&lt;/td&gt;
&lt;td&gt;&lt;code&gt;westeurope&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hourly rate used&lt;/td&gt;
&lt;td&gt;$0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assumed monthly hours&lt;/td&gt;
&lt;td&gt;730&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estimated monthly VM compute cost&lt;/td&gt;
&lt;td&gt;$87.60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is a price-based estimate, not a confirmed Azure invoice amount. The calculation covers the VM rate used in the report and excludes additional AKS and Azure resource charges. Actual billing can also depend on the applicable pricing record, subscription or purchasing terms, usage duration, and associated resources.&lt;/p&gt;

&lt;p&gt;For that reason, the estimate provides a useful baseline for cost planning, but it should not be presented as the complete operating cost of the cluster.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Identify and Remove Unnecessary Public Exposure
&lt;/h2&gt;

&lt;p&gt;I reviewed the Services across namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get services &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4ilpxj4ucfoempg4oxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk4ilpxj4ucfoempg4oxi.png" alt="get services" width="800" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed the internal &lt;code&gt;web-svc&lt;/code&gt; ClusterIP Service, the headless &lt;code&gt;store&lt;/code&gt; Service, and the &lt;code&gt;web-public&lt;/code&gt; LoadBalancer Service.&lt;/p&gt;

&lt;p&gt;The public Service was exposing NGINX using this external IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;20.61.154.190
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I inspected its configuration and endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe service web-public &lt;span class="nt"&gt;-n&lt;/span&gt; k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Service used the selector &lt;code&gt;app=web&lt;/code&gt;, exposed port &lt;code&gt;80&lt;/code&gt;, and routed traffic to the NGINX Pods. Its type was &lt;code&gt;LoadBalancer&lt;/code&gt;, and the external IP had been assigned.&lt;/p&gt;

&lt;p&gt;The lab no longer required this public endpoint for its remaining work, so I removed the Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete service web-public &lt;span class="nt"&gt;-n&lt;/span&gt; k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service "web-public" deleted from k8s-lab namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnpikfqt1m13vo4liosn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdnpikfqt1m13vo4liosn.png" alt="service web-public" width="799" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Removing the Service eliminated that Kubernetes-managed public exposure. The internal Service remained available for cluster-internal access.&lt;/p&gt;

&lt;p&gt;This action also removed the LoadBalancer Service configuration that was no longer needed by the lab. Deleting the Service should not be treated as proof that every associated Azure charge disappears immediately; the resulting Azure resources and billing records still need to be considered when validating actual savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Configure Horizontal Pod Autoscaling
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;web&lt;/code&gt; Deployment had been configured with three replicas. Rather than permanently fixing it at that count, I created a Horizontal Pod Autoscaler (HPA) to allow Kubernetes to adjust the number of Pods based on CPU utilization.&lt;/p&gt;

&lt;p&gt;I configured the HPA as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl autoscale deployment web &lt;span class="nt"&gt;-n&lt;/span&gt; k8s-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu-percent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;70 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;horizontalpodautoscaler.autoscaling/web autoscaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command displayed a deprecation notice for &lt;code&gt;--cpu-percent&lt;/code&gt;, recommending the newer &lt;code&gt;--cpu&lt;/code&gt; option with a percentage or resource quantity format.&lt;/p&gt;

&lt;p&gt;I verified the resulting HPA:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get hpa web &lt;span class="nt"&gt;-n&lt;/span&gt; k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME   REFERENCE      TARGETS   MINPODS   MAXPODS   REPLICAS
web    Deployment/web cpu: 2%/70%  1      5         3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbog0ml9wk4tfo4ooy2i4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbog0ml9wk4tfo4ooy2i4.png" alt="autoscaling" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the time of the check, the observed CPU utilization was approximately 2% against a 70% target. The HPA was configured to maintain between one and five replicas, with three replicas currently reported.&lt;/p&gt;

&lt;p&gt;This configuration allows the replica count to respond to CPU utilization rather than requiring manual scaling for every workload fluctuation.&lt;/p&gt;

&lt;p&gt;The HPA does not guarantee that replicas immediately scale down whenever usage is low, nor does it remove the need for appropriate resource requests and metrics. It evaluates workload metrics against its configuration and adjusts the desired replica count as conditions warrant.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Enable AKS Node-Pool Autoscaling
&lt;/h2&gt;

&lt;p&gt;Pod autoscaling changes the number of application replicas, but it does not automatically increase the number of virtual machines in the cluster unless cluster-level scaling is configured.&lt;/p&gt;

&lt;p&gt;I enabled the cluster autoscaler for &lt;code&gt;nodepool1&lt;/code&gt;, allowing the node count to vary between one and three:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks nodepool update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; nodepool1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-cluster-autoscaler&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--min-count&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-count&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4rutf3hx7t6x6fgu3la.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm4rutf3hx7t6x6fgu3la.png" alt="nodepool update" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The command updated the node-pool configuration. Azure CLI also displayed a notice that the command behavior had been altered by the &lt;code&gt;aks-preview&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;I then queried the effective settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks nodepool show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; nodepool1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{Pool:name,Count:count,Autoscaling:enableAutoScaling,Min:minCount,Max:maxCount}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f8iqn535zuclhc8ueui.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9f8iqn535zuclhc8ueui.png" alt="nodepool show" width="799" height="444"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pool       Count   Autoscaling   Min   Max
nodepool1  1       True          1     3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This verified that cluster autoscaling was enabled and that the configured minimum and maximum node counts were one and three.&lt;/p&gt;

&lt;p&gt;The node pool was currently at one node. With autoscaling enabled, Kubernetes can request additional node capacity when scheduling demand requires it, subject to the configured limits and Azure's ability to provision the capacity.&lt;/p&gt;

&lt;h3&gt;
  
  
  HPA and cluster autoscaling serve different purposes
&lt;/h3&gt;

&lt;p&gt;These settings control separate layers of the environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Horizontal Pod Autoscaler:&lt;/strong&gt; adjusts the number of application Pods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster autoscaler:&lt;/strong&gt; adjusts the number of AKS nodes when cluster capacity needs to change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HPA was configured for one to five &lt;code&gt;web&lt;/code&gt; replicas, while the node pool was configured for one to three nodes. The two mechanisms work at different levels and should be configured together with appropriate resource requests, scheduling requirements, and capacity limits.&lt;/p&gt;

&lt;p&gt;The maximum node count is a limit, not a target. Enabling autoscaling does not mean the cluster will remain at three nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Test the AKS Stop/Start Lifecycle
&lt;/h2&gt;

&lt;p&gt;I also exercised the AKS cluster's stop/start commands.&lt;/p&gt;

&lt;p&gt;To stop the cluster, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks stop &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then started it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks start &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobae8z0i2q0k5twbrjw7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobae8z0i2q0k5twbrjw7.png" alt="aks stop and start" width="800" height="423"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Finally, I checked its reported power state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; powerState.code &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting state was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q106m0zcm3flef6dod3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7q106m0zcm3flef6dod3.png" alt="az aks show" width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the cluster was running after the stop/start sequence.&lt;/p&gt;

&lt;p&gt;For a lab or development environment that is not needed continuously, stopping the cluster during idle periods can be part of a cost-control strategy. However, the cluster must first be checked for active workloads, scheduled operations, dependencies, and service availability requirements.&lt;/p&gt;

&lt;p&gt;The stop/start exercise demonstrates the control operation; it is not, on its own, a measured comparison of the before-and-after Azure bill. Actual savings should be verified against the applicable billing records and the resources that continue to incur charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Document the Findings in COST-REPORT.md
&lt;/h2&gt;

&lt;p&gt;To capture the analysis and decisions, I created &lt;code&gt;COST-REPORT.md&lt;/code&gt; in the project repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; COST-REPORT.md
# AKS FinOps Cost Report

## Cluster Configuration
- Resource Group: rg-aks-lab
- Cluster: aks-lab
- Region: westeurope
- Node Pool: nodepool1
- VM Size: Standard_D2s_v3
- Node Count: 1

## Resource Utilization
- Node CPU usage: 12%
- Node memory usage: 25%
- CPU requests: 62%
- Memory requests: 22%

## Cost Estimate
- Estimated VM rate: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="sh"&gt;.12/hour
- Estimated monthly VM cost (730 hours): &lt;/span&gt;&lt;span class="nv"&gt;$87&lt;/span&gt;&lt;span class="sh"&gt;.60
- Estimate excludes additional AKS and Azure resource charges.

## Cost Optimization Actions
- Configured web deployment autoscaling between 1 and 5 replicas.
- Enabled cluster autoscaling for nodepool1 between 1 and 3 nodes.
- Reduced the node pool to 1 node.
- Removed the web-public LoadBalancer service.
- Stopped the AKS cluster when not in use.

## Notes
The VM price is a public retail-price estimate, not a confirmed invoice amount. Actual costs depend on billing rates and associated Azure resources.
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmele89ywdn36w40xyyg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbmele89ywdn36w40xyyg.png" alt="cost report" width="800" height="421"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report consolidated the cluster configuration, observed utilization, VM price estimate, and optimization actions into a version-controlled artifact.&lt;/p&gt;

&lt;p&gt;A concise cost report is useful because it distinguishes the measurements from the actions taken and documents what remains outside the estimate. In this case, it explicitly records that the $87.60 figure excludes additional AKS and Azure resource charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Commit the Cost Report
&lt;/h2&gt;

&lt;p&gt;I checked the repository state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi59vztmh45bzy7tzn3uj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi59vztmh45bzy7tzn3uj.png" alt="git status" width="800" height="190"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The report appeared as an untracked file. I staged it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add COST-REPORT.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I committed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"docs: AKS cost review and optimisation"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa39guq8hs23ynintuer3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa39guq8hs23ynintuer3.png" alt="git status and commit" width="800" height="271"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Git confirmed the commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[main 2725349] docs: AKS cost review and optimisation
1 file changed, 30 insertions(+)
create mode 100644 COST-REPORT.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This committed the cost report to the &lt;code&gt;main&lt;/code&gt; branch, preserving the analysis and optimization decisions alongside the lab's other project documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Summary of Cost Optimization Decisions
&lt;/h2&gt;

&lt;p&gt;The main findings and actions were:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Finding or action&lt;/th&gt;
&lt;th&gt;Operational purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node utilization&lt;/td&gt;
&lt;td&gt;12% CPU and 25% memory usage observed&lt;/td&gt;
&lt;td&gt;Establish a snapshot of actual resource consumption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource requests&lt;/td&gt;
&lt;td&gt;CPU requests at 62%; memory requests at 22%&lt;/td&gt;
&lt;td&gt;Identify declared capacity reservations for further review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource limits&lt;/td&gt;
&lt;td&gt;Aggregated CPU and memory limits above 100%&lt;/td&gt;
&lt;td&gt;Understand configured overcommitment rather than actual usage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM pricing&lt;/td&gt;
&lt;td&gt;$0.12/hour used for the estimate&lt;/td&gt;
&lt;td&gt;Establish a public retail-price baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monthly estimate&lt;/td&gt;
&lt;td&gt;$87.60 for 730 hours of VM compute&lt;/td&gt;
&lt;td&gt;Quantify an illustrative compute cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workload scaling&lt;/td&gt;
&lt;td&gt;HPA configured for one to five replicas&lt;/td&gt;
&lt;td&gt;Match the &lt;code&gt;web&lt;/code&gt; replica count to CPU demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node scaling&lt;/td&gt;
&lt;td&gt;Cluster autoscaler configured for one to three nodes&lt;/td&gt;
&lt;td&gt;Allow node capacity to adjust to scheduling demand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public networking&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;web-public&lt;/code&gt; Service deleted&lt;/td&gt;
&lt;td&gt;Remove an external endpoint no longer required by the lab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster lifecycle&lt;/td&gt;
&lt;td&gt;Stop/start operations tested&lt;/td&gt;
&lt;td&gt;Establish an option for managing idle lab capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COST-REPORT.md&lt;/code&gt; committed&lt;/td&gt;
&lt;td&gt;Preserve the findings and optimization actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These actions address different aspects of cost management. Autoscaling controls capacity, removing an unnecessary Service eliminates unneeded exposure, resource measurements inform future tuning, and stopping an idle cluster provides a lifecycle control. The retail-price estimate supports planning but does not replace actual cost analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This FinOps review combined Kubernetes telemetry with Azure pricing data to establish a practical baseline for the AKS lab.&lt;/p&gt;

&lt;p&gt;The observed node utilization was 12% CPU and 25% memory, while resource requests and limits revealed how declared capacity differed from current consumption. A public VM consumption rate of $0.12 per hour was used to estimate $87.60 for 730 hours of VM compute, excluding additional AKS and Azure resource charges.&lt;/p&gt;

&lt;p&gt;I configured the &lt;code&gt;web&lt;/code&gt; HPA to scale between one and five replicas, enabled node-pool autoscaling between one and three nodes, removed the public &lt;code&gt;web-public&lt;/code&gt; Service, tested the AKS stop/start workflow, and committed the findings in &lt;code&gt;COST-REPORT.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key principle is that cost optimization should be based on measured usage, explicit assumptions, and deliberate trade-offs. Reducing infrastructure cost is valuable only when the resulting capacity and availability remain appropriate for the workload.&lt;/p&gt;

&lt;p&gt;In Part 9, the series will conclude with the final mission, bringing the lab's implementation, documentation, and cleanup together.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 8 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>azure</category>
      <category>monitoring</category>
      <category>cloudcostoptimization</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads -DaemonSets, Jobs and CronJobs (Part 7/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 19:25:12 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-daemonsets-jobs-and-cronjobs-part-79-4d8k</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-daemonsets-jobs-and-cronjobs-part-79-4d8k</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Kubernetes supports more than continuously running application workloads. It also provides controllers for node-level agents, one-off batch processing, and scheduled tasks.&lt;/p&gt;

&lt;p&gt;In this mission, I implemented a DaemonSet to run a lightweight agent on each eligible AKS node, scaled the node pool to verify that the agent was scheduled on a newly added node, and then returned the pool to one node. I also configured a batch Job with three completions and a CronJob that runs every minute, verifying their execution through Job status and container logs.&lt;/p&gt;

&lt;p&gt;These workload types address different operational requirements: DaemonSets distribute workloads across nodes, Jobs manage finite tasks, and CronJobs create Jobs on a recurring schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Creating and validating a DaemonSet.&lt;/li&gt;
&lt;li&gt;Injecting the node name into a container through the Downward API.&lt;/li&gt;
&lt;li&gt;Scaling the AKS node pool and verifying DaemonSet placement.&lt;/li&gt;
&lt;li&gt;Defining a batch Job with three successful completions.&lt;/li&gt;
&lt;li&gt;Inspecting Job status and logs.&lt;/li&gt;
&lt;li&gt;Scheduling recurring work with a CronJob.&lt;/li&gt;
&lt;li&gt;Verifying scheduled executions and reviewing the resulting cluster resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Deploy a DaemonSet
&lt;/h2&gt;

&lt;p&gt;A DaemonSet ensures that a copy of a specified Pod runs on each eligible node. This makes it suitable for node-level agents that need to operate wherever workloads are scheduled.&lt;/p&gt;

&lt;p&gt;I defined the DaemonSet in &lt;code&gt;manifests/07-daemonset.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/07-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: node-agent
spec:
  selector:
    matchLabels:
      app: node-agent
  template:
    metadata:
      labels:
        app: node-agent
    spec:
      containers:
        - name: agent
          image: busybox:1.36
          command: ["sh", "-c", "while true; do echo &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt; agent running on node &lt;/span&gt;&lt;span class="nv"&gt;$NODE_NAME&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;; sleep 30; done"]
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgdjofi7zj46sih1zl0a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhgdjofi7zj46sih1zl0a.png" alt="daemonsets" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Configuration details
&lt;/h3&gt;

&lt;p&gt;The manifest defines a DaemonSet named &lt;code&gt;node-agent&lt;/code&gt; with a single container using &lt;code&gt;busybox:1.36&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The container runs a loop that writes a timestamp and the node name to its output every 30 seconds. This provides a lightweight way to confirm which node is hosting each agent.&lt;/p&gt;

&lt;p&gt;The node name is injected through the Kubernetes Downward API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NODE_NAME&lt;/span&gt;
    &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;fieldRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;fieldPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;spec.nodeName&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This avoids hardcoding a node name into the container command. Each Pod receives the name of the node on which it is running.&lt;/p&gt;

&lt;p&gt;The resource configuration also defines explicit requests and limits:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50m&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;16Mi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;32Mi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These values keep the agent's resource requirements small while making its resource boundaries explicit.&lt;/p&gt;

&lt;p&gt;I applied the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/07-daemonset.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;daemonset.apps/node-agent created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then monitored the rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout status daemonset/node-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;daemon set "node-agent" successfully rolled out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiny2190vihz8fauqxo8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffiny2190vihz8fauqxo8.png" alt="daemon set" width="799" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Verify DaemonSet Placement and Logs
&lt;/h2&gt;

&lt;p&gt;To inspect the DaemonSet's status, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get daemonset node-agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME         DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE
node-agent   1         1         1       1            1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this stage, the cluster had one node, and the DaemonSet had one desired and ready Pod.&lt;/p&gt;

&lt;p&gt;I checked the Pod's placement with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node-agent &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output identified the Pod as &lt;code&gt;node-agent-rdmcq&lt;/code&gt;, running on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aks-nodepool1-33590657-vmss000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Pod was in the &lt;code&gt;Running&lt;/code&gt; state and had the internal IP &lt;code&gt;10.244.0.172&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To confirm that the agent was reporting the correct node, I inspected its recent logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node-agent &lt;span class="nt"&gt;--prefix&lt;/span&gt; &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[pod/node-agent-rdmcq/agent] Mon Oct 26 10:02:52 UTC 2026 agent running on node aks-nodepool1-33590657-vmss000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7yuf4flruj2193c94ksv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7yuf4flruj2193c94ksv.png" alt="node-agent" width="798" height="223"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This verified that the agent was running and that the node name supplied through the Downward API was available inside the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Scale the AKS Node Pool to Verify DaemonSet Behavior
&lt;/h2&gt;

&lt;p&gt;To verify how the DaemonSet responds when the cluster gains another node, I scaled the AKS node pool from one node to two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks scale &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodepool-name&lt;/span&gt; nodepool1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyx44xirrsp7gl0pijqrv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyx44xirrsp7gl0pijqrv.png" alt="aks create" width="800" height="440"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdijcmz2n6stvfmpwu1c9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdijcmz2n6stvfmpwu1c9.png" alt="aks create2" width="800" height="707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Azure CLI returned the node pool configuration. Its output also displayed a notice that the command's behavior had been altered by the &lt;code&gt;aks-preview&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;Once the second node was available, I checked the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed two nodes in the &lt;code&gt;Ready&lt;/code&gt; state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                                STATUS   ROLES    VERSION
aks-nodepool1-33590657-vmss000000   Ready    &amp;lt;none&amp;gt;   v1.36.4
aks-nodepool1-33590657-vmss000001   Ready    &amp;lt;none&amp;gt;   v1.36.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then checked the DaemonSet Pods again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node-agent &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8iveghhcoybgnqkem4kv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8iveghhcoybgnqkem4kv.png" alt="2 nodes" width="800" height="132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed one agent Pod on each node:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pod&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node-agent-rdmcq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Running&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aks-nodepool1-33590657-vmss000000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node-agent-mdscg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Running&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aks-nodepool1-33590657-vmss000001&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second agent was running on the newly added node, demonstrating the DaemonSet's node-level placement behavior.&lt;/p&gt;

&lt;p&gt;Unlike a Deployment, where the replica count is explicitly configured, a DaemonSet aims to maintain a Pod on every eligible node. As the eligible node count changes, the number of agent Pods changes accordingly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Return the node pool to one node
&lt;/h3&gt;

&lt;p&gt;After verifying the two-node behavior, I scaled the node pool back to one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks scale &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodepool-name&lt;/span&gt; nodepool1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fndmmims5zfmdaucam32m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fndmmims5zfmdaucam32m.png" alt="aks scale" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This returned the lab environment to its previous node count. Scaling a node pool affects the cluster's compute capacity and is also relevant when managing cloud costs for a lab environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Create a Batch Job
&lt;/h2&gt;

&lt;p&gt;A Kubernetes Job manages Pods that perform finite work and are expected to complete successfully. Unlike a continuously running Deployment, a Job tracks successful task completion.&lt;/p&gt;

&lt;p&gt;I defined the Job in &lt;code&gt;manifests/08-job.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/08-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: report
spec:
  completions: 3
  parallelism: 3
  backoffLimit: 2
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: report
          image: busybox:1.36
          command: ["sh", "-c", "echo &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;Report part generated by &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;; sleep 5"]
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgknzucmwguf2d6zu5bgx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgknzucmwguf2d6zu5bgx.png" alt="batch job" width="800" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Job configuration
&lt;/h3&gt;

&lt;p&gt;The Job is named &lt;code&gt;report&lt;/code&gt; and uses &lt;code&gt;busybox:1.36&lt;/code&gt; to print a message identifying the Pod that generated each report part.&lt;/p&gt;

&lt;p&gt;The important settings are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;completions: 3&lt;/code&gt;&lt;/strong&gt; specifies that three successful completions are required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;parallelism: 3&lt;/code&gt;&lt;/strong&gt; allows up to three Job Pods to run concurrently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;backoffLimit: 2&lt;/code&gt;&lt;/strong&gt; sets the permitted retry count for failed Pods before the Job is considered failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ttlSecondsAfterFinished: 3600&lt;/code&gt;&lt;/strong&gt; allows the completed Job to be eligible for automatic cleanup one hour after it finishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;restartPolicy: Never&lt;/code&gt;&lt;/strong&gt; prevents a failed container from being restarted within the same Pod.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This configuration models a small batch operation with three successful executions. Each Pod prints its own hostname, making it possible to distinguish the individual task executions in the logs.&lt;/p&gt;

&lt;p&gt;I applied the Job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/08-job.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The initial output confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;job.batch/report created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first verification attempt returned a &lt;code&gt;NotFound&lt;/code&gt; error when waiting for the Job to complete, and the immediate Job listing showed no resources. I reapplied the manifest and checked the resulting Job again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/08-job.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0jfp5qmx7lrhr3pi7e0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi0jfp5qmx7lrhr3pi7e0.png" alt="job yaml" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The subsequent execution completed successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify Job Completion and Inspect the Logs
&lt;/h2&gt;

&lt;p&gt;I checked the Job status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get job report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME     STATUS     COMPLETIONS   DURATION
report   Complete   3/3           10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;3/3&lt;/code&gt; completion count confirmed that the Job had achieved its three required successful completions.&lt;/p&gt;

&lt;p&gt;To inspect the output from all the Job's Pods, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-l&lt;/span&gt; job-name&lt;span class="o"&gt;=&lt;/span&gt;report &lt;span class="nt"&gt;--prefix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6jlyui74pj9dqzldc6v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6jlyui74pj9dqzldc6v.png" alt="kubectl logs" width="800" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The logs included messages such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[pod/report-2v8b5/report] Report part generated by report-2v8b5
[pod/report-bxzps/report] Report part generated by report-bxzps
[pod/report-plnqc/report] Report part generated by report-plnqc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each execution produced a message containing its own Pod hostname. This confirmed that the Job's three task executions ran and completed.&lt;/p&gt;

&lt;p&gt;The logs also demonstrate why Job status and application output are complementary checks. The completion count confirms the controller's recorded outcome, while the logs show what the containers actually executed.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Schedule Recurring Work with a CronJob
&lt;/h2&gt;

&lt;p&gt;A CronJob creates Jobs according to a schedule. It is useful for recurring operations such as periodic reporting, cleanup tasks, and scheduled maintenance.&lt;/p&gt;

&lt;p&gt;I defined the CronJob in &lt;code&gt;manifests/09-cronjob.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/09-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: heartbeat
spec:
  schedule: "*/1 * * * *"
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: heartbeat
              image: busybox:1.36
              command: ["sh", "-c", "echo &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;Heartbeat at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;"]
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CronJob is named &lt;code&gt;heartbeat&lt;/code&gt; and uses the schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;*/1 * * * *
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cron expression requests execution every minute.&lt;/p&gt;

&lt;p&gt;The Job template uses a BusyBox container that prints a timestamp. Unlike the &lt;code&gt;report&lt;/code&gt; Job, which required three successful completions, this CronJob creates a new Job each time its schedule is triggered.&lt;/p&gt;

&lt;p&gt;The history settings control how many finished Jobs are retained by the CronJob controller:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;successfulJobsHistoryLimit: 2&lt;/code&gt; retains up to two successful Jobs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;failedJobsHistoryLimit: 1&lt;/code&gt; retains up to one failed Job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Pod template uses &lt;code&gt;restartPolicy: OnFailure&lt;/code&gt;, allowing a failed container to be restarted within its Pod according to Kubernetes' restart behavior.&lt;/p&gt;

&lt;p&gt;I applied the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/09-cronjob.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cronjob.batch/heartbeat created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2btzp6zgasp61q98mnej.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2btzp6zgasp61q98mnej.png" alt="cronjob" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Verify Scheduled Executions
&lt;/h2&gt;

&lt;p&gt;I inspected the CronJob:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get cronjob heartbeat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jyku6ipcbnx5cqn4vkv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jyku6ipcbnx5cqn4vkv.png" alt="cronjob heartbeat" width="799" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed the every-minute schedule and the CronJob's status fields, including its suspension state, active Job count, and last scheduled execution.&lt;/p&gt;

&lt;p&gt;The initial check occurred before the first scheduled run had been recorded, so the &lt;code&gt;LAST SCHEDULE&lt;/code&gt; field was still empty.&lt;/p&gt;

&lt;p&gt;After subsequent checks, I listed Jobs in creation order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get &lt;span class="nb"&gt;jobs&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.metadata.creationTimestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output included the completed &lt;code&gt;report&lt;/code&gt; Job and a completed Job created by the &lt;code&gt;heartbeat&lt;/code&gt; CronJob.&lt;/p&gt;

&lt;p&gt;To inspect the latest scheduled Job's output, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs job/&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get &lt;span class="nb"&gt;jobs&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.metadata.creationTimestamp &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[-1].metadata.name}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Heartbeat at Mon Oct 26 12:34:01 UTC 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3h9m9fmahs6nljf7b4wf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3h9m9fmahs6nljf7b4wf.png" alt="kubectl logs job" width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the scheduled workload had executed and produced its expected message.&lt;/p&gt;

&lt;p&gt;The naming pattern also makes the controller relationship visible: the CronJob is named &lt;code&gt;heartbeat&lt;/code&gt;, while the Jobs it creates include a generated suffix.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Inspect the Combined Cluster State
&lt;/h2&gt;

&lt;p&gt;After deploying the DaemonSet, Job, and CronJob, I inspected the broader set of resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get all,configmap,secret,pvc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fld6xajdmfmjlo2trdeh2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fld6xajdmfmjlo2trdeh2.png" alt="kubectl get cm,pvc" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flv8mcy8pzstx5m6uztf3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flv8mcy8pzstx5m6uztf3.png" alt="kubectl get cm, secret,pvc" width="800" height="265"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ls manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The manifests folder contains nine yaml files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimyfhtyj6ixgxtceebt4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fimyfhtyj6ixgxtceebt4.png" alt="manifests" width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed the different workload types operating in the &lt;code&gt;k8s-lab&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;The relevant resources included:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Observed state&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node-agent&lt;/code&gt; DaemonSet&lt;/td&gt;
&lt;td&gt;One desired and ready agent Pod after returning the node pool to one node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;report&lt;/code&gt; Job&lt;/td&gt;
&lt;td&gt;Complete, with &lt;code&gt;3/3&lt;/code&gt; successful completions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;heartbeat&lt;/code&gt; CronJob&lt;/td&gt;
&lt;td&gt;Configured to run every minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;heartbeat&lt;/code&gt; Jobs&lt;/td&gt;
&lt;td&gt;Completed executions recorded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;store&lt;/code&gt; StatefulSet&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;2/2&lt;/code&gt; Pods ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;web&lt;/code&gt; Deployment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3/3&lt;/code&gt; replicas available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;web-svc&lt;/code&gt; Service&lt;/td&gt;
&lt;td&gt;Internal &lt;code&gt;ClusterIP&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;web-public&lt;/code&gt; Service&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LoadBalancer&lt;/code&gt; with external IP &lt;code&gt;20.61.154.190&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;store&lt;/code&gt; Service&lt;/td&gt;
&lt;td&gt;Headless Service with no cluster IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StatefulSet PVCs&lt;/td&gt;
&lt;td&gt;Two claims bound, each with &lt;code&gt;1Gi&lt;/code&gt; capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  9. DaemonSets, Jobs and CronJobs: Key Differences
&lt;/h2&gt;

&lt;p&gt;Although all three resources can create and manage Pods, they serve distinct purposes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Primary behavior&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DaemonSet&lt;/td&gt;
&lt;td&gt;Maintains a Pod on each eligible node&lt;/td&gt;
&lt;td&gt;Node-level agents, monitoring, logging, and other node-scoped services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;Runs Pods until a finite task reaches its required successful completion count&lt;/td&gt;
&lt;td&gt;Batch processing, report generation, and one-off operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CronJob&lt;/td&gt;
&lt;td&gt;Creates Jobs according to a recurring schedule&lt;/td&gt;
&lt;td&gt;Scheduled reports, periodic maintenance, and recurring automation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The choice depends on the execution model.&lt;/p&gt;

&lt;p&gt;A DaemonSet follows the eligible node count. A Job tracks successful completion of finite work. A CronJob adds a time-based schedule for creating those Jobs.&lt;/p&gt;

&lt;p&gt;These resources are complementary rather than interchangeable.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This mission demonstrated several useful Kubernetes operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node-level coverage:&lt;/strong&gt; A DaemonSet automatically placed an agent on each eligible node, including the second node added during the test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime visibility:&lt;/strong&gt; The Downward API supplied the node name to the agent without hardcoding infrastructure details into the container command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finite task execution:&lt;/strong&gt; The &lt;code&gt;report&lt;/code&gt; Job completed three successful tasks and exposed their outputs through logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recurring automation:&lt;/strong&gt; The &lt;code&gt;heartbeat&lt;/code&gt; CronJob created scheduled Jobs that printed timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution verification:&lt;/strong&gt; Job completion counts and container logs provided separate ways to verify task outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute management:&lt;/strong&gt; Scaling the AKS node pool demonstrated the relationship between node count and DaemonSet placement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource awareness:&lt;/strong&gt; The final resource listing showed how node-level, batch, scheduled, stateless, and stateful workloads coexist in the same cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;node-agent&lt;/code&gt; DaemonSet was deployed and verified on the AKS node pool. Scaling the pool to two nodes resulted in one agent Pod on each node, and the pool was subsequently returned to one node.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;report&lt;/code&gt; Job completed all three required executions, with logs confirming that each Pod generated its report message. The &lt;code&gt;heartbeat&lt;/code&gt; CronJob also ran successfully on its configured every-minute schedule, producing timestamped output.&lt;/p&gt;

&lt;p&gt;Together, these implementations demonstrate how Kubernetes handles node-scoped agents, finite batch work, and recurring tasks through dedicated controllers.&lt;/p&gt;

&lt;p&gt;In Part 8, the series will continue with the next mission, building on the workload and operational capabilities established here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 7 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>daemonsets</category>
      <category>cronjobs</category>
      <category>aks</category>
      <category>containers</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads - StatefulSets and Persistent Storage (Part 6/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:38:17 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-statefulsets-and-persistent-storage-part-27ml</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-statefulsets-and-persistent-storage-part-27ml</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Applications that maintain data need more than a reliable way to run containers. They also need predictable identities, stable network addresses, and storage that remains available when Pods are replaced.&lt;/p&gt;

&lt;p&gt;In this mission, I deployed a StatefulSet on Azure Kubernetes Service (AKS), paired it with a headless Service, and provisioned persistent Azure Disk volumes for its replicas. I then wrote data from inside a Pod, deleted that Pod, and verified that the replacement could still access the data. Finally, I tested the StatefulSet's DNS identity from another Pod in the cluster.&lt;/p&gt;

&lt;p&gt;The objective was to demonstrate how Kubernetes supports stateful workloads whose identity and data must survive individual Pod replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Inspecting the available Kubernetes StorageClasses.&lt;/li&gt;
&lt;li&gt;Creating a headless Service.&lt;/li&gt;
&lt;li&gt;Defining a StatefulSet with two replicas.&lt;/li&gt;
&lt;li&gt;Provisioning persistent storage through &lt;code&gt;managed-csi&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verifying Pod identities and PersistentVolumeClaims.&lt;/li&gt;
&lt;li&gt;Writing data to a Pod's persistent volume.&lt;/li&gt;
&lt;li&gt;Deleting and waiting for a StatefulSet Pod to return.&lt;/li&gt;
&lt;li&gt;Verifying that data survives Pod replacement.&lt;/li&gt;
&lt;li&gt;Testing stable DNS access through the headless Service.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Inspect the Available StorageClasses
&lt;/h2&gt;

&lt;p&gt;Before defining the StatefulSet, I inspected the StorageClasses available in the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get storageclass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzci9d1uje3svyf8thwc1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzci9d1uje3svyf8thwc1.png" alt="storageclass" width="799" height="212"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed several StorageClasses for Azure Files and Azure Disk. The &lt;code&gt;managed-csi&lt;/code&gt; StorageClass was available for persistent disk provisioning.&lt;/p&gt;

&lt;p&gt;Its relevant properties included:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;StorageClass&lt;/td&gt;
&lt;td&gt;&lt;code&gt;managed-csi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provisioner&lt;/td&gt;
&lt;td&gt;&lt;code&gt;disk.csi.azure.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reclaim policy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Delete&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume binding mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WaitForFirstConsumer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Volume expansion&lt;/td&gt;
&lt;td&gt;Enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;managed-csi&lt;/code&gt; StorageClass uses the Azure Disk Container Storage Interface (CSI) driver. It allows Kubernetes to provision Azure-managed disks for workloads that request persistent storage.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;WaitForFirstConsumer&lt;/code&gt; binding mode delays volume binding and provisioning until a Pod using the claim is scheduled. This allows storage provisioning to account for the Pod's scheduling requirements.&lt;/p&gt;

&lt;p&gt;The reclaim policy is also important. &lt;code&gt;Delete&lt;/code&gt; means that a dynamically provisioned volume can be deleted when its PersistentVolume is released, depending on the lifecycle of the associated storage resources. Deleting an individual StatefulSet Pod does not, by itself, release its PersistentVolumeClaim.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Define a Headless Service and StatefulSet
&lt;/h2&gt;

&lt;p&gt;I created &lt;code&gt;manifests/06-statefulset.yaml&lt;/code&gt; to define both the headless Service and the StatefulSet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/06-statefulset.yaml
apiVersion: v1
kind: Service
metadata:
  name: store
spec:
  clusterIP: None
  selector:
    app: store
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: store
spec:
  serviceName: store
  replicas: 2
  selector:
    matchLabels:
      app: store
  template:
    metadata:
      labels:
        app: store
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
          volumeMounts:
            - name: data
              mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        storageClassName: managed-csi
        resources:
          requests:
            storage: 1Gi
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nz2tj36f51b39l0bhbv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4nz2tj36f51b39l0bhbv.png" alt="statefulSet" width="799" height="523"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why use a headless Service?
&lt;/h3&gt;

&lt;p&gt;The Service is configured with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;clusterIP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes it a headless Service. Instead of providing a conventional cluster IP, it enables DNS records to resolve directly to the endpoints of the selected Pods.&lt;/p&gt;

&lt;p&gt;The Service selects Pods labelled &lt;code&gt;app: store&lt;/code&gt; and exposes port &lt;code&gt;80&lt;/code&gt;. It also provides the network identity associated with the StatefulSet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why use a StatefulSet?
&lt;/h3&gt;

&lt;p&gt;The StatefulSet is named &lt;code&gt;store&lt;/code&gt; and declares two replicas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;serviceName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;store&lt;/span&gt;
&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike ordinary Deployment-managed Pods, StatefulSet Pods have stable ordinal identities. The two replicas use these names:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;store-0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;store-1&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The StatefulSet also uses the &lt;code&gt;store&lt;/code&gt; headless Service as its governing Service, allowing the Pods to have predictable DNS identities.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persistent storage
&lt;/h3&gt;

&lt;p&gt;The manifest defines a &lt;code&gt;volumeClaimTemplates&lt;/code&gt; entry named &lt;code&gt;data&lt;/code&gt;. Each replica receives its own PersistentVolumeClaim requesting &lt;code&gt;1Gi&lt;/code&gt; of storage from the &lt;code&gt;managed-csi&lt;/code&gt; StorageClass.&lt;/p&gt;

&lt;p&gt;The important settings are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
&lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;managed-csi&lt;/span&gt;
&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container mounts the volume at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/share/nginx/html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the directory NGINX serves as its default document root. Writing a file there allows the test to demonstrate whether the contents remain available after a Pod is replaced.&lt;/p&gt;

&lt;p&gt;Because each StatefulSet replica receives its own claim, &lt;code&gt;store-0&lt;/code&gt; and &lt;code&gt;store-1&lt;/code&gt; do not share the same per-replica volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deploy the StatefulSet
&lt;/h2&gt;

&lt;p&gt;I applied the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/06-statefulset.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed that both resources were created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service/store created
statefulset.apps/store created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then monitored the StatefulSet rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout status statefulset/store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Waiting for 1 pods to be ready...
partitioned roll out complete: 2 new pods have been updated...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the rollout, I inspected the StatefulSet, Pods, and PersistentVolumeClaims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get statefulset,pods,pvc &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two Pods were running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME        READY   STATUS    RESTARTS
pod/store-0 1/1     Running   0
pod/store-1 1/1     Running   0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PersistentVolumeClaims were also bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                    STATUS   CAPACITY   ACCESS MODES   STORAGECLASS
persistentvolumeclaim/data-store-0   Bound   1Gi   RWO   managed-csi
persistentvolumeclaim/data-store-1   Bound   1Gi   RWO   managed-csi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3exktc0t4f2grm7nhc1r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3exktc0t4f2grm7nhc1r.png" alt="verify pvc" width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This verified that the StatefulSet had created both replicas and Kubernetes had provisioned a separate &lt;code&gt;1Gi&lt;/code&gt; persistent volume claim for each one.&lt;/p&gt;

&lt;p&gt;The stable Pod names and individual claims are central to the StatefulSet model: a replacement for &lt;code&gt;store-0&lt;/code&gt; is associated with the identity and storage claim belonging to &lt;code&gt;store-0&lt;/code&gt;, rather than being treated as an entirely unrelated replica.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Write Data to the Persistent Volume
&lt;/h2&gt;

&lt;p&gt;To test persistence, I wrote a message to the NGINX document root inside &lt;code&gt;store-0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;store-0 &lt;span class="nt"&gt;--&lt;/span&gt; sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "Hello from $(hostname), written at $(date)" &amp;gt; /usr/share/nginx/html/index.html'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4i45zv8dty5gqq3lbayl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4i45zv8dty5gqq3lbayl.png" alt="kubectl exec" width="800" height="272"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The command writes a message containing the Pod's hostname and the current date into &lt;code&gt;index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Because the document root is backed by the persistent volume mounted at &lt;code&gt;/usr/share/nginx/html&lt;/code&gt;, the test data is stored on the volume rather than only in the container's writable layer.&lt;/p&gt;

&lt;p&gt;This creates a clear verification point: after the Pod is replaced, the file should still contain the original message if the same persistent claim is attached successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Delete the Pod and Wait for Replacement
&lt;/h2&gt;

&lt;p&gt;I deleted &lt;code&gt;store-0&lt;/code&gt; to test the StatefulSet's recovery behavior:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod store-0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fju5w3fth9v717epu95zz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fju5w3fth9v717epu95zz.png" alt="delete pod" width="800" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes confirmed that the Pod had been deleted from the &lt;code&gt;k8s-lab&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;The StatefulSet controller then worked to restore the desired replica count. Because &lt;code&gt;store-0&lt;/code&gt; is part of a StatefulSet, Kubernetes recreates the Pod with the same ordinal identity and reuses its associated PersistentVolumeClaim.&lt;/p&gt;

&lt;p&gt;I waited for the replacement Pod to become ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready pod/store-0 &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;180s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod/store-0 condition met
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fm6y312biy4mih68f43.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1fm6y312biy4mih68f43.png" alt="kubectl wait" width="800" height="178"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that &lt;code&gt;store-0&lt;/code&gt; had returned to the &lt;code&gt;Ready&lt;/code&gt; state.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Verify That the Data Survived
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;store-0&lt;/code&gt; ready again, I inspected the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;store-0 &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /usr/share/nginx/html/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello from store-0, written at Mon Oct 26 09:56:05 UTC 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdn3kjhuqqvw0p0ukd6c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdn3kjhuqqvw0p0ukd6c.png" alt="kubectl exec" width="799" height="177"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The message remained available after the original Pod had been deleted and replaced.&lt;/p&gt;

&lt;p&gt;This confirmed the key behavior being tested: the replacement Pod retained access to the data stored on the PersistentVolume associated with &lt;code&gt;store-0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The Pod itself is replaceable, but its identity and associated persistent storage allow the application to recover without losing the file created during the test.&lt;/p&gt;

&lt;p&gt;This does not mean that every storage configuration automatically survives every failure scenario. Data persistence depends on the volume lifecycle, reclaim policy, storage backend, and how the claims and volumes are managed. In this test, the StatefulSet's per-replica claim remained available through Pod replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Verify Stable DNS from Another Pod
&lt;/h2&gt;

&lt;p&gt;The headless Service also provides predictable network identities for the StatefulSet Pods.&lt;/p&gt;

&lt;p&gt;To verify DNS access, I launched a temporary BusyBox Pod and requested the content through the hostname &lt;code&gt;store-0.store&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run dns-test &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--&lt;/span&gt; wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://store-0.store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello from store-0, written at Mon Oct 26 09:56:05 UTC 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11hy9o8stgky8o6ka0io.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F11hy9o8stgky8o6ka0io.png" alt="dns" width="798" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The temporary Pod returned the same content written before &lt;code&gt;store-0&lt;/code&gt; was deleted.&lt;/p&gt;

&lt;p&gt;This verified two aspects of the implementation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The name &lt;code&gt;store-0.store&lt;/code&gt; resolved and allowed access to the NGINX endpoint.&lt;/li&gt;
&lt;li&gt;The endpoint served the data that persisted across the Pod replacement.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;store-0.store&lt;/code&gt; hostname is derived from the StatefulSet Pod identity and its governing headless Service. This predictable naming is particularly useful for applications that need to communicate with specific replicas.&lt;/p&gt;

&lt;p&gt;The temporary &lt;code&gt;dns-test&lt;/code&gt; Pod was configured with &lt;code&gt;--rm&lt;/code&gt;, so Kubernetes removed it when the command completed.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. StatefulSet vs. Deployment
&lt;/h2&gt;

&lt;p&gt;StatefulSets and Deployments both manage replicated workloads, but they address different operational requirements.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Deployment&lt;/th&gt;
&lt;th&gt;StatefulSet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pod identity&lt;/td&gt;
&lt;td&gt;Typically interchangeable Pod names&lt;/td&gt;
&lt;td&gt;Stable ordinal names such as &lt;code&gt;store-0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable per-replica storage&lt;/td&gt;
&lt;td&gt;Not provided automatically through a Deployment's usual model&lt;/td&gt;
&lt;td&gt;Supported through &lt;code&gt;volumeClaimTemplates&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common use cases&lt;/td&gt;
&lt;td&gt;Stateless web applications and APIs&lt;/td&gt;
&lt;td&gt;Stateful applications requiring stable identity or storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaling and replacement&lt;/td&gt;
&lt;td&gt;Reconciles desired replica count&lt;/td&gt;
&lt;td&gt;Reconciles replicas while preserving ordinal identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;Commonly accessed through a Service&lt;/td&gt;
&lt;td&gt;Can use a headless Service for per-Pod DNS identities&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A StatefulSet is not automatically the right choice for every application that writes files. The appropriate workload resource depends on whether the application needs stable identity, ordered operations, or persistent storage tied to individual replicas.&lt;/p&gt;

&lt;p&gt;In this mission, those features made the StatefulSet a suitable way to demonstrate data persistence across Pod replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This mission demonstrated how StatefulSets, headless Services, and persistent storage work together on AKS.&lt;/p&gt;

&lt;p&gt;The main takeaways were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stable workload identity:&lt;/strong&gt; StatefulSet replicas have predictable names that remain associated with their ordinals when Pods are replaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent storage:&lt;/strong&gt; Each replica receives its own PersistentVolumeClaim through &lt;code&gt;volumeClaimTemplates&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic provisioning:&lt;/strong&gt; The &lt;code&gt;managed-csi&lt;/code&gt; StorageClass allows Kubernetes to provision Azure Disk storage for the claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod recovery:&lt;/strong&gt; Deleting &lt;code&gt;store-0&lt;/code&gt; caused the StatefulSet controller to recreate the same logical replica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data continuity:&lt;/strong&gt; The file written before deletion remained available after the replacement Pod became ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable DNS:&lt;/strong&gt; A headless Service enables per-Pod DNS identities, allowing another Pod to access &lt;code&gt;store-0&lt;/code&gt; through &lt;code&gt;store-0.store&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage lifecycle awareness:&lt;/strong&gt; Persistent volume behavior depends on the claim and volume lifecycle, access modes, storage class, and reclaim policy—not only on the Pod resource.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;store&lt;/code&gt; StatefulSet was deployed with two replicas, a headless Service, and separate &lt;code&gt;1Gi&lt;/code&gt; persistent volume claims backed by the &lt;code&gt;managed-csi&lt;/code&gt; StorageClass.&lt;/p&gt;

&lt;p&gt;I wrote a file to &lt;code&gt;store-0&lt;/code&gt;, deleted the Pod, waited for the replacement to become ready, and verified that the original file content remained available. A request to &lt;code&gt;http://store-0.store&lt;/code&gt; from a temporary Pod returned the same content, confirming that the stable DNS identity and persistent storage were working together as intended.&lt;/p&gt;

&lt;p&gt;This is the primary distinction between simply recreating a container and managing a stateful workload: the replacement can recover with its expected identity and retain access to its data.&lt;/p&gt;

&lt;p&gt;In Part 7, the series will continue with the next mission, building on the workload and storage capabilities established here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 6 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>statefulset</category>
      <category>kubernetes</category>
      <category>aks</category>
      <category>azure</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads - Services, ConfigMaps and Secrets (Part 5/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:12:33 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-services-configmaps-and-secrets-part-59-2lg3</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-services-configmaps-and-secrets-part-59-2lg3</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A running application needs more than healthy Pods. It also needs a reliable way to receive traffic and a secure, maintainable approach to handling configuration.&lt;/p&gt;

&lt;p&gt;In this mission, I extended the NGINX Deployment on Azure Kubernetes Service (AKS) by creating internal and external Services, testing application access, and separating configuration values from the container image. I also created a Kubernetes Secret, verified its stored value, and injected both ConfigMap and Secret data into the running Deployment as environment variables.&lt;/p&gt;

&lt;p&gt;The implementation demonstrates how Kubernetes networking and configuration resources work together to make workloads accessible and configurable without rebuilding their images.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Creating a &lt;code&gt;ClusterIP&lt;/code&gt; Service for internal application access.&lt;/li&gt;
&lt;li&gt;Testing the NGINX application inside the cluster.&lt;/li&gt;
&lt;li&gt;Exposing the Deployment through a &lt;code&gt;LoadBalancer&lt;/code&gt; Service.&lt;/li&gt;
&lt;li&gt;Verifying the public endpoint.&lt;/li&gt;
&lt;li&gt;Creating a ConfigMap from literal values.&lt;/li&gt;
&lt;li&gt;Creating a Secret for a sensitive configuration value.&lt;/li&gt;
&lt;li&gt;Injecting ConfigMap and Secret values into the Deployment.&lt;/li&gt;
&lt;li&gt;Verifying the environment variables inside a running container.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Create a ClusterIP Service
&lt;/h2&gt;

&lt;p&gt;I started by defining an internal Service in &lt;code&gt;manifests/03-service-clusterip.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/03-service-clusterip.yaml
apiVersion: v1
kind: Service
metadata:
  name: web-svc
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcn955b7bopi91kuv2bkf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcn955b7bopi91kuv2bkf.png" alt="service-clusterip" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The manifest defines a Service named &lt;code&gt;web-svc&lt;/code&gt; with the following configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;type: ClusterIP&lt;/code&gt;&lt;/strong&gt; creates an internal Service address that is reachable from within the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;selector: app: web&lt;/code&gt;&lt;/strong&gt; identifies the Pods that receive traffic through the Service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;port: 80&lt;/code&gt;&lt;/strong&gt; specifies the port exposed by the Service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;targetPort: 80&lt;/code&gt;&lt;/strong&gt; specifies the destination port on the selected Pods.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The selector connects the Service to the &lt;code&gt;web&lt;/code&gt; Deployment's Pods, which carry the &lt;code&gt;app: web&lt;/code&gt; label.&lt;/p&gt;

&lt;p&gt;This is an important distinction: clients communicate with the Service rather than depending on an individual Pod IP address. Kubernetes maintains the Service's routing to the eligible backend Pods as the workload changes.&lt;/p&gt;

&lt;p&gt;I applied the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/03-service-clusterip.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Service provides a stable internal endpoint for the application. It does not, by itself, expose the application directly to the public internet.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Test Internal Application Access
&lt;/h2&gt;

&lt;p&gt;After configuring the internal Service, I tested access to the NGINX application from inside the Kubernetes environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run curl-test &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--&lt;/span&gt; wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://web-svc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It starts a temporary Pod called &lt;code&gt;curl-test&lt;/code&gt; inside the cluster, using the small busybox image. The Pod runs wget to fetch &lt;a href="http://web-svc" rel="noopener noreferrer"&gt;http://web-svc&lt;/a&gt;, and the Service’s name resolves through CoreDNS. --rm deletes the Pod afterwards, -i shows the output, and --restart=Never stops Kubernetes from restarting it once it finishes. The command returned the default &lt;strong&gt;“Welcome to nginx!”&lt;/strong&gt; HTML, which showed the app was reachable through the ClusterIP Service from inside the cluster.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Welcome to nginx!&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19mjltl40p4ev42knq1y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F19mjltl40p4ev42knq1y.png" alt="nginx serving" width="800" height="373"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The response also contained the page's standard message confirming that the NGINX web server was installed and running.&lt;/p&gt;

&lt;p&gt;This verified that the application could respond to an HTTP request from within the cluster.&lt;/p&gt;

&lt;p&gt;Testing internal connectivity before exposing a workload externally is useful because it separates application and internal networking checks from the additional configuration required for public access.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Expose the Application with a LoadBalancer Service
&lt;/h2&gt;

&lt;p&gt;For external access, I created a second Service in &lt;code&gt;manifests/04-service-loadbalancer.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/04-service-loadbalancer.yaml
apiVersion: v1
kind: Service
metadata:
  name: web-public
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidx1kq5ceakshsfn68ii.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fidx1kq5ceakshsfn68ii.png" alt="landbalancer" width="799" height="306"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Service uses the same &lt;code&gt;app: web&lt;/code&gt; selector as the internal Service, so it routes traffic to the Pods managed by the Deployment.&lt;/p&gt;

&lt;p&gt;The key difference is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LoadBalancer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On AKS, this Service type requests an Azure load balancer configuration that can expose the application through an external IP address.&lt;/p&gt;

&lt;p&gt;I applied the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/04-service-loadbalancer.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed that the Service was created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service/web-public created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then watched the Service status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get service web-public &lt;span class="nt"&gt;--watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME         TYPE           CLUSTER-IP   EXTERNAL-IP      PORT(S)
web-public   LoadBalancer   10.0.73.23   20.61.154.190   80:31236/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fov77at5rsy8yxj2kj0sc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fov77at5rsy8yxj2kj0sc.png" alt="clusterip" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The assigned addresses and port information were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cluster IP:&lt;/strong&gt; &lt;code&gt;10.0.73.23&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External IP:&lt;/strong&gt; &lt;code&gt;20.61.154.190&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service port:&lt;/strong&gt; &lt;code&gt;80&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NodePort:&lt;/strong&gt; &lt;code&gt;31236&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The external IP made the NGINX application reachable through the load balancer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the public endpoint
&lt;/h3&gt;

&lt;p&gt;I tested the external endpoint using &lt;code&gt;curl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get service web-public &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.loadBalancer.ingress[0].ip}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Welcome to nginx!&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F821poxmp3lmig0u83v62.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F821poxmp3lmig0u83v62.png" alt="verify public ep" width="800" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the request reached the NGINX application through the public Service endpoint.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;LoadBalancer&lt;/code&gt; Service is useful when an application needs an externally reachable endpoint. However, public exposure should be deliberate: production workloads may also require network restrictions, TLS termination, ingress routing, and appropriate access controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Create a ConfigMap
&lt;/h2&gt;

&lt;p&gt;Application configuration should not always be embedded in a container image. Kubernetes ConfigMaps provide a way to store non-sensitive configuration separately from application code and image builds.&lt;/p&gt;

&lt;p&gt;I generated a ConfigMap manifest from literal values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create configmap web-config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;APP_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;WELCOME_MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Hello from AKS"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; manifests/05-configmap.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command generated the YAML locally rather than immediately creating the resource in the cluster. The output was saved to &lt;code&gt;manifests/05-configmap.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The configuration contained two values:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APP_ENV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;production&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;WELCOME_MSG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Hello from AKS&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I then applied the generated manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/05-configmap.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;configmap/web-config created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To inspect the stored configuration, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get configmap web-config &lt;span class="nt"&gt;-o&lt;/span&gt; yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;APP_ENV&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
  &lt;span class="na"&gt;WELCOME_MSG&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hello from AKS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5w8z5cwrg0v7mpcysqv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5w8z5cwrg0v7mpcysqv.png" alt="config map" width="800" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This verified that the ConfigMap contained the expected configuration values.&lt;/p&gt;

&lt;p&gt;Because the ConfigMap is separate from the container image, these values can be managed independently of the image build. ConfigMaps are intended for non-sensitive data and should not be used to store credentials or other confidential values.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Create a Kubernetes Secret
&lt;/h2&gt;

&lt;p&gt;For the next configuration value, I created a Kubernetes Secret named &lt;code&gt;web-secret&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic web-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;change-me-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;secret/web-secret created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Secret contains the key &lt;code&gt;API_KEY&lt;/code&gt;, with a placeholder value used in this lab.&lt;/p&gt;

&lt;p&gt;Unlike a ConfigMap, a Secret is designed for sensitive configuration data. However, creating a Kubernetes Secret does not automatically make the value safe: access controls, encryption at rest, and appropriate secret-management practices still matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the stored value
&lt;/h3&gt;

&lt;p&gt;I verified the value using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secret web-secret &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.data.API_KEY}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;--decode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;change-me-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8d2sk8qcpmfu5w74h7s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi8d2sk8qcpmfu5w74h7s.png" alt="secret" width="800" height="330"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Kubernetes stores Secret data in a base64-encoded representation in the API object. Base64 is an encoding mechanism, not encryption. Anyone authorized to retrieve and decode the value can read it.&lt;/p&gt;

&lt;p&gt;The value used here is only a lab placeholder. Real API keys and production credentials should not be committed to a repository or exposed in terminal output, screenshots, or documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Inject the ConfigMap into the Deployment
&lt;/h2&gt;

&lt;p&gt;With the ConfigMap and Secret created, I configured the &lt;code&gt;web&lt;/code&gt; Deployment to consume their values as environment variables.&lt;/p&gt;

&lt;p&gt;First, I added the ConfigMap values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;set env &lt;/span&gt;deployment/web &lt;span class="nt"&gt;--from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;configmap/web-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web env updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This added the ConfigMap's key-value pairs to the container environment defined by the Deployment.&lt;/p&gt;

&lt;p&gt;The values supplied by &lt;code&gt;web-config&lt;/code&gt; were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;APP_ENV=production&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WELCOME_MSG=Hello from AKS&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Inject the Secret into the Deployment
&lt;/h2&gt;

&lt;p&gt;I then added the Secret value to the Deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;set env &lt;/span&gt;deployment/web &lt;span class="nt"&gt;--from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret/web-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web env updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configured the container environment to include &lt;code&gt;API_KEY&lt;/code&gt; from &lt;code&gt;web-secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Changing the Deployment's Pod template triggered a rollout. I verified that the rollout completed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout status deployment/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment "web" successfully rolled out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbln8k1x9445xl4w5in7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbln8k1x9445xl4w5in7.png" alt="updates/rollout successful" width="800" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The new Pods were created using the updated Deployment configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the environment variables
&lt;/h3&gt;

&lt;p&gt;To verify that the values were available inside a running container, I executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;deploy/web &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;printenv &lt;/span&gt;APP_ENV WELCOME_MSG API_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;production
Hello from AKS
change-me-123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmf2481zaq0aeo9fxpra9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmf2481zaq0aeo9fxpra9.png" alt="env variables" width="800" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the container received the values from both Kubernetes configuration resources.&lt;/p&gt;

&lt;p&gt;The ConfigMap supplied the non-sensitive application configuration, while the Secret supplied the placeholder API key. Both were injected into the container environment without rebuilding the NGINX image.&lt;/p&gt;

&lt;p&gt;For real workloads, avoid printing secrets to logs or terminal output unnecessarily. When applications support it, mounting secrets as files or retrieving credentials from an external secret manager may be more appropriate, depending on the security requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. ClusterIP vs. LoadBalancer
&lt;/h2&gt;

&lt;p&gt;The two Services created in this mission serve different networking purposes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ClusterIP&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;LoadBalancer&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary purpose&lt;/td&gt;
&lt;td&gt;Internal application access&lt;/td&gt;
&lt;td&gt;External application access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default reachability&lt;/td&gt;
&lt;td&gt;Within the cluster&lt;/td&gt;
&lt;td&gt;Through an external load balancer when provisioned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable Service endpoint&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;Internal APIs and service-to-service traffic&lt;/td&gt;
&lt;td&gt;Applications that require a public or externally reachable endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both Services use label selectors to route traffic to the appropriate Pods. The choice of Service type depends on where the clients are located and how the application should be exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This mission brought together Kubernetes networking and configuration management.&lt;/p&gt;

&lt;p&gt;The main operational takeaways were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stable internal networking:&lt;/strong&gt; A ClusterIP Service provides an internal endpoint independent of individual Pod IP addresses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External access:&lt;/strong&gt; A LoadBalancer Service can expose an application through an Azure-managed external endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of configuration:&lt;/strong&gt; ConfigMaps allow non-sensitive settings to be managed separately from the container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret handling:&lt;/strong&gt; Kubernetes Secrets provide a dedicated resource for sensitive values, but they still require appropriate security controls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment injection:&lt;/strong&gt; ConfigMap and Secret values can be supplied to containers through the Deployment's environment configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollout verification:&lt;/strong&gt; Updating the Deployment's Pod template triggers a rollout, which can be monitored with &lt;code&gt;kubectl rollout status&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connectivity testing:&lt;/strong&gt; Testing both internal and external access verifies different parts of the application networking path.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;web&lt;/code&gt; Deployment was made accessible through an internal ClusterIP Service and an external LoadBalancer Service. The public endpoint returned the NGINX welcome page, confirming that external access was working.&lt;/p&gt;

&lt;p&gt;I also created &lt;code&gt;web-config&lt;/code&gt; and &lt;code&gt;web-secret&lt;/code&gt;, injected their values into the Deployment, and verified the resulting environment variables inside a running container.&lt;/p&gt;

&lt;p&gt;The result is a workload with a stable networking interface and configuration managed independently of its container image. These patterns form an important foundation for applications that need controlled connectivity and maintainable configuration on AKS.&lt;/p&gt;

&lt;p&gt;In Part 6, the series will continue with the next mission, building on the networking and configuration resources established here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 5 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>containers</category>
      <category>security</category>
      <category>aks</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads -Deployments, Self-Healing, Scaling and Rolling Updates (Part 4/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 17:05:48 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-deployments-self-healing-scaling-and-47po</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-deployments-self-healing-scaling-and-47po</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Running a container in Kubernetes is only the starting point. Production workloads also need mechanisms for maintaining the desired number of replicas, recovering from Pod failures, scaling with demand, and updating application versions without unnecessary disruption.&lt;/p&gt;

&lt;p&gt;In this mission, I used a Kubernetes Deployment to manage an NGINX workload on Azure Kubernetes Service (AKS). I configured three replicas, added health probes and resource constraints, verified the rollout, deleted a Pod to observe self-healing, scaled the Deployment, updated the container image, inspected rollout history, and rolled back to the previous version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Defining a Deployment with three NGINX replicas.&lt;/li&gt;
&lt;li&gt;Configuring resource requests, limits, and health probes.&lt;/li&gt;
&lt;li&gt;Verifying the rollout and associated ReplicaSet.&lt;/li&gt;
&lt;li&gt;Observing automatic Pod replacement after deletion.&lt;/li&gt;
&lt;li&gt;Scaling the Deployment from three replicas to five and back.&lt;/li&gt;
&lt;li&gt;Updating the NGINX image.&lt;/li&gt;
&lt;li&gt;Inspecting rollout history and recording the change cause.&lt;/li&gt;
&lt;li&gt;Rolling back to the previous Deployment revision.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Define the Deployment Manifest
&lt;/h2&gt;

&lt;p&gt;I created the Deployment manifest at &lt;code&gt;manifests/02-deployment.yaml&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/02-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  labels:
    app: web
spec:
  replicas: 3
  selector:
    matchLabels:
      app: web
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 3
            periodSeconds: 10
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q8i3xzpth5st91gzfkn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8q8i3xzpth5st91gzfkn.png" alt="manifest" width="799" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The manifest defines a Deployment named &lt;code&gt;web&lt;/code&gt;, using the &lt;code&gt;apps/v1&lt;/code&gt; API and the &lt;code&gt;app: web&lt;/code&gt; label to identify its Pods.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replica management
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
&lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Deployment declares three desired replicas. Its selector matches Pods labelled &lt;code&gt;app: web&lt;/code&gt;, while the Pod template applies that label to each replica it creates.&lt;/p&gt;

&lt;p&gt;This relationship allows the Deployment controller to manage the workload through a ReplicaSet, which maintains the required number of Pods.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rolling update strategy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RollingUpdate&lt;/span&gt;
  &lt;span class="na"&gt;rollingUpdate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;maxSurge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;maxUnavailable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update strategy controls how Kubernetes replaces old Pods during an update.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;maxSurge: 1&lt;/code&gt; allows one additional Pod above the desired replica count during a rollout.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxUnavailable: 0&lt;/code&gt; prevents the Deployment from intentionally reducing the number of available replicas below the desired count during the rolling update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These settings prioritize availability during updates, provided the cluster has sufficient capacity and the replacement Pods can become ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Container resources and health checks
&lt;/h3&gt;

&lt;p&gt;The NGINX container uses the &lt;code&gt;nginx:1.27&lt;/code&gt; image and exposes container port &lt;code&gt;80&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Its resource settings are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200m&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64Mi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;128Mi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The manifest also defines a readiness probe that sends an HTTP request to &lt;code&gt;/&lt;/code&gt; on port &lt;code&gt;80&lt;/code&gt;. It begins checking after three seconds and repeats every ten seconds.&lt;/p&gt;

&lt;p&gt;A readiness probe helps Kubernetes determine whether a container is ready to receive traffic. The configuration also establishes resource boundaries for each replica, making resource expectations explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Apply the Deployment and Verify the Rollout
&lt;/h2&gt;

&lt;p&gt;I applied the manifest to the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/02-deployment.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then monitored the rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout status deployment/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment "web" successfully rolled out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To inspect the Deployment, ReplicaSet, and Pods together, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deployment,replicaset,pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                 READY   UP-TO-DATE   AVAILABLE
deployment.apps/web  3/3     3            3

NAME                     DESIRED   CURRENT   READY
replicaset.apps/web-5b77b547f8  3   3         3

NAME                          READY   STATUS
pod/web-5b77b547f8-5jwm5       1/1     Running
pod/web-5b77b547f8-5tzd h      1/1     Running
pod/web-5b77b547f8-qpzqh       1/1     Running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh79xtu5o878874zykhxb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh79xtu5o878874zykhxb.png" alt="pod deployment" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important result was that the Deployment had three available replicas, the ReplicaSet maintained three Pods, and all three Pods were running.&lt;/p&gt;

&lt;p&gt;The Deployment and ReplicaSet provide different levels of workload management. The Deployment manages rollout and revision history, while the ReplicaSet maintains the required number of matching Pods.&lt;/p&gt;

&lt;p&gt;Together, they provide more control over application availability than a standalone Pod.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Verify Self-Healing by Deleting a Pod
&lt;/h2&gt;

&lt;p&gt;To test the Deployment controller's reconciliation behavior, I deleted one of the Pods selected by the &lt;code&gt;app=web&lt;/code&gt; label.&lt;/p&gt;

&lt;p&gt;I used the following command to identify a Pod and pass its name to &lt;code&gt;kubectl delete&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod &lt;span class="si"&gt;$(&lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].metadata.name}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed that the selected Pod had been deleted from the &lt;code&gt;k8s-lab&lt;/code&gt; namespace.&lt;/p&gt;

&lt;p&gt;I then checked the Pods again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1bly6ytj6lh4t8clc7g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv1bly6ytj6lh4t8clc7g.png" alt="self healing" width="800" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed three running Pods. A replacement had been created to maintain the Deployment's desired replica count.&lt;/p&gt;

&lt;p&gt;This is the Deployment controller's reconciliation behavior in action. The desired state remained three replicas, so Kubernetes worked to restore that state after one Pod was removed.&lt;/p&gt;

&lt;p&gt;The replacement Pod did not need to retain the deleted Pod's name. Kubernetes created a new Pod from the Deployment's template through its ReplicaSet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Operational takeaway:&lt;/strong&gt; Deleting a Pod managed by a Deployment does not normally reduce the workload's desired replica count. The controller works to maintain that count.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scale the Deployment
&lt;/h2&gt;

&lt;p&gt;The next step was to change the desired replica count.&lt;/p&gt;

&lt;p&gt;I scaled the Deployment from three replicas to five:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment web &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web scaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To verify the result and inspect the Pod placement, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;web &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed five running Pods, each with a Pod IP and placement on the AKS node.&lt;/p&gt;

&lt;p&gt;I then scaled the Deployment back to three replicas:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment web &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web scaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7l2blg3eoc73flqumunq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7l2blg3eoc73flqumunq.png" alt="scaliing replicas" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Scaling changes the number of Pod replicas managed by the Deployment. Scaling out from three to five increased the number of running instances, while scaling back to three reduced the desired replica count.&lt;/p&gt;

&lt;p&gt;This operation was performed manually with &lt;code&gt;kubectl scale&lt;/code&gt;. It was not an automatic response to CPU utilization or incoming traffic; automatic scaling requires additional configuration, such as a Horizontal Pod Autoscaler.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Update the NGINX Container Image
&lt;/h2&gt;

&lt;p&gt;After verifying replica management, I updated the Deployment to use the &lt;code&gt;nginx:1.27-alpine&lt;/code&gt; image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/web &lt;span class="nv"&gt;nginx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx:1.27-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web image updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new image uses the Alpine-based NGINX image variant. Changing the image in a Deployment triggers a rollout, allowing Kubernetes to replace Pods according to the configured update strategy.&lt;/p&gt;

&lt;p&gt;I then annotated the Deployment with the reason for the change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl annotate deployment/web kubernetes.io/change-cause&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"switch to nginx 1.27-alpine"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web annotated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filtqu7k4j607zd5l44be.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filtqu7k4j607zd5l44be.png" alt="annotated" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Recording the change cause makes the intention behind a revision easier to identify when reviewing rollout history.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Verify the Rollout and Inspect Revision History
&lt;/h2&gt;

&lt;p&gt;I checked the status of the updated Deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout status deployment/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rollout completed successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment "web" successfully rolled out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then inspected the revision history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout &lt;span class="nb"&gt;history &lt;/span&gt;deployment/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed two revisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web

REVISION  CHANGE-CAUSE
1         &amp;lt;none&amp;gt;
2         switch to nginx 1.27-alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvu6gdybvpws45mxp1x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcvvu6gdybvpws45mxp1x.png" alt="rollback" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Revision 1 represented the original Deployment configuration, while revision 2 recorded the change to the Alpine image.&lt;/p&gt;

&lt;p&gt;The history provides an operational record of Deployment revisions and their associated change causes. This can help identify which version is currently deployed and which revision to restore if an update needs to be reversed.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Roll Back to the Previous Revision
&lt;/h2&gt;

&lt;p&gt;To demonstrate rollback, I reverted the Deployment to its previous revision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl rollout undo deployment/web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/web rolled back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command also displayed a warning explaining that the Deployment had previously been managed with &lt;code&gt;kubectl apply&lt;/code&gt;. A rollback does not update the &lt;code&gt;kubectl.kubernetes.io/last-applied-configuration&lt;/code&gt; annotation, which can affect the behavior of future &lt;code&gt;kubectl apply&lt;/code&gt; operations. Kubernetes recommended applying the previous configuration file when appropriate.&lt;/p&gt;

&lt;p&gt;This warning is worth noting when mixing imperative changes, such as &lt;code&gt;kubectl set image&lt;/code&gt;, with declarative manifest management. The live Deployment, its rollout history, and the last-applied configuration annotation are related, but they are not interchangeable.&lt;/p&gt;

&lt;p&gt;To verify the image configured in the Deployment after the rollback, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deployment web &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.template.spec.containers[0].image}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nginx:1.27
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8yax4mb2x0fjakns9wy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8yax4mb2x0fjakns9wy.png" alt="verify rollback" width="800" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the Deployment's Pod template had returned to the original image.&lt;/p&gt;

&lt;p&gt;The rollback demonstrated how Deployment revision history can be used to reverse a change without manually recreating the entire workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This mission demonstrated several core capabilities used to manage Kubernetes applications:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Desired-state management:&lt;/strong&gt; A Deployment maintains the declared number of replicas through its controller and ReplicaSet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing:&lt;/strong&gt; When a managed Pod is deleted, Kubernetes creates a replacement to restore the desired replica count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling:&lt;/strong&gt; The replica count can be increased or reduced to change the number of running application instances.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health checks:&lt;/strong&gt; Readiness probes help Kubernetes determine whether a container is ready to receive traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rolling updates:&lt;/strong&gt; A Deployment can replace Pods gradually according to its update strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revision history:&lt;/strong&gt; Rollout history provides a record of Deployment revisions and their recorded change causes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback:&lt;/strong&gt; A previous Deployment revision can be restored when an update needs to be reversed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration management:&lt;/strong&gt; Mixing imperative updates with declarative &lt;code&gt;kubectl apply&lt;/code&gt; requires attention to the last-applied configuration and future changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These capabilities provide a stronger operational foundation than managing individual Pods directly. They make application changes more controlled and help maintain the intended state of a workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;web&lt;/code&gt; Deployment was successfully created with three NGINX replicas, and its rollout was verified. I confirmed that Kubernetes replaced a deleted Pod, scaled the Deployment to five replicas and back to three, updated the image to &lt;code&gt;nginx:1.27-alpine&lt;/code&gt;, reviewed the rollout history, and restored the original &lt;code&gt;nginx:1.27&lt;/code&gt; image through rollback.&lt;/p&gt;

&lt;p&gt;The key distinction is that a Deployment manages the desired state of an application rather than relying on individual Pods to remain unchanged. Replica management, health checks, rolling updates, and rollback together provide essential tools for operating workloads reliably on AKS.&lt;/p&gt;

&lt;p&gt;In Part 5, the series will continue with the next mission, building on the workload-management capabilities established here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 4 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aks</category>
      <category>containers</category>
      <category>scaling</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads - Pods, the Smallest Unit (Part 3/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:42:24 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-pods-the-smallest-unit-part-39-5d9i</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-pods-the-smallest-unit-part-39-5d9i</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A Kubernetes Pod is the smallest deployable unit in Kubernetes. It provides the execution environment for one or more containers, along with shared networking and storage resources.&lt;/p&gt;

&lt;p&gt;In this mission, I deployed a standalone NGINX Pod to my Azure Kubernetes Service (AKS) cluster, defined its resource requirements, inspected its runtime state, reviewed its logs, accessed the application locally, and examined what happens when a standalone Pod is deleted.&lt;/p&gt;

&lt;p&gt;The focus was on the Pod lifecycle and the operational details that matter when running containerized workloads on Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Defining a Pod using a Kubernetes YAML manifest.&lt;/li&gt;
&lt;li&gt;Deploying the Pod to the &lt;code&gt;k8s-lab&lt;/code&gt; namespace.&lt;/li&gt;
&lt;li&gt;Verifying its status, IP address, and assigned node.&lt;/li&gt;
&lt;li&gt;Inspecting container configuration and resource constraints.&lt;/li&gt;
&lt;li&gt;Reviewing NGINX logs and verifying its version.&lt;/li&gt;
&lt;li&gt;Accessing the application through port forwarding.&lt;/li&gt;
&lt;li&gt;Observing the behavior of a standalone Pod after deletion.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Define the Pod Manifest
&lt;/h2&gt;

&lt;p&gt;I created the manifest at &lt;code&gt;manifests/01-pod.yaml&lt;/code&gt; in the project repository.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; manifests/01-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: web-pod
  labels:
    app: web-pod
spec:
  containers:
    - name: nginx
      image: nginx:1.27
      ports:
        - containerPort: 80
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 200m
          memory: 128Mi
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tfgm1fbsixrbfxlg21l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tfgm1fbsixrbfxlg21l.png" alt="pod creation" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The manifest defines a Pod named &lt;code&gt;web-pod&lt;/code&gt; containing one NGINX container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key configuration details
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;apiVersion: v1&lt;/code&gt;&lt;/strong&gt; specifies the Kubernetes API version for the Pod resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kind: Pod&lt;/code&gt;&lt;/strong&gt; identifies the resource being created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadata.name&lt;/code&gt;&lt;/strong&gt; assigns the name &lt;code&gt;web-pod&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadata.labels&lt;/code&gt;&lt;/strong&gt; attaches the &lt;code&gt;app: web-pod&lt;/code&gt; label, which can be used to identify and select the Pod.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;image: nginx:1.27&lt;/code&gt;&lt;/strong&gt; specifies the NGINX container image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;containerPort: 80&lt;/code&gt;&lt;/strong&gt; documents the port on which the container is expected to receive HTTP traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU request: &lt;code&gt;50m&lt;/code&gt;&lt;/strong&gt; declares 50 millicores of CPU as the container's requested compute capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory request: &lt;code&gt;64Mi&lt;/code&gt;&lt;/strong&gt; declares 64 mebibytes of requested memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU limit: &lt;code&gt;200m&lt;/code&gt;&lt;/strong&gt; sets the container's CPU usage limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory limit: &lt;code&gt;128Mi&lt;/code&gt;&lt;/strong&gt; sets the container's memory limit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Resource requests and limits serve different purposes. Requests influence scheduling decisions, while limits constrain the resources a container can consume. CPU limits can result in throttling, whereas exceeding a container's memory limit can lead to termination.&lt;/p&gt;

&lt;p&gt;Defining these values makes the workload's resource expectations explicit rather than leaving them entirely unspecified.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Deploy the Pod
&lt;/h2&gt;

&lt;p&gt;With the manifest in place, I applied it to the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; manifests/01-pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed that the resource had been created:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod/web-pod created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then checked the Pod's status and placement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME      READY   STATUS    RESTARTS   AGE   IP           NODE
web-pod   1/1     Running   0          25s   10.244.0.139 aks-nodepool1-33590657-vmss000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bp59ac7ri5t7cias20k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9bp59ac7ri5t7cias20k.png" alt="pods" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The important observations were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ready: &lt;code&gt;1/1&lt;/code&gt;&lt;/strong&gt; — the Pod's single container was ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status: &lt;code&gt;Running&lt;/code&gt;&lt;/strong&gt; — the Pod had started successfully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarts: &lt;code&gt;0&lt;/code&gt;&lt;/strong&gt; — no container restarts had occurred at the time of inspection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod IP: &lt;code&gt;10.244.0.139&lt;/code&gt;&lt;/strong&gt; — Kubernetes assigned the Pod an internal IP address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node:&lt;/strong&gt; &lt;code&gt;aks-nodepool1-33590657-vmss000000&lt;/code&gt; — the scheduler placed the Pod on this AKS worker node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;-o wide&lt;/code&gt; option exposes additional placement and networking information beyond the default Pod listing.&lt;/p&gt;

&lt;p&gt;This verified that the manifest had been accepted and the NGINX workload was running in the &lt;code&gt;k8s-lab&lt;/code&gt; namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Inspect the Pod Configuration and Runtime State
&lt;/h2&gt;

&lt;p&gt;To examine the Pod in more detail, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod web-pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftawmopyr1biru2xh8qh4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftawmopyr1biru2xh8qh4.png" alt="decribe pod" width="800" height="574"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output confirmed that &lt;code&gt;web-pod&lt;/code&gt; was running in the &lt;code&gt;k8s-lab&lt;/code&gt; namespace, with the &lt;code&gt;nginx&lt;/code&gt; container using the &lt;code&gt;nginx:1.27&lt;/code&gt; image.&lt;/p&gt;

&lt;p&gt;The inspection also exposed the container's runtime information, resource configuration, readiness conditions, assigned node, and recent events.&lt;/p&gt;

&lt;h3&gt;
  
  
  Container readiness
&lt;/h3&gt;

&lt;p&gt;The Pod reported the following conditions as true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Initialized&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Ready&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ContainersReady&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PodScheduled&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j9mbm3nh6p993wnl8lt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3j9mbm3nh6p993wnl8lt.png" alt="pod status" width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These conditions provide useful operational information. A Pod's existence alone does not guarantee that its application is ready to serve traffic. Checking readiness and container state helps distinguish a successfully scheduled workload from one that is actually running and ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resource configuration
&lt;/h3&gt;

&lt;p&gt;The container's reported resource settings matched the manifest:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;&lt;code&gt;50m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200m&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory&lt;/td&gt;
&lt;td&gt;&lt;code&gt;64Mi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;128Mi&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The events also recorded the scheduling, image-pulling, container-creation, and container-start operations.&lt;/p&gt;

&lt;p&gt;The image was pulled successfully, and Kubernetes started the container without reporting a restart.&lt;/p&gt;

&lt;p&gt;This inspection connected the desired configuration in the YAML manifest with the state Kubernetes was reporting for the running workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Review the NGINX Logs
&lt;/h2&gt;

&lt;p&gt;I retrieved the container logs with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs web-pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuk35bh7cclsj75xykzet.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuk35bh7cclsj75xykzet.png" alt="kubectl logs" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output showed the NGINX container entrypoint initializing its configuration and launching the NGINX process.&lt;/p&gt;

&lt;p&gt;The logs included messages indicating that configuration was complete and that NGINX was ready to start up. They also showed the NGINX worker processes starting.&lt;/p&gt;

&lt;p&gt;Reviewing logs is an important part of troubleshooting containerized applications. They can reveal startup failures, configuration problems, and application-level errors that may not be obvious from the Pod status alone.&lt;/p&gt;

&lt;p&gt;In this case, the startup output was consistent with a successfully initialized NGINX container.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the NGINX Version
&lt;/h2&gt;

&lt;p&gt;To inspect the NGINX version from inside the running container, I executed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;web-pod &lt;span class="nt"&gt;--&lt;/span&gt; nginx &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nginx version: nginx/1.27.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhjoduush3j110led2ll.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhjoduush3j110led2ll.png" alt="kubectl exec" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This verified the version of NGINX running inside the container.&lt;/p&gt;

&lt;p&gt;The manifest specified the &lt;code&gt;nginx:1.27&lt;/code&gt; image tag, while the running binary reported version &lt;code&gt;1.27.5&lt;/code&gt;. This illustrates why inspecting the runtime can provide useful information beyond the image tag declared in a manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Access the Application with Port Forwarding
&lt;/h2&gt;

&lt;p&gt;The Pod was running NGINX on container port 80. To access it from my local machine without creating a Kubernetes Service or exposing the application publicly, I used port forwarding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward pod/web-pod 8080:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Forwarding from 127.0.0.1:8080 -&amp;gt; 80
Forwarding from [::1]:8080 -&amp;gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fddofm8463j25l4ak07f5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fddofm8463j25l4ak07f5.png" alt="port forward" width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This forwarded local port &lt;code&gt;8080&lt;/code&gt; to port &lt;code&gt;80&lt;/code&gt; on the Pod.&lt;/p&gt;

&lt;p&gt;With the port-forwarding process running, I opened the following address in my browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmp6hm2oz9meufcot60tl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmp6hm2oz9meufcot60tl.png" alt="nginx displayed" width="800" height="363"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The browser displayed the default &lt;strong&gt;Welcome to nginx!&lt;/strong&gt; page, confirming that the NGINX web server was accessible through the local port-forwarding connection.&lt;/p&gt;

&lt;p&gt;Port forwarding provided a convenient way to test the application without creating a public endpoint. The connection remained dependent on the &lt;code&gt;kubectl port-forward&lt;/code&gt; process, so it was useful for this verification rather than as a production traffic-routing solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; The port-forwarding command occupies the terminal while it is running. I kept that process active while accessing the page in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Delete the Standalone Pod
&lt;/h2&gt;

&lt;p&gt;After verifying the application, I deleted the Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod web-pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod "web-pod" deleted from k8s-lab namespace
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then checked the namespace for remaining Pods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No resources found in k8s-lab namespace.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fglduqgrgl3csw61ad1s4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fglduqgrgl3csw61ad1s4.png" alt="pod deleted" width="800" height="102"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the standalone Pod had been removed and that no Pods remained in the namespace at the time of the check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the Pod was not recreated
&lt;/h3&gt;

&lt;p&gt;A standalone Pod does not have a higher-level workload controller responsible for maintaining a desired number of replicas.&lt;/p&gt;

&lt;p&gt;Deleting it therefore removed the workload without automatically creating a replacement.&lt;/p&gt;

&lt;p&gt;This behavior is an important distinction between running a Pod directly and managing workloads through resources such as Deployments or StatefulSets. Controllers can reconcile the actual state with the declared desired state, including replacing Pods when necessary.&lt;/p&gt;

&lt;p&gt;For this mission, deleting the standalone Pod demonstrated its lifecycle and the absence of automatic replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Operational Takeaways
&lt;/h2&gt;

&lt;p&gt;This mission established a complete workflow for deploying and inspecting a basic Kubernetes workload.&lt;/p&gt;

&lt;p&gt;The key operational points were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declarative configuration:&lt;/strong&gt; A YAML manifest defines the desired Pod configuration and can be stored alongside the rest of the project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource management:&lt;/strong&gt; CPU and memory requests and limits establish explicit resource expectations for the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime inspection:&lt;/strong&gt; &lt;code&gt;kubectl get pods -o wide&lt;/code&gt; and &lt;code&gt;kubectl describe pod&lt;/code&gt; expose different levels of information about scheduling, readiness, networking, and container state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log inspection:&lt;/strong&gt; &lt;code&gt;kubectl logs&lt;/code&gt; helps reveal what happens during application startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container-level verification:&lt;/strong&gt; &lt;code&gt;kubectl exec&lt;/code&gt; can be used to inspect the running container, including its software version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local application access:&lt;/strong&gt; Port forwarding provides a practical way to test a Pod without creating a public endpoint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod lifecycle:&lt;/strong&gt; A standalone Pod is not automatically recreated after deletion because no higher-level controller manages its desired state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;web-pod&lt;/code&gt; workload was successfully deployed to AKS, inspected at both the Kubernetes and container levels, accessed through local port forwarding, and subsequently deleted.&lt;/p&gt;

&lt;p&gt;The exercise demonstrated how Kubernetes manifests translate into running workloads and how the Kubernetes CLI supports verification throughout the Pod lifecycle.&lt;/p&gt;

&lt;p&gt;A standalone Pod is useful for understanding the fundamentals and performing targeted tests. For workloads that require resilience, scaling, and automatic replacement, higher-level controllers provide the management capabilities needed to maintain the desired state.&lt;/p&gt;

&lt;p&gt;In the next part of this series, I will continue building on the AKS environment and explore the next workload-management concept in the lab.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is Part 3 of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>containers</category>
      <category>aks</category>
      <category>nginx</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads -Creating the AKS Cluster (Part 2/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:13:41 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-creating-the-aks-cluster-part-29-1kf1</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-creating-the-aks-cluster-part-29-1kf1</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Provisioning a Kubernetes cluster is the next step after preparing the development environment. In this part of the series, I moved from local tooling setup to creating an Azure Kubernetes Service (AKS) cluster, connecting &lt;code&gt;kubectl&lt;/code&gt; to it, verifying the cluster, and preparing a dedicated namespace for workloads.&lt;/p&gt;

&lt;p&gt;The objective was to establish a working Kubernetes environment on Azure that could support the deployments and experiments in the missions that follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Part Covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Loading the lab configuration.&lt;/li&gt;
&lt;li&gt;Creating an Azure resource group.&lt;/li&gt;
&lt;li&gt;Provisioning an AKS cluster.&lt;/li&gt;
&lt;li&gt;Retrieving cluster credentials and configuring &lt;code&gt;kubectl&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verifying the cluster and its system components.&lt;/li&gt;
&lt;li&gt;Creating and selecting a Kubernetes namespace.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Load the Lab Configuration
&lt;/h2&gt;

&lt;p&gt;I started from the project directory created in Part 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /mnt/c/Users/Admin/Desktop/labs/aks-kubernetes-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before provisioning any Azure resources, I loaded the environment variables defined in &lt;code&gt;lab.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source &lt;/span&gt;lab.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The environment file provides the configuration used by the commands in this mission, including the resource group name, cluster name, Azure region, and node size.&lt;/p&gt;

&lt;p&gt;Using variables instead of repeatedly hardcoding resource names makes the commands easier to maintain and reuse throughout the lab.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Check the Resource Group and Create It
&lt;/h2&gt;

&lt;p&gt;Before creating the resource group, I checked whether it already existed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group show &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; location &lt;span class="nt"&gt;--output&lt;/span&gt; tsv 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command queries the resource group's location. Redirecting standard error to &lt;code&gt;/dev/null&lt;/code&gt; suppresses error messages if the resource group does not exist.&lt;/p&gt;

&lt;p&gt;I then created the resource group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned the following result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Location    Name
----------  ----------
westeurope  rg-aks-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2xhtr1irn78i0vhx0o2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs2xhtr1irn78i0vhx0o2.png" alt="azure cli" width="800" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that the resource group &lt;code&gt;rg-aks-lab&lt;/code&gt; was available in &lt;code&gt;westeurope&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;An Azure resource group provides a logical container for related resources. In this lab, it serves as the main resource group for the AKS environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Provision the AKS Cluster
&lt;/h2&gt;

&lt;p&gt;With the resource group created, I provisioned the AKS cluster using the Azure CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tier&lt;/span&gt; free &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-vm-size&lt;/span&gt; &lt;span class="nv"&gt;$NODE_SIZE&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what the main options do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--resource-group&lt;/code&gt; specifies the Azure resource group that will contain the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--name&lt;/code&gt; sets the AKS cluster name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--location&lt;/code&gt; specifies the Azure region.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--tier free&lt;/code&gt; selects the Free tier for AKS cluster management.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--node-count 1&lt;/code&gt; configures one initial worker node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--node-vm-size&lt;/code&gt; specifies the virtual machine size for the node.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--generate-ssh-keys&lt;/code&gt; generates SSH keys if required.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--output table&lt;/code&gt; presents the returned information in a table format.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The command displayed a notice that the &lt;code&gt;aks-preview&lt;/code&gt; extension had altered its behavior. It also warned that the new node pool would enable SSH access and recommended using &lt;code&gt;--ssh-access disabled&lt;/code&gt; to disable SSH access for better security.&lt;/p&gt;

&lt;p&gt;The command subsequently returned cluster information, including the Kubernetes version, provisioning state, resource group, and cluster name.&lt;/p&gt;

&lt;p&gt;The key result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ProvisioningState: Succeeded
ResourceGroup:     rg-aks-lab
Name:              aks-lab
Location:          westeurope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provisioning state of &lt;code&gt;Succeeded&lt;/code&gt; confirmed that Azure had completed the cluster creation operation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8051u4xginf1j8c4twhe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8051u4xginf1j8c4twhe.png" alt="AKS creation" width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Connect kubectl to the AKS Cluster
&lt;/h2&gt;

&lt;p&gt;Creating the cluster is only part of the process. I also needed to configure my local Kubernetes client to communicate with it.&lt;/p&gt;

&lt;p&gt;I retrieved the cluster credentials with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks get-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--overwrite-existing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Merged "aks-lab" as current context in /home/rahimahisah/.kube/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This merged the AKS cluster credentials into the local kubeconfig file and selected the &lt;code&gt;aks-lab&lt;/code&gt; context.&lt;/p&gt;

&lt;p&gt;To confirm the active context, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config current-context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aks-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed that &lt;code&gt;kubectl&lt;/code&gt; was configured to use the AKS cluster context.&lt;/p&gt;

&lt;p&gt;Kubernetes contexts allow a single kubeconfig file to hold connection details for multiple clusters. Checking the active context before running commands is a useful habit because it helps prevent accidentally targeting the wrong environment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fetjowp9onvuzx2deiwc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fetjowp9onvuzx2deiwc8.png" alt="Retrieving AKS credentials and verifying the current Kubernetes context" width="800" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Verify the Kubernetes Cluster
&lt;/h2&gt;

&lt;p&gt;After connecting &lt;code&gt;kubectl&lt;/code&gt;, I checked the cluster's node status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME                                STATUS   ROLES    AGE     VERSION
aks-nodepool1-33590657-vmss000000   Ready    &amp;lt;none&amp;gt;   3m18s   v1.36.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node was in the &lt;code&gt;Ready&lt;/code&gt; state, indicating that Kubernetes considered it ready to accept workloads.&lt;/p&gt;

&lt;p&gt;The wider output also included information about the node's internal IP address, operating system image, kernel version, and container runtime.&lt;/p&gt;

&lt;p&gt;The node was running Ubuntu 24.04.5 LTS, and the Kubernetes version shown was &lt;code&gt;v1.36.4&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspect the cluster components
&lt;/h3&gt;

&lt;p&gt;Next, I checked the cluster information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl cluster-info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirmed that the Kubernetes control plane was running and displayed the endpoints for CoreDNS and the metrics server.&lt;/p&gt;

&lt;p&gt;I then inspected the system pods in the &lt;code&gt;kube-system&lt;/code&gt; namespace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output showed system components in the &lt;code&gt;Running&lt;/code&gt; state, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;azure-cns&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;azure-ip-masq-agent&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cloud-node-manager&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coredns&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;coredns-autoscaler&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;csi-azuredisk-node&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;csi-azurefile-node&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kube-proxy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;metrics-server&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;konnectivity-agent&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These components support the operation of the cluster. For example, CoreDNS provides cluster DNS services, the CSI node components support storage integration, and the metrics server provides resource metrics used by Kubernetes tools and autoscaling components.&lt;/p&gt;

&lt;p&gt;Checking the node, cluster information, and system pods provided an initial verification that the cluster was accessible and its core components were running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshot 4:&lt;/strong&gt; Node status, cluster information, and system pods.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr446uilrdhlr052k1msc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr446uilrdhlr052k1msc.png" alt="Node status, cluster information, and system pods" width="799" height="311"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Create a Dedicated Kubernetes Namespace
&lt;/h2&gt;

&lt;p&gt;With the cluster verified, I created a namespace for the lab workloads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes confirmed the operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;namespace/k8s-lab created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then configured the current context to use the new namespace by default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config set-context &lt;span class="nt"&gt;--current&lt;/span&gt; &lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output confirmed that the &lt;code&gt;aks-lab&lt;/code&gt; context had been modified.&lt;/p&gt;

&lt;p&gt;To verify the namespace configured in the current context, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config view &lt;span class="nt"&gt;--minify&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s1"&gt;'jsonpath={..namespace}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;k8s-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means that subsequent &lt;code&gt;kubectl&lt;/code&gt; commands that do not explicitly specify a namespace will use &lt;code&gt;k8s-lab&lt;/code&gt; by default.&lt;/p&gt;

&lt;p&gt;Namespaces provide a way to organize Kubernetes resources within a cluster. Creating a dedicated namespace keeps the lab workloads separate from system components running in &lt;code&gt;kube-system&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5iqt54m5rxuka7qeotw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5iqt54m5rxuka7qeotw.png" alt="Namespace creation" width="800" height="489"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What Learned
&lt;/h2&gt;

&lt;p&gt;This mission connected the local tooling setup from Part 1 to a working Kubernetes environment hosted on Azure.&lt;/p&gt;

&lt;p&gt;The key takeaways were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure resource organization:&lt;/strong&gt; Resource groups provide a logical boundary for related Azure resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster provisioning:&lt;/strong&gt; The Azure CLI can provision an AKS cluster using a repeatable set of configuration options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes authentication and context:&lt;/strong&gt; Retrieving credentials allows &lt;code&gt;kubectl&lt;/code&gt; to communicate with the selected cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cluster verification:&lt;/strong&gt; Checking node readiness, cluster information, and system pods helps establish whether the environment is operational.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace management:&lt;/strong&gt; A dedicated namespace helps organize lab workloads and avoid mixing them with system resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One important habit I reinforced was verifying the active Kubernetes context before working with cluster resources. It is a small check that can help prevent mistakes when managing multiple environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The AKS cluster was provisioned successfully, the local Kubernetes client was connected to it, and the &lt;code&gt;k8s-lab&lt;/code&gt; namespace was created and selected as the default namespace for the current context.&lt;/p&gt;

&lt;p&gt;The environment is now ready for the next stage of the series, where I will begin working with Kubernetes resources and workloads.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Part 3&lt;/strong&gt;, the series will continue with the next mission in the lab, building on the cluster and namespace established here.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of the nine-part series, “Kubernetes on AKS: From Zero to Production Workloads.”&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aks</category>
      <category>cloud</category>
      <category>linux</category>
    </item>
    <item>
      <title>Kubernetes on AKS: From Zero to Production Workloads -Setting Up the Foundation (Part 1/9)</title>
      <dc:creator>Rahimah Sulayman</dc:creator>
      <pubDate>Mon, 26 Oct 2026 15:31:25 +0000</pubDate>
      <link>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-setting-up-the-foundation-part-19-1gpl</link>
      <guid>https://dev.to/rahimah_dev/kubernetes-on-aks-from-zero-to-production-workloads-setting-up-the-foundation-part-19-1gpl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Series:&lt;/strong&gt; Kubernetes on AKS: From Zero to Production Workloads&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Part 1:&lt;/strong&gt; Workspace, Tools and Azure Sign-In&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Environment:&lt;/strong&gt; Ubuntu on WSL, VS Code terminal, Azure CLI, &lt;code&gt;kubectl&lt;/code&gt; and Git&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before deploying workloads to Kubernetes, there is some groundwork to do. A reliable environment needs more than a cluster: it needs a structured project directory, version control, the right command-line tools, authenticated access to Azure, and the necessary resource providers.&lt;/p&gt;

&lt;p&gt;In this first part of my nine-part series, I'll walk through how I prepared the environment for a hands-on Azure Kubernetes Service (AKS) lab. I'll cover the project workspace, Git initialization, Azure CLI installation, Azure authentication, Kubernetes command-line tools, and Azure resource provider registration. I'll also share an installation error I encountered, how I investigated it, and the workaround that allowed me to continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Creating the project workspace
&lt;/h2&gt;

&lt;p&gt;I started by creating a dedicated directory for the lab and navigating into it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /mnt/c/Users/Admin/Desktop/labs/aks-kubernetes-lab
&lt;span class="nb"&gt;cd&lt;/span&gt; /mnt/c/Users/Admin/Desktop/labs/aks-kubernetes-lab
&lt;span class="nb"&gt;pwd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what each command does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mkdir -p&lt;/code&gt; creates the project directory, including any missing parent directories.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cd&lt;/code&gt; moves into the directory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pwd&lt;/code&gt; prints the current working directory so we can verify the location.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The terminal returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/mnt/c/Users/Admin/Desktop/labs/aks-kubernetes-lab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirming the working directory matters because subsequent commands create files and perform Git operations relative to that location.&lt;/p&gt;

&lt;p&gt;A dedicated workspace keeps the project's documentation, Kubernetes manifests, and configuration organized as the lab grows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa379lghsccgeawm124p4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa379lghsccgeawm124p4.png" alt="part-01-workspace-creation.png" width="800" height="95"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Initializing Git
&lt;/h2&gt;

&lt;p&gt;With the workspace ready, I initialized a Git repository using &lt;code&gt;main&lt;/code&gt; as the initial branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git init &lt;span class="nt"&gt;-b&lt;/span&gt; main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal confirmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initialized empty Git repository in /mnt/c/Users/Admin/Desktop/labs/aks-kubernetes-lab/.git/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff032g314t7hx4slqhtvh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff032g314t7hx4slqhtvh.png" alt="git init" width="800" height="95"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Git provides version control, allowing us to track changes, review previous versions, and maintain a history of the files created throughout the project.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-b main&lt;/code&gt; option establishes &lt;code&gt;main&lt;/code&gt; as the initial branch.&lt;/p&gt;

&lt;p&gt;At this point, the repository existed locally. It had not yet been connected to a remote GitHub repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Creating the initial project structure
&lt;/h2&gt;

&lt;p&gt;Next, I created a directory to hold the Kubernetes manifests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; manifests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I then created an initial &lt;code&gt;README.md&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'# Kubernetes on AKS\n\nProduction Kubernetes workloads on Azure Kubernetes Service, built as a hands-on lab.\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To inspect the file, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Kubernetes on AKS&lt;/span&gt;

Production Kubernetes workloads on Azure Kubernetes Service, built as a hands-on lab.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This README was intentionally minimal. It established the repository's entry point, while the &lt;code&gt;manifests/&lt;/code&gt; directory provided a dedicated location for Kubernetes YAML files.&lt;/p&gt;

&lt;p&gt;The initial project structure looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aks-kubernetes-lab/
├── manifests/
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This represents the initial structure, not the final repository layout.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpebh5d4nkezi4bvxssoy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpebh5d4nkezi4bvxssoy.png" alt="part-01-initial-project-structure" width="800" height="139"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Staging the README
&lt;/h2&gt;

&lt;p&gt;Before creating the first commit, I staged the README and checked the repository status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add README.md
git status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached &amp;lt;file&amp;gt;..." to unstage)
        new file:   README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyqcoxdqf52n9jb9wiz0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvyqcoxdqf52n9jb9wiz0.png" alt="git status" width="800" height="220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output confirmed that &lt;code&gt;README.md&lt;/code&gt; was staged and ready to be included in a commit.&lt;/p&gt;

&lt;p&gt;Git's staging area lets us choose which changes belong in a commit. This is useful when a project contains multiple modifications but we want to record only a specific set of changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Adding &lt;code&gt;.gitignore&lt;/code&gt; and creating the first commit
&lt;/h2&gt;

&lt;p&gt;Before committing the initial files, I created a &lt;code&gt;.gitignore&lt;/code&gt; file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'lab.env\n*.log\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its contents were:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lab.env
*.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;lab.env&lt;/code&gt; rule tells Git to ignore a file named &lt;code&gt;lab.env&lt;/code&gt;, while &lt;code&gt;*.log&lt;/code&gt; ignores files ending in &lt;code&gt;.log&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a useful precaution when working with cloud infrastructure. Environment files may contain configuration values that should not be published, and log files can add unnecessary clutter to a repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; &lt;code&gt;.gitignore&lt;/code&gt; does not remove files that have already been committed or staged. It is a preventive measure, not a replacement for proper secret management.&lt;/p&gt;

&lt;p&gt;I staged the files and created the initial commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add README.md .gitignore
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore: initialise AKS lab workspace"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Git confirmed the commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[main (root-commit) 7bdb9aa] chore: initialise AKS lab workspace
2 files changed, 5 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This established the first checkpoint in the repository's history. Subsequent changes could now be recorded as separate commits.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg7u9yyfl1j7g4kh6lu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqg7u9yyfl1j7g4kh6lu7.png" alt="initial-git-commit" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Installing Azure CLI
&lt;/h2&gt;

&lt;p&gt;The next step was to install the Azure command-line interface (Azure CLI).&lt;/p&gt;

&lt;p&gt;Azure CLI allows us to manage Azure resources from a terminal. It is particularly useful for repeatable infrastructure workflows, where commands can be documented and reused instead of relying exclusively on the Azure portal.&lt;/p&gt;

&lt;p&gt;I ran the installation command provided by the lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; https://aka.ms/InstallAzureCLIDeb | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjifu05xni07fzqdoa99i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjifu05xni07fzqdoa99i.png" alt="part-01-azure-cli-installation" width="800" height="479"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The command downloads and executes Microsoft's installation script for Debian-based Linux environments. Because it runs with elevated privileges, it is important to use a trusted installation source and understand what the command does.&lt;/p&gt;

&lt;p&gt;The terminal showed the package repositories being updated and Azure CLI being installed.&lt;/p&gt;

&lt;p&gt;I then verified the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azure-cli"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.91.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azure-cli-core"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.91.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azure-cli-telemetry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.1.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed that Azure CLI was installed and available from the terminal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qqtdcling14kuwyjbrk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8qqtdcling14kuwyjbrk.png" alt="az version" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Signing in to Azure
&lt;/h2&gt;

&lt;p&gt;With Azure CLI installed, I authenticated using device-code sign-in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az login &lt;span class="nt"&gt;--use-device-code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fecarl3543e4gfods24j6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fecarl3543e4gfods24j6.png" alt="azure-device-login" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The CLI instructed me to open Microsoft's device-login page in a browser and enter the displayed code. After completing the browser authentication, I selected the available subscription.&lt;/p&gt;

&lt;p&gt;The terminal showed the selected subscription as &lt;code&gt;Azure subscription 1&lt;/code&gt; under &lt;code&gt;Default Directory&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Device-code authentication is useful when the terminal cannot conveniently open a browser or when authentication needs to be completed in a separate browser session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying the active account
&lt;/h3&gt;

&lt;p&gt;I checked the active Azure account using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az account show &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxsfbhu5ivlwfm82c3gi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxsfbhu5ivlwfm82c3gi.png" alt="azure-account-verification" width="797" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The output displayed account information, including the subscription name, state, and tenant details.&lt;/p&gt;

&lt;p&gt;This verification is important because the subscription selected in Azure CLI determines where subsequent Azure commands will operate. Accidentally using the wrong subscription can result in resources being created in the wrong environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; Before publishing terminal screenshots, redact device codes, access tokens, credentials, and any identifiers you don't want to expose.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Installing Kubernetes command-line tools
&lt;/h2&gt;

&lt;p&gt;Azure CLI manages Azure resources, but we also need &lt;code&gt;kubectl&lt;/code&gt; to interact with Kubernetes.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;kubectl&lt;/code&gt; is the Kubernetes command-line client. It allows us to inspect resources, apply manifests, examine workload status, and communicate with the Kubernetes API when a cluster is available and the appropriate credentials and permissions are configured.&lt;/p&gt;

&lt;p&gt;The lab's initial installation command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;az aks install-cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Troubleshooting: The default installation path failed
&lt;/h3&gt;

&lt;p&gt;The command encountered an error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connection error while attempting to download client
([Errno 2] No such file or directory: '/usr/local/bin/kubectl')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal also showed that &lt;code&gt;/usr/local/bin&lt;/code&gt; existed.&lt;/p&gt;

&lt;p&gt;This was a useful reminder that an installation command can fail at its target path even when Azure CLI itself is working. Rather than repeatedly retrying the same command, I inspected the directory and switched to a user-local installation location.&lt;/p&gt;

&lt;p&gt;First, I checked the directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-ld&lt;/span&gt; /usr/local/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created a directory under my home folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.local/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reran the installation with explicit paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks install-cli &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--install-location&lt;/span&gt; ~/.local/bin/kubectl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--kubelogin-install-location&lt;/span&gt; ~/.local/bin/kubelogin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This instructed the installer to place &lt;code&gt;kubectl&lt;/code&gt; and &lt;code&gt;kubelogin&lt;/code&gt; in my user-local binary directory.&lt;/p&gt;

&lt;p&gt;The installer completed the downloads and advised that &lt;code&gt;~/.local/bin&lt;/code&gt; needed to be included in &lt;code&gt;PATH&lt;/code&gt; so the shell could find the commands.&lt;/p&gt;

&lt;h3&gt;
  
  
  Adding the installation directory to &lt;code&gt;PATH&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;I added the directory to my Bash configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'export PATH="$HOME/.local/bin:$PATH"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I reloaded the configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, I verified the Kubernetes client installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl version &lt;span class="nt"&gt;--client&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client Version: v1.37.1
Kustomize Version: v5.8.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed that the &lt;code&gt;kubectl&lt;/code&gt; client was installed and available from the shell.&lt;/p&gt;

&lt;h3&gt;
  
  
  What resolved the problem?
&lt;/h3&gt;

&lt;p&gt;The workaround used a directory within my home folder rather than the original default installation location. Updating &lt;code&gt;PATH&lt;/code&gt; then allowed the shell to locate the downloaded binaries.&lt;/p&gt;

&lt;p&gt;The failure occurred during local tooling setup, before a Kubernetes cluster was involved. Distinguishing local installation problems from cluster or workload problems makes troubleshooting more systematic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fja30oqlks108oqviabio.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fja30oqlks108oqviabio.png" alt="kubectl-install" width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Registering Azure resource providers
&lt;/h2&gt;

&lt;p&gt;Before creating Azure resources, the subscription may need the relevant resource providers registered.&lt;/p&gt;

&lt;p&gt;A resource provider exposes the management operations for a category of Azure resources. For this lab, I registered four providers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az provider register &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.ContainerService
az provider register &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.Compute
az provider register &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.Network
az provider register &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.Storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These providers correspond to the services used throughout the lab:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource provider&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft.ContainerService&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AKS and related container-service management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft.Compute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Virtual machines and compute resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft.Network&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Networking resources, including virtual networks and load balancers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Microsoft.Storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Azure storage resources&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Registering a provider does not create an AKS cluster or provision the associated resources. It enables the subscription to use the relevant provider's operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying registration
&lt;/h3&gt;

&lt;p&gt;I checked the registration state of the AKS provider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az provider show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; Microsoft.ContainerService &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; registrationState &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Registered
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa24hy71mtf33puvpwa2p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa24hy71mtf33puvpwa2p.png" alt="registered" width="797" height="130"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed that &lt;code&gt;Microsoft.ContainerService&lt;/code&gt; was registered. The screenshot documents the verification of this provider; it does not independently confirm the registration state of all four providers.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Preparing the lab environment
&lt;/h2&gt;

&lt;p&gt;The lab used an environment file to keep its configuration values consistent across subsequent commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;' &amp;gt; lab.env
export RESOURCE_GROUP=rg-aks-lab
export LOCATION=westeurope
export CLUSTER_NAME=aks-lab
export NODE_SIZE=Standard_D2s_v3
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I loaded the variables into the current shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source &lt;/span&gt;lab.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I checked their values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RESOURCE_GROUP&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$CLUSTER_NAME&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$NODE_SIZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The terminal returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rg-aks-lab westeurope aks-lab Standard_D2s_v3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqklrbs90j2ls1r4dgi2k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqklrbs90j2ls1r4dgi2k.png" alt="list" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Using variables avoids repeatedly typing the same resource names and configuration values throughout a lab. It also reduces the chance of accidentally using inconsistent names in later commands.&lt;/p&gt;

&lt;p&gt;The values describe the intended lab configuration; they do not mean the AKS cluster has already been created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security reminder:&lt;/strong&gt; &lt;code&gt;lab.env&lt;/code&gt; is included in &lt;code&gt;.gitignore&lt;/code&gt;. Keep credentials and secrets out of source control, and check the repository's status before committing changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated workspace and Git history provide a maintainable starting point for infrastructure projects.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.gitignore&lt;/code&gt; helps prevent selected local files from being added to the repository, but it does not replace proper secret management.&lt;/li&gt;
&lt;li&gt;Azure CLI and &lt;code&gt;kubectl&lt;/code&gt; have different roles: one manages Azure resources, while the other interacts with Kubernetes.&lt;/li&gt;
&lt;li&gt;When an installer fails at its default path, inspecting the environment and selecting a suitable installation location can provide a practical workaround.&lt;/li&gt;
&lt;li&gt;Always verify the active Azure account and subscription before creating or modifying resources.&lt;/li&gt;
&lt;li&gt;Registering a resource provider enables its operations; it does not create resources by itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Part 2&lt;/strong&gt;, I'll continue with the next stage of the lab: provisioning and configuring the Azure Kubernetes Service infrastructure, following the lab guide.&lt;/p&gt;

&lt;p&gt;We'll move from preparing the local environment to establishing the Azure infrastructure on which the Kubernetes workloads will run.&lt;/p&gt;

&lt;p&gt;This is the foundation for the remaining parts of the series, covering Kubernetes workloads, networking, configuration, storage, automation, scaling, cost optimization, and cleanup.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article documents the commands and outcomes captured during the lab. Installation results and version numbers reflect the recorded environment and may differ on other systems. Review commands, permissions, and potential Azure charges before running them in your own subscription.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>azure</category>
      <category>devops</category>
      <category>linux</category>
    </item>
  </channel>
</rss>
