<?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: manny300</title>
    <description>The latest articles on DEV Community by manny300 (@manny300).</description>
    <link>https://dev.to/manny300</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%2F787659%2F6c3da68e-1c2c-42f3-a42b-885328ad64d9.jpg</url>
      <title>DEV Community: manny300</title>
      <link>https://dev.to/manny300</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/manny300"/>
    <language>en</language>
    <item>
      <title>I Built a Secure, Decoupled E-Commerce Architecture on AWS Using Only Free-Tier Resources</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Tue, 01 Sep 2026 17:54:32 +0000</pubDate>
      <link>https://dev.to/manny300/i-built-a-secure-decoupled-e-commerce-architecture-on-aws-using-only-free-tier-resources-pgf</link>
      <guid>https://dev.to/manny300/i-built-a-secure-decoupled-e-commerce-architecture-on-aws-using-only-free-tier-resources-pgf</guid>
      <description>&lt;p&gt;Monolithic application deployments mix public web traffic with internal database storage, creating a single point of failure and unnecessarily expanding the attack surface. To solve this, I designed and deployed a multi-tier PrestaShop e-commerce architecture on AWS. It decouples the application layer from the database layer, restricts network traffic using the Principle of Least Privilege, and remains entirely within AWS Free-Tier constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An AWS Account (Free-Tier eligible)&lt;/li&gt;
&lt;li&gt;Basic understanding of Linux terminal commands&lt;/li&gt;
&lt;li&gt;An SSH key pair for secure access&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Database Provisioning (Amazon RDS)
&lt;/h2&gt;

&lt;p&gt;I prioritized the creation of a managed relational database to establish a secure and isolated data backend before configuring the application frontend.&lt;/p&gt;

&lt;p&gt;I accessed the RDS Console to initialize a new MySQL 8.0 instance. I selected the &lt;code&gt;db.t3.micro&lt;/code&gt; instance class under the Free-tier template to maintain cost compliance. Most importantly, I disabled public accessibility. The database must remain internal to the VPC. A public-facing database is an unnecessary risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Application Server Provisioning (Amazon EC2)
&lt;/h2&gt;

&lt;p&gt;For the web host, I launched a new Ubuntu 26.04 LTS instance via the EC2 Dashboard. I selected the &lt;code&gt;t3.micro&lt;/code&gt; instance type to ensure Free-Tier eligibility. I also generated a new RSA Key Pair to authenticate via secure shell.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Security Group Configuration
&lt;/h2&gt;

&lt;p&gt;This is where the architecture becomes secure. Simply putting resources in a VPC is not enough; you must define explicit traffic rules.&lt;/p&gt;

&lt;p&gt;For the EC2 Security Group, I defined inbound rules to permit SSH (Port 22) access only from my personal IP address. I allowed global HTTP (Port 80) access so the storefront is accessible to the public.&lt;/p&gt;

&lt;p&gt;For the RDS Security Group, I encapsulated the database by allowing traffic on Port 3306 only if it originates from the EC2 instance's specific Security Group ID. This establishes a secure, private link between the tiers. The database will drop any traffic that does not come directly from the web server.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Troubleshooting SSH Key Permissions
&lt;/h2&gt;

&lt;p&gt;When I first attempted to connect to the EC2 instance, macOS blocked the connection with a &lt;code&gt;WARNING: UNPROTECTED PRIVATE KEY FILE!&lt;/code&gt; error because the downloaded key had &lt;code&gt;0644&lt;/code&gt; permissions (readable by anyone). SSH requires private keys to be strictly secured.&lt;/p&gt;

&lt;p&gt;I fixed this by restricting the file permissions so only my user account could read 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;chmod &lt;/span&gt;400 ~/.ssh/prestashop-key.pem
ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/prestashop-key.pem ubuntu@100.62.104.71
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Server Configuration &amp;amp; Dependency Installation
&lt;/h2&gt;

&lt;p&gt;Once connected, I updated the package manager and prepared to install Apache and PHP 8.1. Because I used a newer Ubuntu release (26.04), the standard &lt;code&gt;ppa:ondrej/php&lt;/code&gt; repository was obsolete and returned a 404 error. &lt;/p&gt;

&lt;p&gt;I resolved this by removing the broken repository and securely adding the canonical SURY repository via its GPG key:&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;# 1. Update the system&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# 2. Install prerequisites and add the new PHP GPG key and repository&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https lsb-release ca-certificates curl apache2
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-sSLo&lt;/span&gt; /usr/share/keyrings/deb.sury.org-php.gpg https://packages.sury.org/php/apt.gpg
&lt;span class="nb"&gt;sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "deb [signed-by=/usr/share/keyrings/deb.sury.org-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" &amp;gt; /etc/apt/sources.list.d/php.list'&lt;/span&gt;

&lt;span class="c"&gt;# 3. Update the package list and install PHP 8.1 with required extensions&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;php8.1 libapache2-mod-php8.1 php8.1-mysql php8.1-curl php8.1-xml php8.1-gd php8.1-mbstring php8.1-intl php8.1-zip &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# 4. Enable Apache rewrite module and restart&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;a2enmod rewrite
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart apache2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I activated the Apache rewrite module (&lt;code&gt;a2enmod rewrite&lt;/code&gt;) to facilitate search engine friendly URL structures for the e-commerce platform.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. PrestaShop Installation &amp;amp; Database Connection
&lt;/h2&gt;

&lt;p&gt;With the environment ready, I pulled the latest PrestaShop release directly onto the server using &lt;code&gt;wget&lt;/code&gt; and deployed the installation files to the &lt;code&gt;/var/www/html/&lt;/code&gt; web root.&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 chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; www-data:www-data /var/www/html/
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 755 /var/www/html/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I modified the folder ownership to the &lt;code&gt;www-data&lt;/code&gt; user and applied &lt;code&gt;755&lt;/code&gt; permissions. This ensures the application has the proper write access to function, without exposing the files to broader system access.&lt;/p&gt;

&lt;p&gt;I then initiated the web-based installation wizard via the public DNS. During the initial setup, I encountered a "Database Server is not found" error. I quickly realized two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I could not use &lt;code&gt;127.0.0.1&lt;/code&gt; (localhost) because this violates the decoupled architecture; the database is not on the same server.&lt;/li&gt;
&lt;li&gt;I had to use the exact RDS Endpoint URL and ensure the RDS Security Group accurately referenced the EC2 Security Group ID.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After providing the correct remote RDS endpoint and credentials, the connection succeeded.&lt;/p&gt;

&lt;p&gt;Once the installation completed, I removed the &lt;code&gt;/install&lt;/code&gt; directory to harden security and prevent malicious re-installation. &lt;/p&gt;

&lt;p&gt;The PrestaShop environment successfully transitioned to a live state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publicly Accessible Storefront:&lt;/strong&gt; &lt;a href="http://ec2-100-62-104-71.compute-1.amazonaws.com" rel="noopener noreferrer"&gt;http://ec2-100-62-104-71.compute-1.amazonaws.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin Portal:&lt;/strong&gt; &lt;a href="http://ec2-100-62-104-71.compute-1.amazonaws.com/admin588uov6hcpqq1sxjj8s/index.php?controller=AdminDashboard&amp;amp;token=fcb7dff774d88a7d032c77adaa0e6e7c#" rel="noopener noreferrer"&gt;http://ec2-100-62-104-71.compute-1.amazonaws.com/admin588uov6hcpqq1sxjj8s/index.php&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I Would Improve in a v2
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Automate the provisioning process using declarative Infrastructure as Code (Terraform) instead of relying on manual AWS Console configuration.&lt;/li&gt;
&lt;li&gt;Place the RDS instance into a dedicated private subnet with no internet gateway route to provide an even stronger network boundary.&lt;/li&gt;
&lt;li&gt;Implement an Application Load Balancer (ALB) in front of the EC2 instance to allow for HTTPS termination and future horizontal scaling.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decoupling stateful data from stateless compute is the foundation of a resilient architecture.&lt;/li&gt;
&lt;li&gt;The Principle of Least Privilege applies directly to network traffic. Restricting database access to a specific application security group is a baseline requirement.&lt;/li&gt;
&lt;li&gt;When working with modern Linux distributions, legacy package repositories often break. Knowing how to manually manage GPG keys and sources lists is a critical debugging skill.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Follow me for more content about Cloud Engineering and DevOps. Follow along on &lt;a href="https://dev.to/manny300"&gt;my Dev.to profile&lt;/a&gt; and &lt;a href="https://github.com/EmmanuelAjibokun" rel="noopener noreferrer"&gt;my github profile&lt;/a&gt; if you want to see how it goes.&lt;/p&gt;

</description>
      <category>cloudengineering</category>
      <category>automation</category>
      <category>aws</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built an Automated Deployment Pipeline for a 3-Tier Application to Eliminate Configuration Drift</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Sun, 16 Aug 2026 20:34:25 +0000</pubDate>
      <link>https://dev.to/manny300/i-built-an-automated-deployment-pipeline-for-a-3-tier-application-to-eliminate-configuration-drift-4bl9</link>
      <guid>https://dev.to/manny300/i-built-an-automated-deployment-pipeline-for-a-3-tier-application-to-eliminate-configuration-drift-4bl9</guid>
      <description>&lt;p&gt;If you are just getting started with cloud deployments and CI/CD, this post is for you. I will walk you through how my team and I built a Dockerized 3-tier web application (React, Node.js, PostgreSQL), and how I specifically designed and automated the deployment infrastructure for it; covering Azure VM provisioning, manual deployment simulation, and a full GitHub Actions CI/CD pipeline with automated rollbacks.&lt;/p&gt;

&lt;p&gt;This is the kind of project that bridges the gap between writing code locally and keeping it running reliably in the cloud. It is not glamorous, but it is one of those solutions that prevent "it works on my machine" from becoming a production outage.&lt;/p&gt;

&lt;p&gt;This is a collaborative group Capstone project from my TechCrush Cloud Engineering bootcamp series. If you want to see where this journey started—including the write-up for my own personal Capstone project—you can read my previous posts on my &lt;a href="https://dev.to/manny300"&gt;Dev.to profile&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My team (TechCrush Group 4) built FormFlow, a Dockerized 3-tier application. The application works flawlessly on our local machines via Docker Compose. The challenge is getting it to a production Azure Linux VM reliably. We needed three things to happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When infrastructure is provisioned&lt;/strong&gt;, the VM, network security groups, and Docker runtime should all be installed automatically without anyone SSHing into the machine to run &lt;code&gt;apt-get&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When code is pushed&lt;/strong&gt;, we need absolute certainty about exactly what version is running in production. No pushing the &lt;code&gt;latest&lt;/code&gt; tag and hoping for the best.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a deployment breaks&lt;/strong&gt;, the system must detect the failure and roll back to the previous version immediately. Not when a user complains. Automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hardest part of building CI/CD pipelines is that if you try to automate everything at once, a failed deployment leaves you guessing. Is the pipeline YAML wrong? Is the Dockerfile bad? Is the server misconfigured? To solve and simplify this, I designed an intermediate step: a script that simulates the CI/CD pipeline locally to isolate variables before getting to GitHub Actions.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;p&gt;Before running any of these scripts, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure CLI installed&lt;/strong&gt; on your local machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An active Azure account&lt;/strong&gt; to provision the Virtual Machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Hub credentials&lt;/strong&gt; configured as repository secrets (&lt;code&gt;DOCKERHUB_USERNAME&lt;/code&gt;, &lt;code&gt;DOCKERHUB_TOKEN&lt;/code&gt;) for the CI/CD pipelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment Secrets&lt;/strong&gt; configured in GitHub (&lt;code&gt;DEPLOY_HOST&lt;/code&gt;, &lt;code&gt;DEPLOY_USER&lt;/code&gt;, &lt;code&gt;DEPLOY_SSH_KEY&lt;/code&gt;) once the VM is provisioned.&lt;/li&gt;
&lt;li&gt;A terminal that runs Bash.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Understanding the Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Principle: Isolate Variables Before Automating
&lt;/h3&gt;

&lt;p&gt;The core design decision behind this entire system is that &lt;strong&gt;the deployment logic must be proven manually before it is automated in CI/CD.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you write a Bash script that successfully copies files, pulls images, and orchestrates containers on a remote server, you prove the infrastructure works. When you then transition that exact script into a GitHub Actions YAML file, any subsequent errors are strictly pipeline issues. &lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;The system has three layers: infrastructure provisioning, the deployment simulation script, and the GitHub Actions pipeline.&lt;/p&gt;

&lt;p&gt;The provisioning script (&lt;code&gt;provision-vm.sh&lt;/code&gt;) creates the Azure VM and injects a &lt;code&gt;cloud-init&lt;/code&gt; config to install Docker. The deployment script (&lt;code&gt;deploy.sh&lt;/code&gt;) simulates the pipeline to catch race conditions. Finally, GitHub Actions orchestrates the linting, testing, security scanning, and deployment with health checks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scripts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Provisioning: &lt;code&gt;provision-vm.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This script builds the underlying Azure infrastructure and prepares the server to host Docker containers.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nv"&gt;RG_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"FormFlow-RG"&lt;/span&gt;
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eastus"&lt;/span&gt;
&lt;span class="nv"&gt;VM_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"formflow-prod-vm"&lt;/span&gt;
&lt;span class="nv"&gt;ADMIN_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"azureuser"&lt;/span&gt;
&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"&lt;/span&gt;
&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Standard_D2s_v3"&lt;/span&gt;

&lt;span class="c"&gt;# 1. Create Resource Group&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--location&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="nt"&gt;-o&lt;/span&gt; table

&lt;span class="c"&gt;# 2. Create the Virtual Machine&lt;/span&gt;
az vm create &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;$RG_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; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_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;--image&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--size&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SIZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt; 3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-username&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ADMIN_USER&lt;/span&gt;&lt;span class="s2"&gt;"&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;--custom-data&lt;/span&gt; cloud-init.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-ip-sku&lt;/span&gt; Standard &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What matters here:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--custom-data cloud-init.yaml&lt;/code&gt; flag is the most important part of this script. Instead of provisioning a blank Ubuntu VM and manually SSHing in to install Docker, the &lt;code&gt;cloud-init&lt;/code&gt; file runs on the very first boot. By the time the Azure API says the VM is ready, Docker, Docker Compose, and the Buildx plugins are already installing in the background.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The Simulation: &lt;code&gt;deploy.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This script is what a developer runs from their local machine to test the deployment process before we trust GitHub Actions to do 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="c"&gt;# Step 2: Copy the entire project to the VM (excluding unnecessary files)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[2/4] Copying project files to VM..."&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="s1"&gt;'node_modules'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="s1"&gt;'.git'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="s1"&gt;'infra'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="s1"&gt;'*.md'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"ssh -i &lt;/span&gt;&lt;span class="nv"&gt;$SSH_KEY_PATH&lt;/span&gt;&lt;span class="s2"&gt; -o StrictHostKeyChecking=no"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;realpath&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SSH_USER&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="nv"&gt;$PUBLIC_IP&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;rsync&lt;/code&gt;?&lt;/strong&gt; In a 3-tier application, moving files over SSH can be slow. By explicitly excluding &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;.git&lt;/code&gt;, we keep the payload incredibly lightweight. This mirrors how a CI/CD runner checks out code—it does not bring local development baggage with it.&lt;/p&gt;

&lt;p&gt;Next, the script handles the most critical bug I encountered: the &lt;code&gt;cloud-init&lt;/code&gt; race condition.&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;# Step 4: Wait for Docker to be installed by cloud-init, then run compose&lt;/span&gt;
&lt;span class="nv"&gt;$SSH_CMD&lt;/span&gt; &lt;span class="s2"&gt;"
  retries=12
  while [ &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;retries -gt 0 ]; do
    if command -v docker &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
      echo 'Docker is ready!'
      break
    fi
    echo 'Docker not yet installed (cloud-init still running). Retrying in 10s...'
    sleep 10
    retries=&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;((retries - 1))
  done

  if ! command -v docker &amp;gt;/dev/null 2&amp;gt;&amp;amp;1; then
    echo 'ERROR: Docker was not installed after 2 minutes.'
    exit 1
  fi

  cd &lt;/span&gt;&lt;span class="nv"&gt;$APP_DIR&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;&amp;amp; sudo docker compose up -d --build
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you provision an Azure VM, &lt;code&gt;cloud-init&lt;/code&gt; runs in the background. If you run &lt;code&gt;deploy.sh&lt;/code&gt; immediately after &lt;code&gt;provision-vm.sh&lt;/code&gt;, it will fail because Docker is not fully installed yet. Rather than using a static, fragile &lt;code&gt;sleep 120&lt;/code&gt; command, I wrote a polling loop. It checks for the &lt;code&gt;docker&lt;/code&gt; binary every 10 seconds. If Docker is ready in 20 seconds, the deployment continues immediately. This defensive code prevents race conditions while keeping deployments fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CI/CD Pipeline
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;deploy.sh&lt;/code&gt; proved the architecture was sound, I translated that logic into &lt;code&gt;.github/workflows/pipeline.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Versioning with Git SHAs
&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;tag&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;Generate Git SHA Tag&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;sha_tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.sha.outputs.tag }}&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Set short SHA&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sha&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "tag=$(git rev-parse --short HEAD)" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We never push the &lt;code&gt;latest&lt;/code&gt; tag to Docker Hub. That is a recipe for untrackable production states. Instead, the pipeline generates the Git short SHA and tags the frontend, backend, and database images with it. Pulling image &lt;code&gt;a1b2c3d&lt;/code&gt; always corresponds to a known, inspectable commit in GitHub.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security and Integration
&lt;/h3&gt;

&lt;p&gt;Before touching the production VM, the pipeline runs a full suite. It builds the containers, stands them up via Docker Compose on the GitHub runner, and runs an integration test.&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="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;Smoke test – POST /todos via Nginx&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;curl -sf -X POST http://localhost/todos \&lt;/span&gt;
      &lt;span class="s"&gt;-H "Content-Type: application/json" \&lt;/span&gt;
      &lt;span class="s"&gt;-d '{"description":"CI smoke test todo"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In parallel, it runs Trivy security scans on all three images. If a high-severity vulnerability is found, or if the API fails to respond to the curl request, the pipeline fails. Bad code never reaches production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deployment and Automatic Rollback
&lt;/h3&gt;

&lt;p&gt;If tests pass, the &lt;code&gt;deploy&lt;/code&gt; job runs. It SSHes into the Azure VM, pulls the SHA-tagged images, and orchestrates them. But what if the new images crash on startup?&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="c1"&gt;# Keep a backup of the old .env file just in case for rollback&lt;/span&gt;
&lt;span class="s"&gt;if [ -f .env ]; then&lt;/span&gt;
  &lt;span class="s"&gt;cp .env .env.backup&lt;/span&gt;
  &lt;span class="s"&gt;OLD_TAG=$(grep IMAGE_TAG .env | cut -d '=' -f2)&lt;/span&gt;
&lt;span class="s"&gt;fi&lt;/span&gt;

&lt;span class="c1"&gt;# ... Docker pull and compose up logic ...&lt;/span&gt;

&lt;span class="c1"&gt;# Health Check and Automatic Rollback&lt;/span&gt;
&lt;span class="s"&gt;echo "Waiting for services to start..."&lt;/span&gt;
&lt;span class="s"&gt;sleep &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;

&lt;span class="s"&gt;if ! curl -sf http://localhost/todos; then&lt;/span&gt;
  &lt;span class="s"&gt;echo "Health check failed! Initiating automatic rollback..."&lt;/span&gt;
  &lt;span class="s"&gt;if [ -n "$OLD_TAG" ]; then&lt;/span&gt;
    &lt;span class="s"&gt;echo "Rolling back to IMAGE_TAG=$OLD_TAG"&lt;/span&gt;
    &lt;span class="s"&gt;mv .env.backup .env&lt;/span&gt;
    &lt;span class="s"&gt;docker compose up -d --remove-orphans&lt;/span&gt;
  &lt;span class="s"&gt;fi&lt;/span&gt;
  &lt;span class="s"&gt;exit &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the safety net. Before updating the containers, the pipeline backs up the current &lt;code&gt;.env&lt;/code&gt; file (which holds the &lt;em&gt;currently running&lt;/em&gt; Git SHA tag). After deploying the new containers, it fires an HTTP request at the live endpoint. If the app does not respond, the script restores the &lt;code&gt;.env.backup&lt;/code&gt; file and immediately redeploys the old containers. The pipeline then exits with code 1, alerting the team of the failure, but production remains online.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Result Looks Like
&lt;/h2&gt;

&lt;p&gt;When another developer clones this repository, they do not need to understand the underlying infrastructure to deploy. They simply run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./infra/provision-vm.sh
&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;✅ Provisioning Complete!
VM Name:    formflow-prod-vm
Public IP:  20.55.38.90
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They take that IP, drop it into their GitHub Secrets along with their Docker Hub credentials, and push their code. The GitHub Actions dashboard shows a clean, parallel execution:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Checkout&lt;/code&gt; → &lt;code&gt;Generate Git SHA&lt;/code&gt; → &lt;code&gt;Build/Lint&lt;/code&gt; → &lt;code&gt;Integration Test &amp;amp; Security Scan&lt;/code&gt; → &lt;code&gt;Deploy &amp;amp; Health Check&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%2Ft334agu1u3wlrbiflojf.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%2Ft334agu1u3wlrbiflojf.png" alt="screenshot showing github actions workflow" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If they introduce a breaking change, the logs show the exact moment the system protected itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Waiting for services to start...
Health check failed! Initiating automatic rollback...
Rolling back to IMAGE_TAG=888e10d
Rollback complete.
Process exited with status 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I Would Improve in a v2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Declarative Infrastructure as Code&lt;/strong&gt;&lt;br&gt;
Bash scripts are great for provisioning, but they do not manage state. In a v2, I would replace &lt;code&gt;provision-vm.sh&lt;/code&gt; entirely with Terraform or Azure Bicep to handle configuration drift automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Azure Key Vault Integration&lt;/strong&gt;&lt;br&gt;
Currently, secrets like &lt;code&gt;PG_PASSWORD&lt;/code&gt; are injected via GitHub Actions into a &lt;code&gt;.env&lt;/code&gt; file on the server. A more secure approach would be having the Node.js backend authenticate directly with Azure Key Vault at runtime to fetch the database credentials.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never rely on static sleep commands.&lt;/strong&gt; Use polling loops to gracefully handle background tasks like &lt;code&gt;cloud-init&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag images with Git SHAs, never &lt;code&gt;latest&lt;/code&gt;.&lt;/strong&gt; Version traceability makes rollbacks trivial.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Follow me for more content about Cloud Engineering and DevOps. Follow along on &lt;a href="https://dev.to/manny300"&gt;my Dev.to profile&lt;/a&gt; and &lt;a href="https://github.com/EmmanuelAjibokun" rel="noopener noreferrer"&gt;my github profile&lt;/a&gt; if you want to see how it goes.&lt;/p&gt;




&lt;p&gt;You can find the full scripts, Dockerfiles, pipeline configs, and architecture documentation here: &lt;a href="https://github.com/techcrush-group4-capstone/3-tier-dockerized-application" rel="noopener noreferrer"&gt;github.com/techcrush-group4-capstone/3-tier-dockerized-application&lt;/a&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>cicd</category>
      <category>azure</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>I Built an IAM System That Onboards and Offboards Users in Under 60 Seconds</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Wed, 12 Aug 2026 11:26:22 +0000</pubDate>
      <link>https://dev.to/manny300/i-built-an-iam-system-that-onboards-and-offboards-users-in-under-60-seconds-9n0</link>
      <guid>https://dev.to/manny300/i-built-an-iam-system-that-onboards-and-offboards-users-in-under-60-seconds-9n0</guid>
      <description>&lt;p&gt;If you are just getting started with Identity and Access Management on Azure, this post is for you. I will walk you through how I designed and automated a complete access governance system for a fictional fintech company; covering infrastructure provisioning, intern onboarding, and zero-trust offboarding. Using Bash scripts, Azure RBAC, and GitHub Actions CI/CD pipelines.&lt;/p&gt;

&lt;p&gt;This is the kind of project that sits at the intersection of cloud engineering and security. It is not glamorous, but it is one of those solutions that keep organizations from ending up in the news for all the wrong reasons.&lt;/p&gt;

&lt;p&gt;This is One of my Capstone projects in my TechCrush Cloud Engineering bootcamp series. If you want to see where this journey started, you can read my previous posts where I tackled &lt;a href="https://dev.to/manny300/from-zero-to-multi-region-my-experience-deploying-on-azure-for-the-first-time-cj3"&gt;deploying a web app across two Azure regions&lt;/a&gt; and &lt;a href="https://dev.to/manny300/how-to-automate-azure-resource-group-creation-with-a-bash-script-8jk"&gt;automating resource group creation for multi-environment workflows&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;VerdantPay is a fintech company growing fast. They bring on interns every cycle, they have permanent engineering teams working across web and database tiers, and people leave. The company needs three things to happen reliably:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When infrastructure is provisioned&lt;/strong&gt;, the network, resource groups, security groups, and RBAC role assignments should all be created in one operation with a full audit trail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When an intern joins&lt;/strong&gt;, they should be added to the correct security group in one command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When someone leaves&lt;/strong&gt;, every group membership, every role assignment, and the account itself should be revoked and verified. Not tomorrow. Not when someone remembers. Immediately.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third one is the one that matters most. A departed engineer who still has Contributor access to your production resource group is not a hypothetical risk. It is one that shows up in compliance audits and incident reports.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;p&gt;Before running any of these scripts, make sure you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure CLI installed&lt;/strong&gt; on your local machine. Follow the &lt;a href="https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" rel="noopener noreferrer"&gt;official installation guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An active Azure account&lt;/strong&gt; with at least Pay-As-You-Go. A free account works for most of this, but you will need Entra ID (Azure AD) permissions to create groups and manage users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A GitHub repository&lt;/strong&gt; with &lt;code&gt;AZURE_CREDENTIALS&lt;/code&gt; configured as a repository secret for the CI/CD pipelines.&lt;/li&gt;
&lt;li&gt;A terminal that runs Bash; Linux, macOS, or WSL on Windows.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Understanding the Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Principle: Least Privilege, Enforced by Structure
&lt;/h3&gt;

&lt;p&gt;The core design decision behind this entire system is that &lt;strong&gt;role assignments are made to Azure AD groups, not to individual users&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you assign a role directly to a user, revoking access means finding every individual assignment across every scope. When you assign roles to groups, revoking access means removing the user from the group. One operation. One place to audit.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Role Matrix
&lt;/h3&gt;

&lt;p&gt;Before writing a single line of code, I built a role matrix. Every group, its scope, its permission level, whether the access is time-bound, and the justification for why that level was chosen and not a higher one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Azure Role&lt;/th&gt;
&lt;th&gt;Time-bound&lt;/th&gt;
&lt;th&gt;Why This Level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;InternWebDevs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;web-subnet&lt;/td&gt;
&lt;td&gt;Reader&lt;/td&gt;
&lt;td&gt;Yes, 6 weeks&lt;/td&gt;
&lt;td&gt;Interns need to inspect web-tier resources. Contributor would let them modify infrastructure they should not touch.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;InternDBReadOnly&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;db-subnet&lt;/td&gt;
&lt;td&gt;Reader&lt;/td&gt;
&lt;td&gt;Yes, 6 weeks&lt;/td&gt;
&lt;td&gt;Interns need to inspect DB config. They must not modify the database or its network rules.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebAdmins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;web-subnet&lt;/td&gt;
&lt;td&gt;Contributor&lt;/td&gt;
&lt;td&gt;No, permanent&lt;/td&gt;
&lt;td&gt;Web admins manage the web tier. Owner is not needed because they do not manage access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBAdmins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;db-subnet&lt;/td&gt;
&lt;td&gt;Contributor&lt;/td&gt;
&lt;td&gt;No, permanent&lt;/td&gt;
&lt;td&gt;DB admins manage the database tier. Reader covers infrastructure. Data actions are granted separately.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rule: &lt;strong&gt;no group or role assignment is created without a corresponding row in this matrix.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architecture
&lt;/h3&gt;

&lt;p&gt;The system has three layers: automation scripts, Azure AD groups, and Azure infrastructure, connected by GitHub Actions pipelines.&lt;/p&gt;

&lt;p&gt;The provisioning script creates the resource group, VNet, subnets, AD groups, and role assignments. The onboarding script adds a user to a group. The offboarding script revokes everything and verifies the revocation. GitHub Actions orchestrates the provisioning and offboarding pipelines with full artifact logging.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scripts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Provisioning: &lt;code&gt;provision.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This script builds the entire infrastructure and access control layer in one run.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;LOG_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"provision-log.txt"&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%dT%H:%M:%SZ'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

log&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

log &lt;span class="s2"&gt;"Starting provisioning..."&lt;/span&gt;

&lt;span class="nv"&gt;SUBSCRIPTION_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az account show &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 1. Create Resource Group&lt;/span&gt;
&lt;span class="nv"&gt;RG_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"verdantpay-rg"&lt;/span&gt;
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"eastus"&lt;/span&gt;

log &lt;span class="s2"&gt;"Creating Resource Group: &lt;/span&gt;&lt;span class="nv"&gt;$RG_NAME&lt;/span&gt;&lt;span class="s2"&gt; in &lt;/span&gt;&lt;span class="nv"&gt;$LOCATION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RG_NAME&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;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Create VNet and Subnets&lt;/span&gt;
&lt;span class="nv"&gt;VNET_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"verdantpay-vnet"&lt;/span&gt;

az network vnet create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NAME&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;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--address-prefix&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

az network vnet subnet create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&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;"web-subnet"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--address-prefixes&lt;/span&gt; &lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

az network vnet subnet create &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&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;"db-subnet"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--address-prefixes&lt;/span&gt; &lt;span class="s2"&gt;"10.0.2.0/24"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What matters here:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt; at the top is non-negotiable. If any command fails, the script stops. If any variable is unset, the script stops. In infrastructure automation, a script that silently continues after a failure is worse than a script that crashes loudly.&lt;/p&gt;

&lt;p&gt;The script also checks whether subnets and groups already exist before creating them. This makes the script &lt;strong&gt;idempotent&lt;/strong&gt; you can run it ten times and get the same result. This matters in CI/CD where pipelines re-run on retries.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating AD Groups and Role Assignments
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;create_ad_group&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;group_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
    &lt;span class="nv"&gt;GROUP_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad group show &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;az ad group create &lt;span class="nt"&gt;--display-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--mail-nickname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else
        &lt;/span&gt;log &lt;span class="s2"&gt;"Group &lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt; already exists."&lt;/span&gt;
    &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

create_ad_group &lt;span class="s2"&gt;"verdantpay-InternWebDevs"&lt;/span&gt;
create_ad_group &lt;span class="s2"&gt;"verdantpay-InternDBReadOnly"&lt;/span&gt;
create_ad_group &lt;span class="s2"&gt;"verdantpay-WebAdmins"&lt;/span&gt;
create_ad_group &lt;span class="s2"&gt;"verdantpay-DBAdmins"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After creating groups, the script waits 30 seconds for Entra ID replication before attempting role assignments. This upgrade was as a result of a notable failed case that I encountered. Group creation is eventually consistent, and assigning a role to a group that has not replicated yet will fail silently or throw an error depending on your timing.&lt;/p&gt;

&lt;p&gt;The role assignment function includes a retry loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;assign_role&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;group_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
    &lt;span class="nb"&gt;local wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10

    &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="nv"&gt;$retries&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        &lt;/span&gt;&lt;span class="nv"&gt;GROUP_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad group show &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
            &lt;/span&gt;&lt;span class="nb"&gt;break
        &lt;/span&gt;&lt;span class="k"&gt;fi
        &lt;/span&gt;log &lt;span class="s2"&gt;"Group '&lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;' not found yet, retrying in &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;wait&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s... (attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$retries&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
        &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="nv"&gt;$wait&lt;/span&gt;
    &lt;span class="k"&gt;done

    &lt;/span&gt;&lt;span class="nv"&gt;EXISTING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az role assignment list &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$role&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$scope&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[].id'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXISTING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;az role assignment create &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--role&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$role&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--scope&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$scope&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else
        &lt;/span&gt;log &lt;span class="s2"&gt;"Role assignment already exists for &lt;/span&gt;&lt;span class="nv"&gt;$group_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the kind of defensive code you write after learning the hard way that Azure AD group replication is not instant. The retry loop gives the system time to catch up without failing the entire pipeline.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. Onboarding: &lt;code&gt;onboard.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This script is intentionally simple. One command. One log entry.&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nv"&gt;GROUP_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%dT%H:%M:%SZ'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;EXECUTOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad signed-in-user show &lt;span class="nt"&gt;--query&lt;/span&gt; userPrincipalName &lt;span class="nt"&gt;-o&lt;/span&gt; tsv 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"service-principal"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;USER_OBJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad user show &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

az ad group member add &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--member-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;] ADDED: User '&lt;/span&gt;&lt;span class="nv"&gt;$USER_EMAIL&lt;/span&gt;&lt;span class="s2"&gt;' to Group '&lt;/span&gt;&lt;span class="nv"&gt;$GROUP_NAME&lt;/span&gt;&lt;span class="s2"&gt;' by '&lt;/span&gt;&lt;span class="nv"&gt;$EXECUTOR&lt;/span&gt;&lt;span class="s2"&gt;'"&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"onboarding-log.txt"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why it logs the executor&lt;/strong&gt;: When an intern gets added to &lt;code&gt;verdantpay-InternWebDevs&lt;/code&gt;, you need to know &lt;em&gt;who&lt;/em&gt; added them. The script captures the signed-in user's principal name. If it is running via a service principal in CI/CD, it logs that instead. This is a basic audit requirement that most onboarding scripts skip.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. Offboarding: &lt;code&gt;offboard.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is the most important script in the entire system. It follows a strict sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Look up the user's Object ID&lt;/li&gt;
&lt;li&gt;Capture the &lt;strong&gt;before state&lt;/strong&gt; — every group membership and role assignment&lt;/li&gt;
&lt;li&gt;Remove the user from every AD group&lt;/li&gt;
&lt;li&gt;Delete every direct role assignment&lt;/li&gt;
&lt;li&gt;Disable the Azure AD account&lt;/li&gt;
&lt;li&gt;Verify that all access has been revoked&lt;/li&gt;
&lt;li&gt;Write &lt;code&gt;VERIFICATION: PASS&lt;/code&gt; or &lt;code&gt;VERIFICATION: FAIL&lt;/code&gt;
&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;USER_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nv"&gt;LOG_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"offboarding-log.txt"&lt;/span&gt;
&lt;span class="nv"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s1"&gt;'+%Y-%m-%dT%H:%M:%SZ'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Step 1: Get Object ID&lt;/span&gt;
&lt;span class="nv"&gt;OBJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad user show &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Step 2: Capture before-state&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'--- BEFORE STATE ---'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Timestamp: &lt;/span&gt;&lt;span class="nv"&gt;$TIMESTAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"User: &lt;/span&gt;&lt;span class="nv"&gt;$USER_EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
az ad user get-member-groups &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--security-enabled-only&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
az role assignment list &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Step 3: Remove from all groups&lt;/span&gt;
&lt;span class="nv"&gt;USER_GROUPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad user get-member-groups &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--security-enabled-only&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[].displayName'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;GROUP &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$USER_GROUPS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;az ad group member remove &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--member-id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Step 4: Delete direct role assignments&lt;/span&gt;
&lt;span class="nv"&gt;ROLE_IDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az role assignment list &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[].id'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;ROLE_ID &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$ROLE_IDS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;az role assignment delete &lt;span class="nt"&gt;--ids&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROLE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Step 5: Disable account&lt;/span&gt;
az ad user update &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--account-enabled&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;

&lt;span class="c"&gt;# Step 6: Verify with retries&lt;/span&gt;
&lt;span class="nv"&gt;VERIFY_RETRIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;span class="nv"&gt;VERIFY_WAIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="nv"&gt;$VERIFY_RETRIES&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;REMAINING_ROLES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az role assignment list &lt;span class="nt"&gt;--assignee&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nv"&gt;REMAINING_GROUPS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az ad user get-member-groups &lt;span class="nt"&gt;--id&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OBJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REMAINING_ROLES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REMAINING_GROUPS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'VERIFICATION: PASS -- all access removed for '&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_EMAIL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="nb"&gt;exit &lt;/span&gt;0
    &lt;span class="k"&gt;fi

    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Residual access detected, retrying in &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VERIFY_WAIT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s... (attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$VERIFY_RETRIES&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
    &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="nv"&gt;$VERIFY_WAIT&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'VERIFICATION: FAIL -- residual access detected after retries'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script queries Azure AD and Azure RBAC after the revocation to confirm that all groups and roles are actually gone. If they are not because of replication lag or a partial failure the script retries up to 5 times over 75 seconds. If it still fails, it exits with code 1, which causes the CI/CD pipeline to fail and alert the team.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;|| true&lt;/code&gt; on the removal commands is deliberate. If a user has already been removed from a group (maybe by a previous partial run), the command would fail and &lt;code&gt;set -e&lt;/code&gt; would kill the script before it gets to the other groups. The &lt;code&gt;|| true&lt;/code&gt; lets it continue to the next group while the verification step at the end catches anything that was actually missed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The CI/CD Pipelines
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Provisioning Pipeline
&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VerdantPay IAM Provisioning&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;main&lt;/span&gt; &lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;scripts/provision.sh'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;scripts/onboard.sh'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;provision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&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="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;Checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Azure Login&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;creds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CREDENTIALS }}&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;Run provisioning script&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/provision.sh&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;Upload access log&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&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;access-log&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;provision-log.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Path filtering&lt;/strong&gt; is the design decision here. The pipeline only triggers when &lt;code&gt;provision.sh&lt;/code&gt; or &lt;code&gt;onboard.sh&lt;/code&gt; are modified. Updating the README does not trigger a cloud deployment. This is was done to saves unnecessary runs and keep the audit logs clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Offboarding Pipeline
&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;VerdantPay Offboarding&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;user_email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Email&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;offboard'&lt;/span&gt;
        &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&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="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;offboard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&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="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;Checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;Azure Login&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;azure/login@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;creds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.AZURE_CREDENTIALS }}&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;Run offboarding script&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/offboard.sh ${{ github.event.inputs.user_email }}&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;Upload offboarding log&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&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;offboarding-log&lt;/span&gt;
          &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;offboarding-log.txt&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;workflow_dispatch&lt;/code&gt; instead of an automated trigger?&lt;/strong&gt; Offboarding is a destructive, irreversible action. Triggering it automatically on a push would be dangerous. &lt;code&gt;workflow_dispatch&lt;/code&gt; requires a security administrator to manually type in the user's email and click Run.&lt;/p&gt;

&lt;p&gt;The offboarding log is uploaded as a GitHub Actions artifact. Security teams can download it after the run to prove that access was revoked at a specific timestamp.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Result Looks Like
&lt;/h2&gt;

&lt;p&gt;After running the full provisioning, I tested the system with real user accounts. The onboarding log captured every addition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[2026-08-03T07:01:49Z] ADDED: User 'temiloluwa@...' to Group 'verdantpay-WebAdmins' by 'emmanuelajibokunedu...'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the offboarding log shows the complete before-and-after state:&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="err"&gt;---&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;BEFORE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;STATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;---&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Timestamp:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026-08-03&lt;/span&gt;&lt;span class="err"&gt;T&lt;/span&gt;&lt;span class="mi"&gt;07&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="err"&gt;Z&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;User:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;temiloluwa@...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Groups&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;before:&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;span class="nl"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"verdantpay-WebAdmins"&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;span class="err"&gt;VERIFICATION:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;PASS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;all&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;access&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;removed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;temiloluwa@...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;VERIFICATION: PASS&lt;/code&gt; line means the script queried Azure after revocation, found zero remaining group memberships and zero remaining role assignments, and confirmed the offboarding was complete.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Would Improve in a v2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Time-bound access with Azure AD PIM&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The role matrix specifies that intern access should be time-bound to 6 weeks, but the current scripts do not enforce expiry automatically. In a v2, I would integrate Azure AD Privileged Identity Management (PIM) to set eligible assignments with an automatic expiry date. That way, even if the offboarding script is never run, the access disappears on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Slack or Teams notifications on offboarding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the offboarding pipeline completes, the result sits in a GitHub Actions log that someone has to go look at. A webhook that posts the PASS/FAIL result to a Slack or Teams channel would close that loop and give security teams instant visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A dedicated teardown script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I actually built this already, a &lt;code&gt;teardown.sh&lt;/code&gt; that removes everything in reverse order: role assignments first, then AD groups, then the resource group.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Assign roles to groups, never to individual users.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a role matrix before writing code.&lt;/strong&gt; &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify your revocations.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use &lt;code&gt;set -euo pipefail&lt;/code&gt; in every script.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make your CI/CD pipelines produce evidence.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write a teardown script for everything you provision.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Follow me for more contents about Cloud engineering and DevOps&lt;br&gt;
Follow along on &lt;a href="https://dev.to/manny300"&gt;my Dev.to profile&lt;/a&gt; if you want to see how it goes.&lt;/p&gt;




&lt;p&gt;You can find the full scripts, pipeline configs, role matrix, and architecture diagram here: &lt;a href="https://github.com/EmmanuelAjibokun/VerdantPay-IAM" rel="noopener noreferrer"&gt;github.com/EmmanuelAjibokun/VerdantPay-IAM&lt;/a&gt;&lt;/p&gt;

</description>
      <category>identitygovernance</category>
      <category>devsecops</category>
      <category>cloudengineering</category>
      <category>azureiam</category>
    </item>
    <item>
      <title>How to Automate Azure Resource Group Creation with a Bash Script</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Mon, 08 Jun 2026 18:17:27 +0000</pubDate>
      <link>https://dev.to/manny300/how-to-automate-azure-resource-group-creation-with-a-bash-script-8jk</link>
      <guid>https://dev.to/manny300/how-to-automate-azure-resource-group-creation-with-a-bash-script-8jk</guid>
      <description>&lt;p&gt;If you are just getting started with Azure CLI and Bash scripting, this post is for you. I will walk you through how I automated the creation of Azure resource groups for multiple environments using a single Bash script — something that was taking a cloud admin several manual steps every week.&lt;/p&gt;

&lt;p&gt;This is Project 2 in my TechRush Cloud Engineering bootcamp series. If you want to see where this journey started, you can read my previous post where I tackled &lt;a href="https://dev.to/manny300/from-zero-to-multi-region-my-experience-deploying-on-azure-for-the-first-time-cj3"&gt;deploying a web app across two Azure regions for the first time&lt;/a&gt;. That project involved real blockers — quota limits, CLI version mismatches, and a deep dive into Azure Resource Providers. This one went smoother, and I think that is because the previous project was the hard school.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Imagine a cloud administrator who has to create five resource groups every single week, one for each active project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project-A-RG
Project-B-RG
Project-C-RG
Project-D-RG
Project-E-RG
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every week. By hand. Management's response was simple: automate it.&lt;/p&gt;

&lt;p&gt;But here is where the task gets more interesting. Instead of creating one flat resource group per project, the better approach is to create &lt;strong&gt;four resource groups per project&lt;/strong&gt; — one for each environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dev&lt;/li&gt;
&lt;li&gt;Test&lt;/li&gt;
&lt;li&gt;UAT&lt;/li&gt;
&lt;li&gt;Production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because each environment needs its own access controls, cost tracking, and lifecycle rules. You do not want your Development environment sharing a resource group with Production. Keeping them separate is a real-world cloud best practice, not just a bootcamp exercise.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Will Need
&lt;/h2&gt;

&lt;p&gt;Before running this script, make sure you have the following set up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure CLI installed&lt;/strong&gt; on your local machine. You can follow the &lt;a href="https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" rel="noopener noreferrer"&gt;official installation guide&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An active Azure account&lt;/strong&gt;. A free account works fine for this.&lt;/li&gt;
&lt;li&gt;A terminal that runs Bash — Linux, macOS, or WSL on Windows.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Understanding the Design
&lt;/h2&gt;

&lt;p&gt;The core idea behind this script is &lt;strong&gt;parameterization&lt;/strong&gt;. Instead of hardcoding project names, the script accepts a project name as input and uses it as a prefix for every resource group it creates.&lt;/p&gt;

&lt;p&gt;So if you enter &lt;code&gt;Project-A&lt;/code&gt;, you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Project-A-RG-Dev
Project-A-RG-Test
Project-A-RG-UAT
Project-A-RG-Production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next week, you run the same script, enter &lt;code&gt;Project-B&lt;/code&gt;, and get the same structure with a different prefix. The script never changes. Only the input does.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;RG&lt;/code&gt; in the middle is there to make the resource type clear at a glance. When you are looking at a list of twenty Azure resources, names that include what the resource is save you a lot of time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Script
&lt;/h2&gt;

&lt;p&gt;Create a file called &lt;code&gt;deploy.sh&lt;/code&gt; and paste the following:&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;#!/bin/bash&lt;/span&gt;

&lt;span class="c"&gt;# Check if the user is logged into Azure&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; az account show &amp;amp;&amp;gt;/dev/null&lt;span class="p"&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;"Not logged in. Run 'az login' first."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Prompt the user for a project name&lt;/span&gt;
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Enter Project Name: "&lt;/span&gt; ProjectName

&lt;span class="c"&gt;# Validate that the input is not empty&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&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;"Project Name cannot be empty."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Inform the user that resource group creation is starting&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Creating resource groups for &lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;

&lt;span class="c"&gt;# Create one resource group per environment&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-Dev"&lt;/span&gt;        &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-Test"&lt;/span&gt;       &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-UAT"&lt;/span&gt;        &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-Production"&lt;/span&gt; &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Resource groups created successfully."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now make the script executable:&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;chmod&lt;/span&gt; +x deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./deploy.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Walking Through the Script
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Login check&lt;/strong&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; az account show &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The very first thing the script does is check whether you are already logged into Azure. If you are not, it stops immediately and tells you exactly what to do. This is called a &lt;strong&gt;guard clause&lt;/strong&gt; — you check your preconditions before doing any real work. It prevents confusing errors further down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input prompt&lt;/strong&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;read&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Enter Project Name: "&lt;/span&gt; ProjectName
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;read&lt;/code&gt; command pauses the script and waits for you to type something. Whatever you type gets stored in the variable &lt;code&gt;ProjectName&lt;/code&gt;. The &lt;code&gt;-p&lt;/code&gt; flag lets you show a prompt message at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty input validation&lt;/strong&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the user just presses Enter without typing anything, &lt;code&gt;ProjectName&lt;/code&gt; will be empty. Without this check, the script would go ahead and try to create resource groups with names like &lt;code&gt;-RG-Dev&lt;/code&gt;, which is not useful to anyone. This check catches that and exits cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource group creation&lt;/strong&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 group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-Dev"&lt;/span&gt; &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the Azure CLI command that does the actual work. The &lt;code&gt;--name&lt;/code&gt; flag uses string interpolation to combine the variable with the environment suffix. The &lt;code&gt;--location&lt;/code&gt; flag tells Azure which region to deploy to. You can change &lt;code&gt;eastus&lt;/code&gt; to any region that is available on your subscription.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Result Looks Like
&lt;/h2&gt;

&lt;p&gt;After running the script with &lt;code&gt;FI_deparment&lt;/code&gt; as the project name (from the actual assignment run), the Azure portal showed the following resource groups created 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.amazonaws.com%2Fuploads%2Farticles%2Fecfecnj26gboi2ie60nk.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.amazonaws.com%2Fuploads%2Farticles%2Fecfecnj26gboi2ie60nk.png" alt="Azure portal showing four resource groups: FI_deparment-RG-Dev, FI_deparment-RG-Test, FI_deparment-RG-UAT, FI_deparment-RG-Production"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Would Improve in a v2
&lt;/h2&gt;

&lt;p&gt;Shipping something that works is step one. Thinking about what comes next is what separates a script you wrote once from a script a team can actually use.&lt;/p&gt;

&lt;p&gt;Two things I would change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Add a location prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right now the region is hardcoded to &lt;code&gt;eastus&lt;/code&gt;. A slightly better script would also ask the user for their preferred region. Different teams or clients might need resources in different geographies, and hardcoding a region removes that flexibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Replace the four &lt;code&gt;az group create&lt;/code&gt; lines with a loop&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The current script has four nearly identical lines. If the environments ever changed — say, you needed to add a &lt;code&gt;Staging&lt;/code&gt; environment — you would have to manually add another line. A loop over an array is cleaner and easier to extend:&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="nv"&gt;environments&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"Dev"&lt;/span&gt; &lt;span class="s2"&gt;"Test"&lt;/span&gt; &lt;span class="s2"&gt;"UAT"&lt;/span&gt; &lt;span class="s2"&gt;"Production"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;env &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;environments&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ProjectName&lt;/span&gt;&lt;span class="s2"&gt;-RG-&lt;/span&gt;&lt;span class="nv"&gt;$env&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="s2"&gt;"eastus"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result, but now adding a new environment is a one-word change.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parameterize, do not hardcode.&lt;/strong&gt; A script that accepts input is reusable. A script with hardcoded values is a one-time tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate your inputs early.&lt;/strong&gt; Check that required values exist before doing any real work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name things clearly.&lt;/strong&gt; &lt;code&gt;ProjectName-RG-Dev&lt;/code&gt; tells you the project, the resource type, and the environment at a glance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate environments into separate resource groups.&lt;/strong&gt; Dev and Production should never share a resource group in a real setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a destroy script alongside every deploy script.&lt;/strong&gt; I did not need it here, but the habit of writing a teardown script is what keeps your Azure bill from surprising you.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Assignment 3 is more complex. It covers two scenarios: a one-click deploy script that provisions a full environment stack for non-technical staff, and a university migration to Azure where each department gets its own set of resources — with a requirement to support 20 more departments the following year. That last part is a design thinking challenge, not just a scripting challenge.&lt;/p&gt;

&lt;p&gt;I will write about that one too. Follow along on &lt;a href="https://dev.to/manny300"&gt;my Dev.to profile&lt;/a&gt; if you want to see how it goes.&lt;/p&gt;




&lt;p&gt;You can find the full script and repo here: &lt;a href="https://github.com/EmmanuelAjibokun/Techcrush-Ass-2" rel="noopener noreferrer"&gt;github.com/EmmanuelAjibokun/Techcrush-Ass-2&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cloudcomputing</category>
      <category>azure</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>From Zero to Multi-Region: My experience deploying on Azure for the First Time</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Sat, 30 May 2026 17:27:37 +0000</pubDate>
      <link>https://dev.to/manny300/from-zero-to-multi-region-my-experience-deploying-on-azure-for-the-first-time-cj3</link>
      <guid>https://dev.to/manny300/from-zero-to-multi-region-my-experience-deploying-on-azure-for-the-first-time-cj3</guid>
      <description>&lt;p&gt;Starting this project, I was not as lost as I was with the first one.&lt;br&gt;
If you haven't seen the first one, here it is:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8" class="crayons-story__hidden-navigation-link"&gt;A cloud service model decision framework comparing IaaS / PaaS / SaaS trade-offs for three production workloads&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/manny300" class="crayons-avatar  crayons-avatar--l  "&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F787659%2F6c3da68e-1c2c-42f3-a42b-885328ad64d9.jpg" alt="manny300 profile" class="crayons-avatar__image" width="800" height="739"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/manny300" class="crayons-story__secondary fw-medium m:hidden"&gt;
              manny300
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                manny300
                
              
              &lt;div id="story-author-preview-content-3668993" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/manny300" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F787659%2F6c3da68e-1c2c-42f3-a42b-885328ad64d9.jpg" class="crayons-avatar__image" alt="" width="800" height="739"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;manny300&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;May 14&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8" id="article-link-3668993"&gt;
          A cloud service model decision framework comparing IaaS / PaaS / SaaS trade-offs for three production workloads
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/cloudcomputing"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;cloudcomputing&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/solutionarchitect"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;solutionarchitect&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/cloudsolution"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;cloudsolution&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            2 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;This time I had some familiarity coming in. The first thing I did was explore the Azure portal, clicked around, created a Resource Group, setup an App Service, and it worked.&lt;/p&gt;

&lt;p&gt;Then I moved on to the CLI, which is where things started to feel funny.&lt;/p&gt;




&lt;h2&gt;
  
  
  First Attempt: Azure Cloud Shell
&lt;/h2&gt;

&lt;p&gt;My first attempt was with Azure Cloud Shell. It did not feel natural to me. My main confusion was around shell scripts. I was not sure if I could write and run a deploy script from inside Cloud Shell.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In hindsight, the answer is yes. You can use a text editor like Vim/Emacs right inside Cloud Shell and execute your script from there. But I did not think of that at the time.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I moved on to installing the Azure CLI locally on my machine.&lt;/p&gt;

&lt;p&gt;That took a couple of days on its own, but working on my local machine felt more natural. The first installation method I tried did not work. I eventually got it working, moved into VS Code, and started writing my deploy script properly.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;Take the same static website I built for Project 1 and deploy it to Azure using the Web App Service, across two different regions.&lt;/p&gt;

&lt;p&gt;I wrote a &lt;code&gt;deploy.sh&lt;/code&gt; script and a &lt;code&gt;destroy.sh&lt;/code&gt; script. The destroy script is the habit that saves you from racking up charges on resources you forgot about.&lt;/p&gt;

&lt;p&gt;The script flow followed Azure's hierarchy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;strong&gt;Resource Group&lt;/strong&gt;, every resource on Azure must belong to one&lt;/li&gt;
&lt;li&gt;Create an &lt;strong&gt;App Service Plan&lt;/strong&gt;, this is where you choose the OS for your web app&lt;/li&gt;
&lt;li&gt;Create the &lt;strong&gt;Web App&lt;/strong&gt; on top of that plan&lt;/li&gt;
&lt;li&gt;Deploy your files&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The App Service Plan is worth understanding properly. It is not just a billing tier. Even though we are working with Platform as a Service, meaning we are not managing the underlying infrastructure ourselves, we still choose the operating system. Azure handles everything below that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting West Europe Working
&lt;/h2&gt;

&lt;p&gt;Getting the West Europe deployment working took some back and forth with the commands. Some flags I used were not recognized by my version of the CLI, and some runtime options that older documentation referenced no&lt;br&gt;
longer exist.&lt;br&gt;
&lt;em&gt;Static site hosting on a Web App Service Plan with a Linux OS, for example, is not supported. The Web App Service Plan works with Windows OS for deploying static sites.&lt;/em&gt; Each of these were small walls that required reading error messages carefully, researching, and adjusting.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Wall: East US Quota Limits
&lt;/h2&gt;

&lt;p&gt;The second half of the challenge was deploying to a second region, East US.&lt;br&gt;
I expected it to work the same way West Europe did. It did not.&lt;br&gt;
The error I kept hitting was about quota. Specifically, the &lt;strong&gt;Total VMs limit for East US was set to zero&lt;/strong&gt; on my subscription. No VMs available meant no App Service Plan could be created, which meant no deployment.&lt;/p&gt;

&lt;p&gt;My first instinct was that maybe the SKU was the problem. I was using &lt;code&gt;F1&lt;/code&gt;, which is the free tier. I went into the Azure portal and looked through the quota section under the Web App provider. I could see that certain SKUs like &lt;code&gt;P1V4&lt;/code&gt; had limits of 10 or 30 in that region, while &lt;code&gt;F1&lt;/code&gt; was sitting at zero.&lt;/p&gt;

&lt;p&gt;I changed the SKU in my script to one of those available tiers, ran the script again, and hit the same wall. &lt;em&gt;The error was not about the SKU specifically. It was about the Total VM quota for the region, and that articular quota had no option to adjust the limit from the portal.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Rabbit Hole: Resource Providers
&lt;/h2&gt;

&lt;p&gt;Around this time I started paying closer attention to how Azure&lt;br&gt;
organizes its services. I came across &lt;strong&gt;Resource Providers&lt;/strong&gt;, which I had not really thought about before.&lt;/p&gt;

&lt;p&gt;Every Azure service belongs to a provider namespace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Microsoft.Web&lt;/code&gt; for App Services&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Microsoft.Compute&lt;/code&gt; for Virtual Machines&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you can use any service, the corresponding provider needs to be registered on your subscription. On a free account, some providers are not registered by default, and quotas are locked.&lt;/p&gt;

&lt;p&gt;I went down the rabbit hole of manually registering the Compute&lt;br&gt;
provider, only to discover that the Web provider I actually needed had already been registered automatically.&lt;br&gt;
&lt;em&gt;It was a detour, but it was a useful one.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Upgrading to Pay As You Go
&lt;/h2&gt;

&lt;p&gt;I upgraded from the free tier to Pay As You Go. I still have my $200 free credit for the first 30 days, so the cost risk was manageable.&lt;br&gt;
After upgrading, the provider situation improved and quotas became adjustable.&lt;br&gt;
But the East US Total VM limit was still at zero and still had no adjustment option in the portal. I reached out to Microsoft engineering support and submitted a formal quota increase request for two instances.&lt;br&gt;
&lt;em&gt;That request is still being processed.&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Finding a Different Path
&lt;/h2&gt;

&lt;p&gt;While that was still on, I found a different path.&lt;br&gt;
Since what I am deploying is a static website, Azure has a dedicated &lt;strong&gt;Static Web Apps&lt;/strong&gt; service that sits outside the App Service quota system entirely. I deployed to it and it worked without hitting any quota restrictions at all. I was able to deploy to two different regions successfully using that service.&lt;/p&gt;

&lt;p&gt;I also went back and tested West Europe with the original Web App Service approach, and it worked there as well. So I now have deployments running across multiple regions through two different methods.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Unanswered Question
&lt;/h2&gt;

&lt;p&gt;As a Pay As You Go subscriber, I would expect to be able to deploy to any region Azure offers. But right now I am restricted to one region for Web App Service deployments while the quota request gets processed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If anyone has run into this and know the reason for this, I would genuinely like to know. It is the kind of thing that would matter on a real project with a deadline.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read the Full Project&lt;/p&gt;

&lt;p&gt;You can have a look at the project by clicking the link below:&lt;br&gt;
&lt;/p&gt;
&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/EmmanuelAjibokun" rel="noopener noreferrer"&gt;
        EmmanuelAjibokun
      &lt;/a&gt; / &lt;a href="https://github.com/EmmanuelAjibokun/Azure-Web-App" rel="noopener noreferrer"&gt;
        Azure-Web-App
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A small web application (a static site is fine; even better, a "Hello, [your name]" Flask app) deployed into Azure App Service in two regions, behind a Resource Group you provisioned entirely from the Azure CLI — no portal clicks for the deployment itself.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Azure Web App&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Deploy a Node.js app to Azure using the Azure CLI. This repo contains two deployment scripts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/EmmanuelAjibokun/Azure-Web-App/deploy-app.sh" rel="noopener noreferrer"&gt;deploy-app.sh&lt;/a&gt; — deploys the local Node.js app from the &lt;code&gt;app/&lt;/code&gt; folder using &lt;code&gt;az webapp up&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/EmmanuelAjibokun/Azure-Web-App/deploy-static.sh" rel="noopener noreferrer"&gt;deploy-static.sh&lt;/a&gt; — provisions a Static Web App (East US 2) and a Web App from GitHub (West Europe)&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Prerequisites&lt;/h2&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/cli/azure/install-azure-cli" rel="nofollow noopener noreferrer"&gt;Azure CLI&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;An active Azure subscription&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git-scm.com/" rel="nofollow noopener noreferrer"&gt;Git&lt;/a&gt; installed&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quickstart&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;1. Clone the repo&lt;/h3&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;git clone https://github.com/EmmanuelAjibokun/cloud-eng.git
&lt;span class="pl-c1"&gt;cd&lt;/span&gt; azure-web-app&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;2. Log in to Azure&lt;/h3&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;az login&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;3. (Optional) Set your active subscription&lt;/h3&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;az account list --output table
az account &lt;span class="pl-c1"&gt;set&lt;/span&gt; --subscription &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&amp;lt;your-subscription-id&amp;gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Option A — Deploy the local Node.js app&lt;/h2&gt;

&lt;/div&gt;
&lt;p&gt;This deploys the &lt;code&gt;app/&lt;/code&gt; folder directly to an Azure Web App using &lt;code&gt;az webapp up&lt;/code&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;chmod +x deploy-app.sh
./deploy-app.sh&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;What it does:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Checks that you are logged in to the Azure CLI — exits with an error if not&lt;/li&gt;
&lt;li&gt;Checks if &lt;code&gt;cloud-decision-east&lt;/code&gt; is already…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/EmmanuelAjibokun/Azure-Web-App" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;If you would like to follow along on the learn-by-doing journey,&lt;br&gt;
comment "github" and I will send you the project guide link. Kindly star the project and follow me on GitHub.&lt;br&gt;
&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://github.com/EmmanuelAjibokun" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Favatars.githubusercontent.com%2Fu%2F96999656%3Fv%3D4%3Fs%3D400" height="460" class="m-0" width="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://github.com/EmmanuelAjibokun" rel="noopener noreferrer" class="c-link"&gt;
            EmmanuelAjibokun (Emmanuel Ajibokun ) · GitHub
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            A computer programming enthusiast, Geologist by field of study and a fast learner - EmmanuelAjibokun
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.githubassets.com%2Ffavicons%2Ffavicon.svg" width="32" height="32"&gt;
          github.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>cloudcomputing</category>
      <category>azure</category>
      <category>cloudsolution</category>
      <category>staticwebapps</category>
    </item>
    <item>
      <title>A cloud service model decision framework comparing IaaS / PaaS / SaaS trade-offs for three production workloads</title>
      <dc:creator>manny300</dc:creator>
      <pubDate>Thu, 14 May 2026 10:27:54 +0000</pubDate>
      <link>https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8</link>
      <guid>https://dev.to/manny300/a-cloud-service-model-decision-framework-comparing-iaas-paas-saas-trade-offs-for-three-4jd8</guid>
      <description>&lt;h2&gt;
  
  
  From Geology to Cloud Computing: My First Project 🌍
&lt;/h2&gt;

&lt;p&gt;Diving into the world of cloud computing as an applied geology graduate, &lt;br&gt;
having moved from frontend development with React and Next.js to backend &lt;br&gt;
development with Node.js.&lt;/p&gt;

&lt;p&gt;This is not just another tech skill to learn. This season could be the &lt;br&gt;
beginning of a career transition for me. Because of that, I paid a &lt;br&gt;
subsidised price to join the TechCrush bootcamp. But I know that is not &lt;br&gt;
enough. If I want to finish with the outcome I desire, I must put in &lt;br&gt;
extra work.&lt;/p&gt;


&lt;h2&gt;
  
  
  How I Planned the Work
&lt;/h2&gt;

&lt;p&gt;The first thing I did was prompt Claude with some considerations that &lt;br&gt;
included 10 job posting requirements in cloud computing and DevOps, the &lt;br&gt;
course modules that would be covered during the bootcamp, and a sample &lt;br&gt;
task by Forrest Brazeal called the #CloudResumeChallenge. Here is the &lt;br&gt;
prompt I used:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I'm transitioning my career into cloud engineering, and I feel the &lt;br&gt;
3-month training covers the majority of these opportunities. I want to &lt;br&gt;
have projects for each milestone, in the same order that I would be &lt;br&gt;
doing them in the training. Create a similar guide to the one by &lt;br&gt;
Forrest Brazeal's #CloudResumeChallenge. Juxtapose my learning and job &lt;br&gt;
requirements to map out the projects."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This mapped out &lt;strong&gt;9 projects&lt;/strong&gt; to work on by the end of my bootcamp.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first project is what this post is about.&lt;/strong&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Humbling Part
&lt;/h2&gt;

&lt;p&gt;I had one introductory class and one main class with my tutor. I &lt;br&gt;
actually started working on the first project before my classes began. &lt;br&gt;
I had studied on my own, watched about 6 YouTube videos, and was &lt;br&gt;
feeling pretty confident.&lt;/p&gt;

&lt;p&gt;Then, to my great amusement, I did not seem to understand the task at &lt;br&gt;
all, let alone know where to get started. It was one of the strangest &lt;br&gt;
feelings, and I remember thinking to myself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I don't understand what is expected of me."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I gave AI this prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I'm just starting out in this career, I don't want the answer &lt;br&gt;
handed out to me, but I need resources that can help me better &lt;br&gt;
understand the question, and information on what to learn in order to &lt;br&gt;
complete the task."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With some targeted reading and research, I was able to complete the &lt;br&gt;
project. 🎉&lt;/p&gt;


&lt;h2&gt;
  
  
  Read the Full Project
&lt;/h2&gt;

&lt;p&gt;You can read about my findings by clicking the link below:&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://emmanuelajibokun.github.io/cloud-decision-document/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;emmanuelajibokun.github.io&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;





&lt;p&gt;&lt;em&gt;If you would like to follow along on the learn-by-doing journey, &lt;br&gt;
comment "github" and I will send you the project guide link. Kindly &lt;br&gt;
star the project and follow me on GitHub.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudcomputing</category>
      <category>devops</category>
      <category>solutionarchitect</category>
      <category>cloudsolution</category>
    </item>
  </channel>
</rss>
