<?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: Raphael Gab-Momoh</title>
    <description>The latest articles on DEV Community by Raphael Gab-Momoh (@rdgmh).</description>
    <link>https://dev.to/rdgmh</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%2F511199%2Fffa24559-7006-4491-bfcc-d981cf7ce644.png</url>
      <title>DEV Community: Raphael Gab-Momoh</title>
      <link>https://dev.to/rdgmh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rdgmh"/>
    <language>en</language>
    <item>
      <title>Install and set up Kubeflow for ML on EKS.</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:33:51 +0000</pubDate>
      <link>https://dev.to/rdgmh/install-and-set-up-kubeflow-for-ml-on-eks-4cgm</link>
      <guid>https://dev.to/rdgmh/install-and-set-up-kubeflow-for-ml-on-eks-4cgm</guid>
      <description>&lt;h2&gt;
  
  
  What is Kubeflow?
&lt;/h2&gt;

&lt;p&gt;Kubeflow is like a dedicated playground for machine learning on Kubernetes. Imagine having a magic toolbox that not only helps you build and train your models but also handles all the nitty-gritty details of deploying and scaling them in a Kubernetes environment. It's like having a personal assistant that makes sure your machine learning workflows run smoothly, letting you focus on the fun part—experimenting and creating cutting-edge models.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Set Up AWS CLI
&lt;/h2&gt;

&lt;p&gt;You can install the AWS CLI from the official &lt;code&gt;[documentation](https://docs.aws.amazon.com/cli/)&lt;/code&gt;&lt;br&gt;
This command can also be used to install AWS CLI&lt;br&gt;
&lt;/p&gt;

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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyb3c5sph269qb8g54k4k.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%2Fyb3c5sph269qb8g54k4k.png" alt="aws cli downloaded"&gt;&lt;/a&gt;&lt;br&gt;
After the download, we need to configure it using the command&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;This step will prompt you to enter your AWS Access Key ID, AWS Secret Access Key, default region, and default output format (optional).&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%2Fnajf3vy19trneq8iozlz.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%2Fnajf3vy19trneq8iozlz.png" alt="account configured"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install EKS Cluster
&lt;/h2&gt;

&lt;p&gt;The raw &lt;code&gt;aws eks create-cluster&lt;/code&gt; command shown above works, but only if you already have a VPC, subnets, and an EKS service role with the right trust policy set up beforehand — that's three extra prerequisites most people don't have lying around. The faster, more common path is &lt;code&gt;eksctl&lt;/code&gt;, which creates the cluster, the VPC, the subnets, and a managed node group in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eksctl create cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; kubeflow-eks &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodegroup-name&lt;/span&gt; ml-nodes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-type&lt;/span&gt; m5.xlarge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodes&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodes-min&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nodes-max&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--managed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;m5.xlarge&lt;/code&gt; (4 vCPU, 16 GB RAM) is a reasonable floor for Kubeflow — its control-plane components (Istio, Dex, the Kubeflow dashboard, Notebook controller, Pipelines) are memory-hungry even before you run a single training job. This takes 15-20 minutes; &lt;code&gt;eksctl&lt;/code&gt; is provisioning real VPC, subnet, and IAM resources underneath, not just the cluster.&lt;/p&gt;

&lt;p&gt;Once it finishes, point &lt;code&gt;kubectl&lt;/code&gt; at the new cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws eks update-kubeconfig &lt;span class="nt"&gt;--name&lt;/span&gt; kubeflow-eks &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see your node group's instances in &lt;code&gt;Ready&lt;/code&gt; state before moving on — Kubeflow's installer will fail in confusing ways if the nodes aren't ready yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install Kubeflow
&lt;/h2&gt;

&lt;p&gt;Kubeflow doesn't ship as a single Helm chart — it's a collection of components (Istio, Dex, cert-manager, the Kubeflow Pipelines UI, Notebook controller, KServe, and more) glued together with Kustomize overlays in the official &lt;a href="https://github.com/kubeflow/manifests" rel="noopener noreferrer"&gt;kubeflow/manifests&lt;/a&gt; repo. Clone it and check out a release tag that matches a Kubernetes version your EKS cluster actually supports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kubeflow/manifests.git
&lt;span class="nb"&gt;cd &lt;/span&gt;manifests
git checkout v1.9.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubeflow's components have interdependencies that &lt;code&gt;kubectl apply -k&lt;/code&gt; alone can't always resolve on the first pass — a CRD from one component might not exist yet when another component's manifest tries to reference it. The project's own documented workaround is to retry the apply in a loop until every resource is created:&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;while&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; kustomize build example | kubectl apply &lt;span class="nt"&gt;--server-side&lt;/span&gt; &lt;span class="nt"&gt;--force-conflicts&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; -&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Retrying to apply resources"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;20
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can take several passes and several minutes — that's expected, not a sign something's broken, as long as the error messages are about missing CRDs rather than something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Verify the Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kubeflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every pod should eventually reach &lt;code&gt;Running&lt;/code&gt;. Then reach the dashboard by port-forwarding the Istio ingress gateway rather than exposing it publicly on a fresh cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl port-forward svc/istio-ingressgateway &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system 8080:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080&lt;/code&gt; — the default login is &lt;code&gt;user@example.com&lt;/code&gt; / &lt;code&gt;12341234&lt;/code&gt;, which you should treat as a placeholder to change immediately, not a real credential to leave in place, if this cluster is anything more than a throwaway lab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The gap between the raw &lt;code&gt;aws eks create-cluster&lt;/code&gt; command and a running Kubeflow dashboard is bigger than it looks from the AWS CLI reference page alone — a working node group, a Kustomize-based multi-component install with real interdependency ordering issues, and a default credential you have to remember to rotate. None of that is a knock on Kubeflow itself; it's a genuinely capable ML platform once it's up. It's just worth knowing the real shape of the setup before starting, rather than assuming one &lt;code&gt;aws eks create-cluster&lt;/code&gt; call and a Helm install away.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>machinelearning</category>
      <category>eks</category>
    </item>
    <item>
      <title>Terraform on Alibaba Cloud: Building Repeatable Infrastructure as Code</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:30:48 +0000</pubDate>
      <link>https://dev.to/rdgmh/terraform-on-alibaba-cloud-building-repeatable-infrastructure-as-code-3j5</link>
      <guid>https://dev.to/rdgmh/terraform-on-alibaba-cloud-building-repeatable-infrastructure-as-code-3j5</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of the Alibaba Cloud Engineering Lab Series.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;This isn't "Terraform lets you provision Alibaba Cloud" — that's true but generic. It's &lt;strong&gt;how to build repeatable Alibaba Cloud infrastructure with Terraform&lt;/strong&gt;: a real multi-tier project, module structure, remote state, CI/CD, drift detection, and the boundary where Terraform's job ends and configuration management begins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Git → Terraform code → plan → state → apply → VPC/ECS/OSS/ACK → Alibaba Cloud&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Before the how, the what — three terms this guide leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure as Code (IaC)&lt;/strong&gt; — describing your cloud infrastructure (networks, servers, databases) in text files instead of clicking through a console. The point isn't just automation — it's that the text file is a single, reviewable, version-controlled source of truth for what your infrastructure is supposed to look like, the same discipline you already apply to application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform provider&lt;/strong&gt; — a plugin that translates Terraform's generic language into calls against one specific cloud's API. &lt;code&gt;aliyun/alicloud&lt;/code&gt; is the Alibaba Cloud provider; Azure and AWS each have their own. The provider is &lt;em&gt;why&lt;/em&gt; the same Terraform workflow (write, plan, apply) works across completely different clouds — only the resource names and fields change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform state&lt;/strong&gt; — a file Terraform keeps that records what it already created, so a second &lt;code&gt;apply&lt;/code&gt; knows the difference between "create this new thing" and "this already exists, only two fields changed." Without state, every run would have no memory of previous runs — see Section 06 for why that file has to be handled carefully.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every resource below is &lt;code&gt;terraform validate&lt;/code&gt;-verified against the real &lt;code&gt;aliyun/alicloud&lt;/code&gt; provider, not written from memory and assumed correct — the &lt;a href="https://github.com/raphgm/terraform-alibaba-cloud-lab" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt; has the actual module structure, restructured from a single flat file into the modules/environments layout Section 04 describes below.&lt;/p&gt;




&lt;h2&gt;
  
  
  01 — Why This, and What "Repeatable" Actually Means
&lt;/h2&gt;

&lt;p&gt;Alibaba Cloud is the dominant hyperscaler across China and much of Southeast Asia — a region most Azure- and AWS-trained IaC practitioners have never provisioned into. &lt;a href="https://www.alibabacloud.com/help/en/terraform/what-is-terraform" rel="noopener noreferrer"&gt;Alibaba's own Terraform documentation positions it as the IaC mechanism spanning ECS, VPC, OSS, ACK, RAM, PAI, and the rest of the platform&lt;/a&gt; — the same breadth Terraform covers on Azure or AWS.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    Git
                     │
                     ▼
              Terraform Code
                     │
              terraform plan
                     │
                     ▼
              Terraform State
                     │
              terraform apply
                     │
        ┌────────────┼────────────┐
        ▼            ▼            ▼
       VPC          ECS          OSS
        │            │
        ▼            ▼
    Security       ACK
     Groups
        │
        └────────────┬────────────┘
                     ▼
                 Alibaba Cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of this article builds toward one concrete deliverable: &lt;strong&gt;a production-ready web application, fully reproducible from a Terraform project&lt;/strong&gt;, not a series of disconnected resource snippets.&lt;/p&gt;




&lt;h2&gt;
  
  
  02 — Provider Setup: Be Explicit About the Source
&lt;/h2&gt;

&lt;p&gt;The Terraform Registry currently lists &lt;strong&gt;two&lt;/strong&gt; Alibaba Cloud providers — the Alibaba-maintained &lt;a href="https://registry.terraform.io/providers/aliyun/alicloud/latest/docs" rel="noopener noreferrer"&gt;&lt;code&gt;aliyun/alicloud&lt;/code&gt;&lt;/a&gt; and a legacy &lt;a href="https://registry.terraform.io/providers/hashicorp/alicloud/latest/docs" rel="noopener noreferrer"&gt;&lt;code&gt;hashicorp/alicloud&lt;/code&gt;&lt;/a&gt; listing. Ambiguity here causes real init/version problems — always pin the source explicitly, not just the provider short name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;alicloud&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aliyun/alicloud"&lt;/span&gt; &lt;span class="c1"&gt;# the actively Alibaba-maintained provider&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 1.230"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.6.0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"alicloud"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Credentials via environment variables — &lt;a href="https://www.alibabacloud.com/help/en/terraform/how-to-use-terraform-provider/" rel="noopener noreferrer"&gt;Alibaba's documentation recommends environment-based credential configuration for local usage&lt;/a&gt;, never hardcoded in a &lt;code&gt;provider&lt;/code&gt; block:&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;export &lt;/span&gt;&lt;span class="nv"&gt;ALICLOUD_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALICLOUD_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ALICLOUD_REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production CI/CD should go further than "environment variables instead of hardcoded" — use short-lived or managed credentials where the pipeline supports them, rather than a long-lived static AccessKey committed to a secrets store.&lt;/p&gt;




&lt;h2&gt;
  
  
  03 — The Project: A Production-Ready Web Application
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Scenario:&lt;/strong&gt; deploy a real multi-tier web application — VPC, VSwitch, security groups, ECS, OSS, SLB, a RAM role scoped to the app, optional RDS, and outputs a reader can actually reproduce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network foundation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-prod-apac"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.10.0.0/16"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vswitch"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vsw-app-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.10.1.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1a"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vswitch"&lt;/span&gt; &lt;span class="s2"&gt;"data"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vsw-data-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.10.2.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1b"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A VSwitch is bound to a single &lt;strong&gt;zone&lt;/strong&gt; at creation, unlike an Azure subnet spanning a region — plan zone placement deliberately for availability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security groups:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"app_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-app-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"allow_https"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;nic_type&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"intranet"&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"accept"&lt;/span&gt;
  &lt;span class="nx"&gt;port_range&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"443/443"&lt;/span&gt;
  &lt;span class="nx"&gt;priority&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_ip&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Compute:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_instance"&lt;/span&gt; &lt;span class="s2"&gt;"app_server"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instance_name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs-app-01"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs.g6.large"&lt;/span&gt;
  &lt;span class="nx"&gt;image_id&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aliyun_3_x64_20G_alibase_20240628.vhd"&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_id&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;internet_max_bandwidth_out&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
  &lt;span class="nx"&gt;system_disk_category&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloud_essd"&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;managed_by&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Storage (OSS):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_oss_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"app_assets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-static-assets-prod"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# ACL is a separate resource as of provider 1.220+ — "private" is also&lt;/span&gt;
&lt;span class="c1"&gt;# the bucket's default, but set it explicitly rather than relying on it.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_oss_bucket_acl"&lt;/span&gt; &lt;span class="s2"&gt;"app_assets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_oss_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;
  &lt;span class="nx"&gt;acl&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"private"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Load balancing:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_slb_load_balancer"&lt;/span&gt; &lt;span class="s2"&gt;"app_lb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"slb-app-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_id&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_spec&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"slb.s2.small"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_slb_listener"&lt;/span&gt; &lt;span class="s2"&gt;"https"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_slb_load_balancer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;backend_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="nx"&gt;frontend_port&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;bandwidth&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A RAM role scoped to what the app actually needs&lt;/strong&gt; — read access to its own OSS bucket, nothing account-wide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_ram_role"&lt;/span&gt; &lt;span class="s2"&gt;"app_role"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;role_name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-server-role"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy_document&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ecs.aliyuncs.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Data tier (optional RDS), scoped to the app-tier CIDR only:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MySQL"&lt;/span&gt;
  &lt;span class="nx"&gt;engine_version&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"8.0"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rds.mysql.s2.large"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_storage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_id&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;instance_name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rds-prod-primary"&lt;/span&gt;
  &lt;span class="nx"&gt;security_ips&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"10.10.1.0/24"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Outputs, so the deployment hands back what a consumer actually needs:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"load_balancer_ip"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_slb_load_balancer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"oss_bucket_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_oss_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  04 — Repository Structure, and Why It's Shaped This Way
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;terraform-alibaba/
├── main.tf
├── provider.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── terraform.tfvars
├── modules/
│   ├── network/
│   ├── ecs/
│   ├── security/
│   └── storage/
└── environments/
    ├── dev/
    ├── staging/
    └── production/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;modules/&lt;/code&gt;&lt;/strong&gt; isolates each infrastructure concern (network, compute, security, storage) so a change to how ECS is provisioned doesn't require touching network code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;environments/&lt;/code&gt;&lt;/strong&gt; keeps dev/staging/production as separate root configurations calling the same modules with different variables — the environment differs in scale and CIDR ranges, not in the underlying architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;versions.tf&lt;/code&gt;&lt;/strong&gt; pins the provider source and Terraform CLI version separately from &lt;code&gt;main.tf&lt;/code&gt;, so a version bump is a one-file, reviewable diff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the difference between "knows Terraform syntax" and "can structure a Terraform project" — the second is what actually gets evaluated in a real engineering review.&lt;/p&gt;




&lt;h2&gt;
  
  
  05 — The Terraform Lifecycle
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WRITE
  ↓
terraform fmt
  ↓
terraform validate
  ↓
terraform plan
  ↓
CODE REVIEW
  ↓
terraform apply
  ↓
STATE
  ↓
DRIFT DETECTION
  ↓
UPDATE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.alibabacloud.com/help/en/terraform/terraform-command" rel="noopener noreferrer"&gt;Alibaba's documentation covers &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;, &lt;code&gt;apply&lt;/code&gt;, and &lt;code&gt;destroy&lt;/code&gt;&lt;/a&gt;, with &lt;code&gt;plan&lt;/code&gt; previewing changes before they're applied. Two commands worth treating as non-negotiable in a professional workflow, beyond that baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform &lt;span class="nb"&gt;fmt&lt;/span&gt; &lt;span class="nt"&gt;-check&lt;/span&gt;    &lt;span class="c"&gt;# enforce consistent formatting, fail CI if not run&lt;/span&gt;
terraform validate      &lt;span class="c"&gt;# catch syntax/config errors before a plan even runs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skipping these two doesn't save time — it just moves the failure from a 2-second local check to a slower, more visible CI failure or a bad &lt;code&gt;plan&lt;/code&gt; output nobody trusts.&lt;/p&gt;




&lt;h2&gt;
  
  
  06 — State Management
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does Terraform need state at all?&lt;/strong&gt; Because it has to answer one question before every &lt;code&gt;apply&lt;/code&gt;: &lt;em&gt;"what did I create previously?"&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform configuration
        +
Terraform state
        +
Actual Alibaba Cloud infrastructure
        ↓
Terraform determines the difference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without state, every &lt;code&gt;apply&lt;/code&gt; would have no way to distinguish "create this new resource" from "this resource already exists, only these two fields changed." State is the memory that makes incremental, non-destructive updates possible.&lt;/p&gt;

&lt;p&gt;What a serious state practice covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Remote state&lt;/strong&gt; — an OSS bucket, never a local &lt;code&gt;.tfstate&lt;/code&gt; file, and never committed to Git (it can contain sensitive values in plain text).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State locking&lt;/strong&gt; — prevents two people running &lt;code&gt;apply&lt;/code&gt; simultaneously from corrupting state; pair the OSS backend with Table Store for lock coordination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State backup&lt;/strong&gt; — versioning enabled on the state bucket, so a bad apply's prior state is recoverable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sensitive information&lt;/strong&gt; — database passwords and similar values that land in state should be sourced from a secrets manager reference, not a plaintext &lt;code&gt;variable&lt;/code&gt; default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team collaboration&lt;/strong&gt; — remote state with locking is what makes concurrent team usage safe at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State isolation between environments&lt;/strong&gt; — dev, staging, and production each need their own state file; one shared state file across environments is how a &lt;code&gt;terraform destroy&lt;/code&gt; in dev takes down production by accident.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"oss"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-state-prod-apac"&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"network/terraform.tfstate"&lt;/span&gt;
    &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  07 — Terraform vs. the Alibaba Cloud Console
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Console&lt;/th&gt;
&lt;th&gt;Terraform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One-off VM&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeat environment&lt;/td&gt;
&lt;td&gt;Poor&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version control&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code review&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-environment&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disaster recovery&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large infrastructure&lt;/td&gt;
&lt;td&gt;Difficult&lt;/td&gt;
&lt;td&gt;Strong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning curve&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://www.alibabacloud.com/help/en/terraform/what-is-terraform" rel="noopener noreferrer"&gt;Alibaba's own documentation draws essentially this same distinction&lt;/a&gt; — Terraform earns its complexity budget at the point where an environment needs to be repeatable, reviewable, or reproduced more than once. A single throwaway test VM doesn't need a Terraform module; a production environment always does.&lt;/p&gt;




&lt;h2&gt;
  
  
  08 — Modules: From Script to Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt; &lt;span class="s2"&gt;"network"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"./modules/network"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_cidr&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_cidr&lt;/span&gt;
  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Resource → Module → Environment&lt;/strong&gt; is the progression that turns Terraform from a scripting tool into reusable infrastructure architecture. The Registry already has community modules worth knowing about — &lt;a href="https://registry.terraform.io/modules/alibaba/vpc/alicloud/latest" rel="noopener noreferrer"&gt;the &lt;code&gt;alibaba/vpc/alicloud&lt;/code&gt; module&lt;/a&gt;, for instance, wraps VPC, VSwitch, and route-entry resources into a single reusable call — worth evaluating before writing an equivalent module from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  09 — Multi-Environment Design
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 Terraform Modules
                        │
             ┌──────────┼──────────┐
             ▼          ▼          ▼
            DEV       STAGING      PROD
             │          │          │
             ▼          ▼          ▼
           Alibaba Cloud environments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference between &lt;code&gt;environment = "dev"&lt;/code&gt; and &lt;code&gt;environment = "production"&lt;/code&gt; isn't just a variable value — it's &lt;strong&gt;isolation&lt;/strong&gt;: separate state files, separate VPC CIDR ranges (so they can never accidentally peer), separate credentials with separate permission scopes, and in most orgs, a manual-approval gate on production &lt;code&gt;apply&lt;/code&gt; that doesn't exist for dev. Environment isolation is a security and blast-radius boundary, not a naming convention.&lt;/p&gt;




&lt;h2&gt;
  
  
  10 — CI/CD
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer
    │
    ▼
Git push
    │
    ▼
CI Pipeline
    │
    ├── terraform fmt
    ├── terraform validate
    ├── security scan
    └── terraform plan
             │
             ▼
        Pull Request
             │
          Approval
             │
             ▼
       terraform apply
             │
             ▼
       Alibaba Cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the step that turns isolated Terraform knowledge into an actual DevOps practice: &lt;code&gt;plan&lt;/code&gt; output posted to the pull request for human review, &lt;code&gt;apply&lt;/code&gt; gated behind approval, and a security scan (&lt;code&gt;tfsec&lt;/code&gt;/&lt;code&gt;checkov&lt;/code&gt;) catching a public &lt;code&gt;0.0.0.0/0&lt;/code&gt; security-group rule or an unencrypted OSS bucket before it merges — the same gate a CI pipeline enforces on application code, applied to infrastructure code.&lt;/p&gt;




&lt;h2&gt;
  
  
  11 — Drift Detection
&lt;/h2&gt;

&lt;p&gt;The scenario every team eventually hits: someone opens the Alibaba Cloud console and manually changes a security group rule "just to fix something quickly" — port 22 gets opened where Terraform's configuration says it should stay restricted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform configuration says:  Port 443 = allowed, Port 22 = restricted
Actual infrastructure says:    Port 22 = open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single command surfaces the exact drift — Terraform reads the real infrastructure state and diffs it against configuration, and the plan output shows the manual change as something Terraform intends to revert. This is one of the strongest arguments for IaC over console-driven changes: the drift isn't just detected, it's specifically named, in a way a manual audit would take far longer to catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  12 — What Terraform Doesn't Manage
&lt;/h2&gt;

&lt;p&gt;Terraform is excellent for infrastructure lifecycle management — it is not a full configuration-management system. &lt;a href="https://www.alibabacloud.com/help/en/terraform/terraform-provider/" rel="noopener noreferrer"&gt;Alibaba's own Terraform provider documentation is explicit that Terraform manages infrastructure resources, not system-level operational tasks&lt;/a&gt; like installing software or managing OS updates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform
    ↓
Infrastructure
    ↓
ECS / VPC / SLB / OSS
    ↓
Ansible / cloud-init / containers
    ↓
Application configuration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Knowing this boundary is a maturity signal — a common mistake is trying to force Terraform's &lt;code&gt;null_resource&lt;/code&gt; + &lt;code&gt;remote-exec&lt;/code&gt; into doing configuration management that a purpose-built tool (Ansible, cloud-init, or a container image) handles far more reliably.&lt;/p&gt;




&lt;h2&gt;
  
  
  13 — Connecting to the AI Infrastructure Article
&lt;/h2&gt;

&lt;p&gt;This isn't an isolated tutorial — it's the infrastructure layer underneath &lt;a href="https://dev.to/articles/alibaba-cloud-ai-infrastructure-ecs-pai"&gt;this series' AI Infrastructure article&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform
    │
    ├── VPC
    ├── ECS GPU
    ├── OSS
    ├── Security
    │
    ▼
Alibaba Cloud Infrastructure
    │
    ▼
PAI
    │
    ├── DSW
    ├── DLC
    └── EAS
    │
    ▼
AI Application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Real-world scenario:&lt;/strong&gt; deploying an AI application on Alibaba Cloud needs GPU ECS, a VPC with a private subnet, OSS for model storage, PAI, a load balancer, security groups, and a CI/CD pipeline gating all of it. Terraform establishes every piece of that infrastructure layer &lt;em&gt;before&lt;/em&gt; PAI's DSW/DLC/EAS ever touches a workload — the two articles are one continuous engineering path, not two separate topics that happen to both mention Alibaba Cloud.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Takeaways
&lt;/h2&gt;

&lt;p&gt;The value here isn't "learn a fourth cloud provider's resource names" — it's proving that a well-formed Terraform practice &lt;strong&gt;transfers&lt;/strong&gt;: the resources differ, the naming differs, but modular structure, remote state with locking, environment isolation, CI-gated &lt;code&gt;plan&lt;/code&gt;/&lt;code&gt;apply&lt;/code&gt;, and drift detection are identical disciplines regardless of which cloud sits above the API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My recommendation:&lt;/strong&gt; treat Terraform on Alibaba Cloud exactly like Terraform anywhere else — pin the provider source explicitly, never let state live locally, gate production &lt;code&gt;apply&lt;/code&gt; behind review, and stop at the boundary where configuration management should take over. Get that discipline right once, and it ports to the next provider almost unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/terraform-alibaba-cloud-lab" rel="noopener noreferrer"&gt;terraform-alibaba-cloud-lab&lt;/a&gt; — the full modular project: network/security/ecs/storage modules composed into a production environment, ready to run.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against current Alibaba Cloud Terraform documentation and the Terraform Registry as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform · Alibaba Cloud · Multi-Cloud IaC · State Management · Modules · CI/CD · Drift Detection&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/terraform-on-alibaba-cloud" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>alibabacloud</category>
      <category>iac</category>
      <category>multicloud</category>
    </item>
    <item>
      <title>Building a Secure Alibaba Cloud VPC: From Public Internet to Private Architecture</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:29:26 +0000</pubDate>
      <link>https://dev.to/rdgmh/building-a-secure-alibaba-cloud-vpc-from-public-internet-to-private-architecture-4ghj</link>
      <guid>https://dev.to/rdgmh/building-a-secure-alibaba-cloud-vpc-from-public-internet-to-private-architecture-4ghj</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 5 of the Alibaba Cloud Engineering Lab Series.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Most VPC tutorials explain terminology. This one builds a real three-tier network and deliberately breaks a route so the troubleshooting is genuine, not scripted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
   │
   ▼
 SLB (public VSwitch, 10.0.1.0/24)
   │
   ▼
 App Tier (private VSwitch, 10.0.2.0/24) ── NAT Gateway ── Internet (outbound only)
   │
   ▼
 Data Tier (private VSwitch, 10.0.3.0/24, no internet route at all)

 Bastion Host (public VSwitch, restricted security group) ──▶ App/Data tiers via SSH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the how, the what — three terms this build leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPC (Virtual Private Cloud) / VSwitch&lt;/strong&gt; — a VPC is an isolated network you control inside Alibaba Cloud; a VSwitch is a subdivision of it (Alibaba's equivalent of an Azure subnet or AWS subnet) bound to a single availability zone. Splitting one VPC into multiple VSwitches by tier (public, app, data) is what makes it possible to apply different network rules to each tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NAT Gateway&lt;/strong&gt; — lets resources with no public IP of their own still reach the internet &lt;em&gt;outbound&lt;/em&gt; (for package installs, calling an external API), without ever being reachable &lt;em&gt;inbound&lt;/em&gt; from the internet. It's the standard way to give a private-tier resource internet access without making it internet-facing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bastion host&lt;/strong&gt; — a single, tightly locked-down server that's the only path allowed in for administrative access (SSH/RDP) to everything else in the private tiers. Rather than exposing every server's management port to the internet, you expose exactly one, monitor it closely, and route all admin access through it.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;The naive version of this architecture puts everything in one VSwitch with one permissive security group — a frontend, backend, and database all mutually reachable, and the database directly internet-facing "temporarily" during setup, which in practice means indefinitely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;VPC and tiered VSwitches:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vpc"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"vpc-secure-lab"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.0.0/16"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vswitch"&lt;/span&gt; &lt;span class="s2"&gt;"public"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1a"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vswitch"&lt;/span&gt; &lt;span class="s2"&gt;"app_private"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.2.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1a"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_vswitch"&lt;/span&gt; &lt;span class="s2"&gt;"data_private"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.3.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ap-southeast-1b"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NAT Gateway for the app tier's outbound-only path&lt;/strong&gt; (package updates, external API calls) without any inbound route from the internet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_nat_gateway"&lt;/span&gt; &lt;span class="s2"&gt;"app_nat"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;nat_type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Enhanced"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Security groups, least privilege.&lt;/strong&gt; Four groups — one per tier — declared first, then the rules between them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"slb_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-slb-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"app_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-app-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"data_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-data-tier"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"bastion_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sg-bastion"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"app_from_slb_only"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;port_range&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"8080/8080"&lt;/span&gt;
  &lt;span class="nx"&gt;source_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slb_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"data_from_app_only"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;port_range&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"3306/3306"&lt;/span&gt;
  &lt;span class="nx"&gt;source_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data tier's security group accepts traffic &lt;strong&gt;only&lt;/strong&gt; from the app tier's security group — not a CIDR range, a security-group reference. That distinction matters: CIDR-based rules break the moment IPs shift; security-group references stay correct as instances scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bastion access:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"bastion_ssh"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bastion_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;port_range&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22/22"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_ip&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin_office_cidr&lt;/span&gt; &lt;span class="c1"&gt;# not 0.0.0.0/0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Failure / Challenge
&lt;/h2&gt;

&lt;p&gt;After deploying, the app-tier instances couldn't reach the internet for package installation — &lt;code&gt;apt-get update&lt;/code&gt; hung and timed out. The NAT Gateway was provisioned, but the app-tier VSwitch's route table still had its default route pointing nowhere (the implicit local-only route), because Terraform's &lt;code&gt;alicloud_route_entry&lt;/code&gt; resource for the NAT default route hadn't been declared — the NAT Gateway existing isn't enough; the route table has to actually point traffic at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Solution
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_route_entry"&lt;/span&gt; &lt;span class="s2"&gt;"app_default_route"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;route_table_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route_table_id&lt;/span&gt;
  &lt;span class="nx"&gt;destination_cidrblock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
  &lt;span class="nx"&gt;nexthop_type&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NatGateway"&lt;/span&gt;
  &lt;span class="nx"&gt;nexthop_id&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_nat_gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_nat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the Alibaba Cloud equivalent of forgetting a Route Table association on an Azure UDR, or an AWS route table missing its NAT Gateway target — the same class of "the component exists, but nothing points traffic at it" mistake shows up on every cloud's networking stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost / Performance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Monthly Cost (approx.)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;NAT Gateway (Enhanced)&lt;/td&gt;
&lt;td&gt;~$45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SLB (s2.small)&lt;/td&gt;
&lt;td&gt;~$18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bastion ECS (t6.small)&lt;/td&gt;
&lt;td&gt;~$8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total network overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$71/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the fixed cost of a properly segmented network, independent of the compute it protects — worth budgeting explicitly rather than discovering it as an unexplained line item later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A NAT Gateway provisioned but not routed-to is a silent no-op — verify the route table, not just the resource's existence.&lt;/li&gt;
&lt;li&gt;Security-group-to-security-group references are more resilient than CIDR rules in any environment where instances scale or get replaced.&lt;/li&gt;
&lt;li&gt;"Temporarily" public database access has a way of becoming permanent — build the private path first, never as a follow-up task.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/secure-alibaba-cloud-vpc-lab" rel="noopener noreferrer"&gt;secure-alibaba-cloud-vpc-lab&lt;/a&gt; — the three-tier VPC, NAT gateway, and security-group tier isolation, ready to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC · Alibaba Cloud · Networking · Security Groups · NAT Gateway · Route Tables&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/secure-alibaba-cloud-vpc-architecture" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>alibabacloudengineeringlabseri</category>
      <category>networking</category>
      <category>vpc</category>
      <category>security</category>
    </item>
    <item>
      <title>Alibaba Cloud AI Infrastructure: Building an AI Application with ECS and PAI</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:28:52 +0000</pubDate>
      <link>https://dev.to/rdgmh/alibaba-cloud-ai-infrastructure-building-an-ai-application-with-ecs-and-pai-1356</link>
      <guid>https://dev.to/rdgmh/alibaba-cloud-ai-infrastructure-building-an-ai-application-with-ecs-and-pai-1356</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 4 of the Alibaba Cloud Engineering Lab Series.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;ECS provides the compute. PAI provides the AI platform.&lt;/strong&gt; They are not competing choices — PAI's services run &lt;em&gt;on top of&lt;/em&gt; ECS-class GPU/CPU compute, ACK, and RDMA networking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSW → develop · DLC → train · EAS → deploy · OSS → store&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use ECS directly when infrastructure control is the primary requirement. Use PAI when reducing ML-platform operational overhead matters more. Most production systems end up combining both: ECS-backed compute underneath, PAI services managing the AI lifecycle above it.&lt;/p&gt;
&lt;/blockquote&gt;




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

&lt;p&gt;The question this article actually answers isn't "what is PAI" — it's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How do you build an AI infrastructure stack that can move from a single GPU experiment to production inference without rebuilding the platform at every stage?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most teams hit this wall in a predictable order: a notebook proves the model works → training needs to scale past one GPU → the trained artifact needs a real serving endpoint → that endpoint needs to survive concurrent production traffic without falling over. Four different infrastructure problems, usually solved with four different ad hoc tools, unless the platform underneath is designed to carry a workload through all four stages.&lt;/p&gt;

&lt;p&gt;The shape of the answer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data → OSS → ECS/GPU → PAI-DSW → PAI-DLC → PAI-EAS → API/Application&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before the how, the what — the three PAI acronyms this article uses constantly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PAI-DSW (Data Science Workshop)&lt;/strong&gt; — a hosted, GPU-backed Jupyter-style notebook. This is where you write and iteratively test model code, the same role a local Jupyter notebook plays, minus the hassle of provisioning and configuring a GPU machine yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAI-DLC (Deep Learning Containers)&lt;/strong&gt; — a managed service for running a training job as a container, scaled across multiple GPUs or machines when one GPU isn't enough. Where DSW is for interactive, iterative development, DLC is for a training run you kick off and let finish unattended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAI-EAS (Elastic Algorithm Service)&lt;/strong&gt; — a managed endpoint that serves a trained model to real traffic, handling autoscaling and load distribution so a spike in requests doesn't fall over. This is the only one of the three a live user-facing request ever actually touches — DSW and DLC are both offline, development-time tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I fine-tuned a real sentiment classifier, deployed it to a dedicated PAI-EAS resource group, and load-tested the endpoint myself — the 4.2-second p99 latency spike in Section 09 below is a number I actually measured, not an estimate. Every step is runnable end to end from the &lt;a href="https://github.com/raphgm/alibaba-cloud-ai-ecs-pai-lab" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  02 — Architecture
&lt;/h2&gt;

&lt;p&gt;Two things are easy to conflate in a single diagram here: &lt;strong&gt;what a live inference request touches&lt;/strong&gt; (Applications → EAS, full stop) versus &lt;strong&gt;what everything is built on&lt;/strong&gt; (ECS/GPU compute, with OSS as the shared artifact store). Drawing DSW/DLC in the same downward chain as a live request — a mistake the first version of this diagram made — implies traffic flows through training and development on every call, which it never does. Two diagrams, not one, keeps that honest:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The live serving path (what a request actually touches):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────┐
│    Applications    │
│ Web / Mobile / API │
└────────────────────┘
           │  HTTPS / API Gateway
           ▼
   ┌───────────────┐
   │    PAI-EAS    │
   │ Model Serving │
   │  Autoscaling  │
   └───────────────┘
           │ loads versioned model from
           ▼
  ┌─────────────────┐
  │       OSS       │
  │ Model Artifacts │
  └─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The offline training/dev pipeline (never touched by a live request):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────┐      ┌─────────────────────────┐
│       PAI-DLC        │      │         PAI-DSW         │
│ Distributed Training │      │ Development / Notebooks │
└──────────────────────┘      └─────────────────────────┘
            │                              │
            └──────────────┴───────────────┘
                           │ writes new model version to
                           ▼
                        ┌─────┐
                        │ OSS │
                        └─────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Both pipelines share one base:&lt;/strong&gt; PAI-EAS's serving instances, PAI-DLC's training jobs, and PAI-DSW's notebooks all run &lt;em&gt;on&lt;/em&gt; ECS/GPU compute — that's the layering relationship, not a data-flow relationship. OSS is the only thing both diagrams actually share: DLC/DSW write a new model version to it; EAS reads the current version from it. They never call each other directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.alibabacloud.com/help/en/pai/ai-computing-resource-management/" rel="noopener noreferrer"&gt;PAI's infrastructure layer explicitly includes CPUs, GPUs, RDMA networking, and ACK&lt;/a&gt; underneath its managed tools — the shared ECS/GPU base under both diagrams above is that relationship made visual.&lt;/p&gt;




&lt;h2&gt;
  
  
  03 — ECS: The Compute Layer
&lt;/h2&gt;

&lt;p&gt;ECS is where the actual GPU cycles live. &lt;a href="https://www.alibabacloud.com/help/en/ecs/user-guide/gpu-accelerated-compute-optimized-and-vgpu-accelerated-instance-families-1" rel="noopener noreferrer"&gt;Alibaba Cloud's GPU-accelerated instance families&lt;/a&gt; (the &lt;code&gt;gn7&lt;/code&gt;, &lt;code&gt;gn6&lt;/code&gt;, &lt;code&gt;ebmgn7&lt;/code&gt; series) are what PAI-DSW notebooks, PAI-DLC training jobs, and PAI-EAS serving instances all ultimately run on — you can provision and manage that layer directly, or let PAI provision and manage it for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun ecs DescribeInstanceTypes &lt;span class="nt"&gt;--InstanceTypeFamily&lt;/span&gt; gn7i
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Provisioning ECS directly gives you OS-level control: custom drivers, a specific CUDA/cuDNN version pin, a non-standard networking setup, or integration into an existing Kubernetes/container platform you already operate. That control is the entire reason to reach for ECS directly instead of letting PAI abstract it away.&lt;/p&gt;




&lt;h2&gt;
  
  
  04 — PAI: The AI Platform
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.alibabacloud.com/help/en/pai/product-overview/getting-started-pai" rel="noopener noreferrer"&gt;PAI (Platform for AI) spans the full ML lifecycle&lt;/a&gt;:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PAI-DSW&lt;/strong&gt; — interactive, GPU-backed notebooks for development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAI-DLC&lt;/strong&gt; — managed distributed training jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PAI-EAS&lt;/strong&gt; — &lt;a href="https://www.alibabacloud.com/help/en/pai/model-deployment" rel="noopener noreferrer"&gt;managed model-serving/inference&lt;/a&gt;, with autoscaling, custom images, and storage mounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model Gallery&lt;/strong&gt; — a catalog of pretrained models ready to fine-tune or deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Designer&lt;/strong&gt; — visual pipeline authoring for teams that want a lower-code workflow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is PAI managing orchestration, environment setup, and scaling &lt;em&gt;on top of&lt;/em&gt; ECS/GPU compute — not instead of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  05 — Development with DSW
&lt;/h2&gt;

&lt;p&gt;A DSW instance is a persistent, GPU-backed notebook environment — the managed alternative to SSHing into a raw ECS GPU box and configuring CUDA yourself every time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun pai CreateDSWInstance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--InstanceType&lt;/span&gt; ecs.gn7i-c8g1.2xlarge &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--EnvironmentType&lt;/span&gt; pytorch2.1-gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://www.alibabacloud.com/help/en/pai/developer-reference/quick-start" rel="noopener noreferrer"&gt;Current PAI quick-start documentation demonstrates exactly this&lt;/a&gt; — creating a GPU DSW instance directly against an ECS instance spec such as &lt;code&gt;ecs.gn7i-c8g1.2xlarge&lt;/code&gt;, which is the clearest confirmation that DSW is a managed layer over ECS, not a separate compute product.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hands-On Lab: Deploying a Hugging Face Model, End to End
&lt;/h2&gt;

&lt;p&gt;This is the part most PAI overviews skip — an actual workflow, not a description of one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Provision compute (inside DSW):&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;aliyun pai CreateDSWInstance &lt;span class="nt"&gt;--InstanceType&lt;/span&gt; ecs.gn7i-c8g1.2xlarge &lt;span class="nt"&gt;--EnvironmentType&lt;/span&gt; pytorch2.1-gpu
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Inside the DSW notebook, install dependencies:&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;pip &lt;span class="nb"&gt;install &lt;/span&gt;transformers torch accelerate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — Download and fine-tune a base model:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoModelForSequenceClassification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Trainer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TrainingArguments&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoModelForSequenceClassification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distilbert-base-uncased&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_labels&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;distilbert-base-uncased&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;training_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TrainingArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;per_device_train_batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_train_epochs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Trainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;training_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;train_dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;train_ds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a dataset too large for a single GPU, the same job moves from DSW into &lt;strong&gt;PAI-DLC&lt;/strong&gt; as a distributed training job instead — same code, different execution target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — Save the fine-tuned model to OSS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;./sentiment-v3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ossutil &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ./sentiment-v3 oss://ml-models-prod/sentiment-v3/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 5 — Deploy with EAS, on a dedicated resource group:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Public resource groups are fine for testing and bursty experimentation; a production endpoint should run on a &lt;a href="https://www.alibabacloud.com/help/en/pai/purchase-and-configure-eas-resource-groups/" rel="noopener noreferrer"&gt;dedicated EAS resource group&lt;/a&gt; with a reserved GPU/CPU quota, so its capacity isn't contended by other tenants' workloads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sentiment-classifier-v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model_path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"oss://ml-models-prod/sentiment-v3/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"processor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pytorch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource_group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eas-r-prod-dedicated"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&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;"instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cpu"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"memory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eascmd create service.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If several smaller models need to share GPU capacity rather than each reserving a full card, &lt;a href="https://www.alibabacloud.com/help/en/pai/advanced-configuration-gpu-sharing" rel="noopener noreferrer"&gt;PAI-EAS supports GPU sharing/slicing&lt;/a&gt; — dividing a single GPU's memory and compute across multiple services instead of over-provisioning whole GPUs per model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6 — Call it from a real application&lt;/strong&gt;, through a thin ECS-hosted API layer rather than exposing the inference endpoint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/predict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PredictRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verify_api_key&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pai_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;EAS_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The full path, visualized:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Notebook (DSW)
   ↓
Training (DLC, if distributed)
   ↓
Model artifact
   ↓
OSS
   ↓
EAS
   ↓
REST API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  06 — What Happens Under the Hood at Inference Time
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client submits request
        ↓
   ECS API layer (auth, validation)
        ↓
   PAI-EAS receives request
        ↓
   Request routed to a healthy model instance
        ↓
   GPU executes inference
        ↓
   Response returned
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under low traffic this path is trivial — one instance, one GPU, done. What changes as traffic increases is the routing step: EAS starts distributing requests across multiple instances, and if the queue depth in front of those instances grows faster than they can drain it, EAS's autoscaler provisions additional instances (see the load-test failure in Section 09 for exactly what happens when that autoscaler isn't configured).&lt;/p&gt;




&lt;h2&gt;
  
  
  07 — ECS vs. PAI: The Comparison That Actually Matters
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ECS (GPU, direct)&lt;/th&gt;
&lt;th&gt;PAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure control&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI environment&lt;/td&gt;
&lt;td&gt;Build yourself&lt;/td&gt;
&lt;td&gt;Managed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development&lt;/td&gt;
&lt;td&gt;Manual setup&lt;/td&gt;
&lt;td&gt;DSW&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training&lt;/td&gt;
&lt;td&gt;Manual/self-orchestrated&lt;/td&gt;
&lt;td&gt;DLC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model serving&lt;/td&gt;
&lt;td&gt;Build/manage yourself&lt;/td&gt;
&lt;td&gt;EAS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flexibility&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational effort&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;td&gt;Lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Custom infrastructure&lt;/td&gt;
&lt;td&gt;Full AI lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;ECS gives you infrastructure control. PAI gives you AI platform capabilities. They aren't mutually exclusive — PAI is built on ECS-class compute.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefer ECS directly when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need OS-level control (custom drivers, kernel modules, a specific CUDA build).&lt;/li&gt;
&lt;li&gt;You're running a highly customized or non-standard AI stack.&lt;/li&gt;
&lt;li&gt;You need specialized networking PAI's abstractions don't expose.&lt;/li&gt;
&lt;li&gt;You already operate Kubernetes/container infrastructure and want AI workloads inside that same platform.&lt;/li&gt;
&lt;li&gt;You want to own the orchestration layer yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;PAI becomes attractive when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your team wants managed AI tooling instead of building it.&lt;/li&gt;
&lt;li&gt;You need repeatable, versioned training workflows (DLC).&lt;/li&gt;
&lt;li&gt;You need production model serving with autoscaling built in (EAS).&lt;/li&gt;
&lt;li&gt;Reducing ML infrastructure operations matters more than maximum control.&lt;/li&gt;
&lt;li&gt;You want an integrated develop → train → deploy workflow without stitching one together yourself.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  08 — Architecture Decision Tree
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Do I need GPU compute?
        │
       YES
        │
        ▼
Do I need full infrastructure control?
        │
    ┌───┴────┐
   YES       NO
    │         │
    ▼         ▼
   ECS       PAI
    │         │
    ▼      ┌──┴──────┐
Self-managed  │         │
serving      DSW       DLC
(you build   Develop   Train
 &amp;amp; operate     │         │
 everything)   └────┬────┘
                    ▼
                   EAS
              Model Serving
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two leaves are genuinely different outcomes, not two paths to the same place: choosing &lt;strong&gt;ECS&lt;/strong&gt; for full control means you also build and operate your own serving layer — you don't end up at EAS, because EAS &lt;em&gt;is&lt;/em&gt; the managed-control-tradeoff you just opted out of. Choosing &lt;strong&gt;PAI&lt;/strong&gt; means DSW and DLC both feed into EAS as the natural next stage of the same managed lifecycle.&lt;/p&gt;




&lt;h2&gt;
  
  
  09 — Load Testing, Failure, and the Fix
&lt;/h2&gt;

&lt;p&gt;This is where the lab moved from "deployed" to "actually load-tested."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure:&lt;/strong&gt; first load test at 50 concurrent requests against the EAS endpoint above — p99 latency jumped from 180ms to 4.2 seconds, and roughly 8% of requests started timing out. The service was configured with 2 fixed instances and no autoscaling policy: a static-capacity endpoint hit with variable load, the same class of mistake that causes AKS/ACK pod-pressure incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; enabled PAI-EAS autoscaling on a queue-depth-based metric rather than a naive CPU threshold — inference workloads are latency-sensitive per-request, so scaling on queue depth reacts faster than waiting for aggregate CPU to climb.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&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;"instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"min_instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max_instance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"scaling_metric"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"qps"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"scaling_target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-run at the same 50-concurrent load: p99 dropped to 310ms, zero timeouts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Configuration&lt;/th&gt;
&lt;th&gt;p99 Latency&lt;/th&gt;
&lt;th&gt;Timeout Rate&lt;/th&gt;
&lt;th&gt;Monthly Cost (2-instance baseline)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed 2 instances&lt;/td&gt;
&lt;td&gt;4.2s&lt;/td&gt;
&lt;td&gt;8%&lt;/td&gt;
&lt;td&gt;~$180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaled 2–8 instances&lt;/td&gt;
&lt;td&gt;310ms&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;~$240 (avg. 3.1 instances)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 33% cost increase bought a 13x latency improvement and eliminated failed requests entirely.&lt;/p&gt;




&lt;h2&gt;
  
  
  10 — Cost: The Drivers, Not Just the Numbers
&lt;/h2&gt;

&lt;p&gt;A single monthly total hides the decision that actually matters. AI infrastructure cost breaks down as:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU compute + CPU compute + storage + network + training duration + inference utilization&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The insight that changes how you evaluate this: &lt;strong&gt;a GPU that costs 2x more per hour isn't 2x more expensive for the workload if it completes training in a third of the time.&lt;/strong&gt; Compare cost &lt;em&gt;per finished job&lt;/em&gt;, not cost &lt;em&gt;per hour of rental&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Three cost lenses worth tracking separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost per training run&lt;/strong&gt; — total GPU-hours × instance price for one complete training job, start to finish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per inference request&lt;/strong&gt; — (instance cost / time period) ÷ (requests served in that period) — this is the number that should drive the fixed-vs-autoscaled decision in Section 09.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per deployed model&lt;/strong&gt; — the standing cost of keeping an endpoint warm and available, independent of how much traffic it's currently serving.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Benchmark methodology&lt;/strong&gt; — never compare GPUs purely by advertised TFLOPS; measure the workload you actually care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tokens/sec or requests/sec (throughput)&lt;/li&gt;
&lt;li&gt;p50 / p95 / p99 latency&lt;/li&gt;
&lt;li&gt;GPU utilization and memory utilization&lt;/li&gt;
&lt;li&gt;Cost per request at the concurrency level you actually expect in production&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  11 — Production Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         Internet
                            │
                    ┌───────────────┐
                    │ Load Balancer │
                    └───────────────┘
                            │
                 ┌─────────────────────┐
                 │       PAI-EAS       │
                 │ Model Serving Layer │
                 └─────────────────────┘
                      │           │
                    GPU-1       GPU-2
                  (serving)   (serving)
                      │           │
                      └─────┬─────┘
                            │ loads current model version from
                            ▼
                   ┌────────────────┐
                   │      OSS       │◄───────────────────────┐
                   │ Model Versions │                        │
                   └────────────────┘                        │
                                                             │ writes new version
                                                 ┌───────────┴───────────┐
                                                 │                       │
                                              PAI-DLC                 PAI-DSW
                                            Distributed             Development
                                             Training                / Testing

     (PAI-DLC / PAI-DSW are the offline training/dev pipeline — not part of the live request path above)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live serving path (top) and the offline training/dev pipeline (bottom) only meet at OSS — DLC/DSW write a new model version there; EAS reads the current version from there on its own schedule. A dev → staging → production promotion path mirrors the GitOps pattern from earlier in this series: a staging EAS endpoint validates a new version against real traffic shape before it's promoted to the production endpoint's resource group — never overwrite a live production model in place.&lt;/p&gt;




&lt;h2&gt;
  
  
  12 — Production Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Security&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RAM roles scoped per service, not a shared account credential.&lt;/li&gt;
&lt;li&gt;Security groups restricting the data tier (OSS access, EAS internal endpoints) to the app tier only.&lt;/li&gt;
&lt;li&gt;VPC isolation — the EAS endpoint is not internet-reachable directly, only through the authenticated ECS API layer.&lt;/li&gt;
&lt;li&gt;OSS bucket policies scoped to the specific service identity that needs read/write, not account-wide access.&lt;/li&gt;
&lt;li&gt;Secrets (API keys, model registry credentials) in KMS Secrets Manager, never in environment variables baked into an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Reliability&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-zone instance placement so a single zone failure doesn't take the whole endpoint down.&lt;/li&gt;
&lt;li&gt;Multiple model replicas behind EAS's load distribution, not a single instance of record.&lt;/li&gt;
&lt;li&gt;Health checks on the serving endpoint, separate from infrastructure-level health checks.&lt;/li&gt;
&lt;li&gt;A tested rollback path to the previous model version — the same discipline as the GitOps rollback pattern earlier in this series.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Observability&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPU utilization and memory utilization per instance.&lt;/li&gt;
&lt;li&gt;CPU/memory on the ECS API layer.&lt;/li&gt;
&lt;li&gt;Inference latency (p50/p95/p99), throughput, and error rate.&lt;/li&gt;
&lt;li&gt;Model-level performance drift — accuracy/quality metrics over time, not just infrastructure health.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Scalability — two different things people conflate:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scaling the GPU infrastructure&lt;/strong&gt; — adding more underlying compute capacity (more/larger GPU instances available to the resource group).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling model-serving replicas&lt;/strong&gt; — increasing how many instances of the &lt;em&gt;already-provisioned&lt;/em&gt; model are actively serving traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first is a capacity-planning decision; the second is what EAS's autoscaler does automatically within that capacity. Confusing the two is why a team can "add GPUs" and still see no latency improvement if the serving replica count wasn't the actual bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  13 — Final Takeaways
&lt;/h2&gt;

&lt;p&gt;This article set out to answer five questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What is the problem?&lt;/strong&gt; Moving an AI workload from single-GPU experiment to production inference without rebuilding the platform at each stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What are the components?&lt;/strong&gt; ECS → PAI → DSW/DLC/EAS → OSS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How do they work together?&lt;/strong&gt; ECS/GPU compute at the base; DSW, DLC, and EAS as managed layers consuming that compute for development, training, and serving respectively; OSS as the shared artifact store.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When should I choose each?&lt;/strong&gt; Section 07's comparison and Section 08's decision tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How would I run it in production?&lt;/strong&gt; Section 11's production architecture plus Section 12's security, reliability, and observability requirements.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;My recommendation:&lt;/strong&gt; use ECS directly when infrastructure control is the primary requirement — a custom stack, specialized networking, or an existing Kubernetes platform you want AI workloads inside. Use PAI when reducing ML-platform operational overhead matters more than owning every layer. For teams building real production AI systems, the two aren't a choice — combining ECS-backed compute with PAI's managed DSW/DLC/EAS lifecycle gives a working balance between infrastructure flexibility and reduced operational burden, which is the actual engineering tradeoff this whole article has been describing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/alibaba-cloud-ai-ecs-pai-lab" rel="noopener noreferrer"&gt;alibaba-cloud-ai-ecs-pai-lab&lt;/a&gt; — the full training-to-serving pipeline: fine-tune, deploy to PAI-EAS, and the authenticated API layer, ready to run.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against current Alibaba Cloud PAI documentation as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PAI · ECS · AI Infrastructure · Alibaba Cloud · Model Serving · GPU Sharing · Autoscaling&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/alibaba-cloud-ai-infrastructure-ecs-pai" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>alibabacloudengineeringlabseri</category>
      <category>aiinfrastructure</category>
      <category>pai</category>
      <category>ecs</category>
    </item>
    <item>
      <title>FinOps on Alibaba Cloud: Cost Visibility, Governance, and Optimization at Scale</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:28:18 +0000</pubDate>
      <link>https://dev.to/rdgmh/finops-on-alibaba-cloud-cost-visibility-governance-and-optimization-at-scale-4g2j</link>
      <guid>https://dev.to/rdgmh/finops-on-alibaba-cloud-cost-visibility-governance-and-optimization-at-scale-4g2j</guid>
      <description>&lt;h2&gt;
  
  
  Why FinOps Doesn't Change, Only the Tooling Does
&lt;/h2&gt;

&lt;p&gt;FinOps as a discipline — the FOCUS principles of visibility, accountability, and optimization applied continuously rather than as a quarterly cost review — is provider-agnostic. What changes moving from Azure to Alibaba Cloud is purely which console, API, and pricing construct you're operating: &lt;strong&gt;BSS OpenAPI&lt;/strong&gt; instead of Cost Management, &lt;strong&gt;Reserved Instances / Savings Plans&lt;/strong&gt; with Alibaba's own discount curve, and &lt;strong&gt;Cloud Monitor + Resource Manager&lt;/strong&gt; instead of Azure Monitor + Resource Graph.&lt;/p&gt;

&lt;p&gt;The strategic framework — allocate, analyze, optimize, automate — carries over completely. This guide walks through actually running that loop on a real Alibaba Cloud account: pulling a real BSS OpenAPI cost report, finding real idle-instance candidates via Cloud Monitor, and enforcing tagging with a policy that genuinely denies untagged spend rather than just recommending it. The scripts are in the &lt;a href="https://github.com/raphgm/finops-alibaba-cloud-lab" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, runnable as-is.&lt;/p&gt;

&lt;p&gt;Before the how, the what — three terms this guide leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;FinOps&lt;/strong&gt; — the practice of treating cloud cost as an ongoing engineering concern rather than a once-a-quarter finance review. The core loop is &lt;em&gt;allocate&lt;/em&gt; (know which team/project a cost belongs to), &lt;em&gt;analyze&lt;/em&gt; (find waste and inefficiency), &lt;em&gt;optimize&lt;/em&gt; (fix it), &lt;em&gt;automate&lt;/em&gt; (stop needing a human to repeat the first three steps every month).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reserved Instance / Savings Plan&lt;/strong&gt; — a discount you get by committing to a certain amount of compute usage for 1-3 years upfront, in exchange for a lower rate than pay-as-you-go pricing. A Reserved Instance commits to a specific instance type; a Savings Plan commits to a spend level with more flexibility in what you run — the tradeoff is discount depth (RI) versus flexibility (Savings Plan).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost allocation tags&lt;/strong&gt; — labels attached to a resource (like &lt;code&gt;cost-center: platform-eng&lt;/code&gt;) that let a billing system attribute spend to a specific team, project, or environment. Without consistent tagging, a cost report can tell you the total bill but not &lt;em&gt;whose&lt;/em&gt; spend it actually is — which is why untagged spend is effectively invisible spend, however large the bill is.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Cost Visibility: BSS OpenAPI and Cost Allocation Tags
&lt;/h2&gt;

&lt;p&gt;Alibaba Cloud's Billing and Settlement Service (BSS) is the programmatic equivalent of the Azure Cost Management API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun bssopenapi DescribeInstanceBill &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--BillingCycle&lt;/span&gt; 2026-09 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ProductCode&lt;/span&gt; ecs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--Granularity&lt;/span&gt; MONTHLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost allocation depends entirely on tagging discipline — exactly as it does in Azure. Enforce mandatory tags (&lt;code&gt;cost-center&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;) at resource creation via Resource Manager policies, not as a retroactive cleanup exercise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_resource_manager_policy"&lt;/span&gt; &lt;span class="s2"&gt;"require_tags"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;policy_name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"require-cost-center-tag"&lt;/span&gt;
  &lt;span class="nx"&gt;policy_document&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Deny"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs:RunInstances"&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;StringNotEqualsIfExists&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s2"&gt;"aliyun:CostCenter"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Untagged spend is invisible spend — no allocation model can retroactively assign cost to a resource nobody tagged, which is the single most common reason a FinOps practice stalls at the "visibility" stage on any cloud.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Commitment Discounts: RI and Savings Plans
&lt;/h2&gt;

&lt;p&gt;Alibaba Cloud offers both &lt;strong&gt;Reserved Instances&lt;/strong&gt; (capacity-and-instance-type specific, similar to Azure RIs) and &lt;strong&gt;Savings Plans&lt;/strong&gt; (compute-family-flexible commitment, similar to Azure's Compute Savings Plans). The decision framework is identical to the one used on Azure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stable, predictable baseline workloads&lt;/strong&gt; (the floor of your usage that never scales down) → Reserved Instances, 1- or 3-year term, for the deepest discount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workloads that shift shape&lt;/strong&gt; (instance family/size changes over time) → Savings Plans, for commitment flexibility at a slightly lower discount than a matched RI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bursty, interruption-tolerant workloads&lt;/strong&gt; → Spot Instances, layered on top of the committed baseline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A mature commitment strategy typically lands around &lt;strong&gt;60–70% of baseline compute on RIs/Savings Plans&lt;/strong&gt;, leaving headroom for on-demand and Spot to absorb variability — over-committing past your true floor turns a cost-optimization tool into locked-in waste the moment a workload gets decommissioned.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Right-Sizing: Finding the Idle and the Oversized
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun cms DescribeMetricList &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--Namespace&lt;/span&gt; acs_ecs_dashboard &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--MetricName&lt;/span&gt; CPUUtilization &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--Period&lt;/span&gt; 86400 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--StartTime&lt;/span&gt; &lt;span class="s2"&gt;"2026-08-01 00:00:00"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--EndTime&lt;/span&gt; &lt;span class="s2"&gt;"2026-09-01 00:00:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull 30 days of CPU/memory utilization per instance via Cloud Monitor, then flag anything sitting under ~15–20% average utilization as a right-sizing candidate — the exact same threshold-based triage used against Azure Advisor's underutilized VM recommendations. Automate the query and route results into a weekly report; a right-sizing exercise that requires someone to remember to run it manually decays within a quarter.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Automated Waste Elimination
&lt;/h2&gt;

&lt;p&gt;The highest-leverage FinOps automation isn't dashboarding — it's automatically reclaiming known categories of waste:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unattached OSS-backed disks&lt;/strong&gt; past a grace period (mirrors orphaned Azure Managed Disks after VM deletion).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle SLB instances&lt;/strong&gt; with zero backend targets for 14+ days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unused Elastic IPs&lt;/strong&gt; billed whether attached or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-production environments left running outside business hours&lt;/strong&gt; — schedule ECS start/stop via Cloud Assistant or a scheduled Function Compute job, the equivalent of Azure Automation start/stop schedules.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Example: nightly stop of all dev-tagged instances outside business hours&lt;/span&gt;
aliyun ecs DescribeInstances &lt;span class="nt"&gt;--Tag&lt;/span&gt;.1.Key environment &lt;span class="nt"&gt;--Tag&lt;/span&gt;.1.Value dev &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.Instances.Instance[].InstanceId'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; aliyun ecs StopInstance &lt;span class="nt"&gt;--InstanceId&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Governance: Budgets and Anomaly Alerts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun bssopenapi CreateCostUnit &lt;span class="nt"&gt;--UnitName&lt;/span&gt; &lt;span class="s2"&gt;"platform-engineering"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair Resource Manager cost units (Alibaba's equivalent of Azure Management Groups for cost rollup) with budget alerts at 50/80/100% thresholds per business unit, and a day-over-day anomaly alert on total spend — the same layered-alert pattern that catches both slow budget creep and a sudden runaway resource (a misconfigured autoscaler, a forgotten Spot fleet) before it becomes a five-figure surprise at month-end.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The Reporting Cadence
&lt;/h2&gt;

&lt;p&gt;A FinOps practice that only produces a report nobody reads is theater, not discipline. The cadence that actually changes behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly&lt;/strong&gt; — automated right-sizing and idle-resource report to platform engineering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly&lt;/strong&gt; — cost-per-business-unit review against budget, presented to engineering leads, not just finance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarterly&lt;/strong&gt; — commitment discount coverage review (RI/Savings Plan renewal, coverage ratio vs. actual usage).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;Nothing about FinOps fundamentally changes crossing from Azure to Alibaba Cloud — the same allocate → analyze → optimize → automate loop, the same tagging discipline, the same commitment-discount tradeoffs. What this exercise demonstrates is that cloud economics expertise is a &lt;strong&gt;transferable engineering discipline&lt;/strong&gt;, not a certification tied to one provider's cost dashboard — which is precisely the kind of cross-cloud fluency that distinguishes a FinOps practitioner from someone who's simply memorized one vendor's pricing page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/finops-alibaba-cloud-lab" rel="noopener noreferrer"&gt;finops-alibaba-cloud-lab&lt;/a&gt; — the cost-visibility, idle-detection, tag-enforcement, and automation scripts, ready to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FinOps · Alibaba Cloud · Cost Optimization · BSS OpenAPI · Reserved Instances · Governance&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/finops-on-alibaba-cloud" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>alibabacloud</category>
      <category>costoptimization</category>
      <category>governance</category>
    </item>
    <item>
      <title>Kubernetes on ACK: Cloud-Native Operations and Troubleshooting on Alibaba Cloud</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:27:44 +0000</pubDate>
      <link>https://dev.to/rdgmh/kubernetes-on-ack-cloud-native-operations-and-troubleshooting-on-alibaba-cloud-4id9</link>
      <guid>https://dev.to/rdgmh/kubernetes-on-ack-cloud-native-operations-and-troubleshooting-on-alibaba-cloud-4id9</guid>
      <description>&lt;h2&gt;
  
  
  ACK in the Managed Kubernetes Landscape
&lt;/h2&gt;

&lt;p&gt;Container Service for Kubernetes (&lt;strong&gt;ACK&lt;/strong&gt;) is Alibaba Cloud's managed Kubernetes offering — its counterpart to AKS on Azure and EKS on AWS. For anyone who has operated AKS or EKS, ACK's control-plane abstraction, node pool model, and CNCF-conformant API surface will feel immediately familiar. The differences that matter are in the tooling around the cluster: identity (RAM instead of Entra ID/IAM), networking (Terway CNI), and the console/CLI ecosystem (&lt;code&gt;aliyun&lt;/code&gt; CLI, &lt;code&gt;kubectl&lt;/code&gt; with the ACK credential plugin).&lt;/p&gt;

&lt;p&gt;I provisioned a production-shaped ACK cluster end to end, ran a real load test against it, and deliberately let the autoscaler fall behind so the troubleshooting section below is the actual diagnostic path I walked, not a narrated version of one. This guide covers provisioning that cluster, configuring autoscaling, exposing services, and — the part most tutorials skip — the actual diagnostic workflow for troubleshooting a cluster under real incident pressure. The Terraform and the full diagnostic script are in the &lt;a href="https://github.com/raphgm/ack-kubernetes-operations-lab" rel="noopener noreferrer"&gt;companion repo&lt;/a&gt;, not just quoted here.&lt;/p&gt;

&lt;p&gt;Before the how, the what — three terms this guide leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Managed Kubernetes&lt;/strong&gt; — a Kubernetes cluster where the cloud provider runs and patches the control plane (the API server, scheduler, etcd) for you, so you only manage the worker nodes and what runs on them. ACK, AKS, and EKS are all this same model with different branding — the "managed" part is specifically what you're paying for and what removes a large category of operational burden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node pool&lt;/strong&gt; — a group of worker machines in the cluster sharing the same instance type and configuration. A cluster usually has more than one node pool (e.g. a standard pool for steady workloads, a Spot pool for interruption-tolerant ones), each scaling and pricing independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CNI (Container Network Interface)&lt;/strong&gt; — the plugin that actually wires up pod networking — assigning IP addresses to pods and routing traffic between them. Terway is Alibaba Cloud's CNI (roughly analogous to Azure CNI); it's worth knowing by name because CNI-specific limits (like ENI count per node) are a real, easy-to-miss source of "why won't this pod schedule" incidents.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  1. Provisioning an ACK Cluster (Terraform)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Control plane only — worker nodes are managed as a separate node pool&lt;/span&gt;
&lt;span class="c1"&gt;# below. (worker_instance_types/worker_number/worker_disk_category were&lt;/span&gt;
&lt;span class="c1"&gt;# removed from this resource in provider 1.212+; alicloud_cs_kubernetes&lt;/span&gt;
&lt;span class="c1"&gt;# is the dedicated-master variant and requires master_instance_types/&lt;/span&gt;
&lt;span class="c1"&gt;# master_vswitch_ids, so ack.pro.small's managed control plane uses&lt;/span&gt;
&lt;span class="c1"&gt;# alicloud_cs_managed_kubernetes instead.)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_cs_managed_kubernetes"&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ack-prod-cluster"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_spec&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ack.pro.small"&lt;/span&gt; &lt;span class="c1"&gt;# Professional (managed control plane) tier&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_ids&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;new_nat_gateway&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;service_cidr&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"172.20.0.0/16"&lt;/span&gt;
  &lt;span class="nx"&gt;pod_cidr&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"172.21.0.0/16"&lt;/span&gt;

  &lt;span class="nx"&gt;addons&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terway-eniip"&lt;/span&gt; &lt;span class="c1"&gt;# native VPC-routed CNI, comparable to Azure CNI&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;addons&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csi-plugin"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;addons&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"csi-provisioner"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_cs_kubernetes_node_pool"&lt;/span&gt; &lt;span class="s2"&gt;"prod_workers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_cs_managed_kubernetes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;node_pool_name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"prod-workers"&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_ids&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;instance_types&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ecs.g6.xlarge"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;desired_size&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;system_disk_category&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cloud_essd"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ack.pro.small&lt;/code&gt; provisions a &lt;strong&gt;managed, SLA-backed control plane&lt;/strong&gt; — the equivalent of choosing an AKS cluster with the Uptime SLA add-on rather than the free-tier control plane. For production workloads, this isn't optional.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Connecting kubectl
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aliyun cs GET /k8s/&lt;span class="nv"&gt;$CLUSTER_ID&lt;/span&gt;/user_config &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type=application/json"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; kubeconfig.json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./kubeconfig.json
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For CI/CD pipelines, generate a RAM-scoped kubeconfig rather than reusing an individual engineer's credentials — the same principle as scoping an AKS pipeline to a dedicated service principal rather than a personal Entra ID account.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Node Pool Autoscaling
&lt;/h2&gt;

&lt;p&gt;ACK's autoscaler (built on the same &lt;code&gt;cluster-autoscaler&lt;/code&gt; upstream project used by AKS/EKS) scales node pools based on unschedulable pod pressure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"alicloud_cs_kubernetes_node_pool"&lt;/span&gt; &lt;span class="s2"&gt;"spot_pool"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;alicloud_cs_managed_kubernetes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;node_pool_name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"spot-worker-pool"&lt;/span&gt;
  &lt;span class="nx"&gt;vswitch_ids&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;alicloud_vswitch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;instance_types&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ecs.g6.xlarge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ecs.g6.2xlarge"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;spot_strategy&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SpotAsPriceGo"&lt;/span&gt;
  &lt;span class="nx"&gt;desired_size&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

  &lt;span class="nx"&gt;scaling_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;max_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mixing a Spot-priced node pool alongside an on-demand baseline pool is the same FinOps pattern used on AKS spot node pools — schedule stateless, interruption-tolerant workloads (batch jobs, CI runners, stateless API replicas) onto the Spot pool via taints and tolerations, keep stateful/critical workloads on the guaranteed on-demand pool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spot-instance"&lt;/span&gt;
    &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Equal"&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NoSchedule"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  4. Exposing Services: SLB Ingress
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&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;api-service&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;service.beta.kubernetes.io/alicloud-loadbalancer-spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slb.s2.small"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LoadBalancer&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8443&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;LoadBalancer&lt;/code&gt; service type on ACK provisions a real SLB automatically, the same pattern as an AKS &lt;code&gt;LoadBalancer&lt;/code&gt; service provisioning an Azure Load Balancer — cloud-controller-manager does the provider-specific plumbing so your manifests stay portable.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The Troubleshooting Workflow
&lt;/h2&gt;

&lt;p&gt;This is the part that separates "I deployed a cluster" from "I operate a cluster." When something breaks in production, the diagnostic path is the same discipline regardless of which managed Kubernetes you're on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Is it the workload or the platform?&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;kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--field-selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;status.phase!&lt;span class="o"&gt;=&lt;/span&gt;Running
kubectl describe pod &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt;
kubectl logs &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; &lt;span class="nt"&gt;--previous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Is it resource pressure?&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;kubectl top nodes
kubectl top pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;memory
kubectl get events &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;--sort-by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'.lastTimestamp'&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pod stuck in &lt;code&gt;Pending&lt;/code&gt; with an event reading &lt;code&gt;0/3 nodes are available: insufficient cpu&lt;/code&gt; means the autoscaler hasn't caught up yet, or the node pool's &lt;code&gt;max_size&lt;/code&gt; cap has been hit — check the autoscaler's own logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cluster-autoscaler &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — Is it networking?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Terway CNI issues typically surface as pods stuck in &lt;code&gt;ContainerCreating&lt;/code&gt; with ENI allocation errors. Check ENI quota against the ECS instance type — smaller instance types support fewer attached ENIs, which caps pod density per node regardless of CPU/memory headroom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &amp;lt;namespace&amp;gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A5&lt;/span&gt; Events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4 — Is it the control plane or an addon?&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;kubectl get componentstatuses
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Professional (managed) tier, control-plane health is largely Alibaba's responsibility — but addon health (CSI plugin, Terway, ack-virtual-node) is not, and addon failures present identically to control-plane failures from the workload's perspective.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Observability
&lt;/h2&gt;

&lt;p&gt;Wire ACK into the same three pillars you'd wire AKS into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt; — Prometheus via the ARMS (Application Real-Time Monitoring Service) managed integration, or self-hosted &lt;code&gt;kube-prometheus-stack&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logs&lt;/strong&gt; — Log Service (SLS) DaemonSet collector, Alibaba's equivalent of Azure Monitor Container Insights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces&lt;/strong&gt; — OpenTelemetry Collector, provider-agnostic by design.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;Kubernetes' portability promise mostly holds at the workload layer — manifests, Helm charts, and operators translate almost unchanged between AKS, EKS, and ACK. What doesn't translate automatically is the &lt;strong&gt;operational muscle memory&lt;/strong&gt;: knowing which &lt;code&gt;kubectl describe&lt;/code&gt; output to check first, understanding your CNI's specific failure modes, and knowing where the managed control plane's responsibility ends and yours begins.&lt;/p&gt;

&lt;p&gt;That operational fluency — not the YAML — is what actually gets tested in a production incident, on any cloud.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/ack-kubernetes-operations-lab" rel="noopener noreferrer"&gt;ack-kubernetes-operations-lab&lt;/a&gt; — the ACK cluster Terraform and the complete diagnostic workflow script, ready to run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes · ACK · Alibaba Cloud · Cluster Autoscaler · Terway CNI · Troubleshooting&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/kubernetes-on-ack" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>ack</category>
      <category>alibabacloud</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Zero Trust Network Architecture in Azure: Firewalls, Private Link &amp; NSGs</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:26:34 +0000</pubDate>
      <link>https://dev.to/rdgmh/zero-trust-network-architecture-in-azure-firewalls-private-link-nsgs-325k</link>
      <guid>https://dev.to/rdgmh/zero-trust-network-architecture-in-azure-firewalls-private-link-nsgs-325k</guid>
      <description>&lt;h2&gt;
  
  
  Introduction to Zero Trust Networking
&lt;/h2&gt;

&lt;p&gt;The traditional castle-and-moat security model — where anything inside the corporate network is implicitly trusted — is obsolete. Once an attacker (or a misconfigured workload) is inside a flat network, there is nothing stopping lateral movement between resources.&lt;/p&gt;

&lt;p&gt;Zero Trust replaces that assumption with three operating principles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify explicitly&lt;/strong&gt; — authenticate and authorize based on all available signals, not network location.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use least-privilege access&lt;/strong&gt; — scope access to what a workload actually needs, nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume breach&lt;/strong&gt; — design so that a single compromised resource cannot reach everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A production-ready Azure network design should incorporate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Segmented Hub-and-Spoke topology&lt;/li&gt;
&lt;li&gt;Forced tunneling through a central firewall&lt;/li&gt;
&lt;li&gt;Deny-by-default network security groups&lt;/li&gt;
&lt;li&gt;Private connectivity to PaaS services&lt;/li&gt;
&lt;li&gt;Private DNS resolution&lt;/li&gt;
&lt;li&gt;Identity-based access instead of network-based trust&lt;/li&gt;
&lt;li&gt;Centralized logging and traffic inspection&lt;/li&gt;
&lt;li&gt;Automated validation of the above&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to make the network impenetrable — no network is — but to ensure that &lt;strong&gt;a single compromised subnet or credential does not automatically expose everything else&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Before the how, the what — three terms this guide leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network Security Group (NSG)&lt;/strong&gt; — a set of allow/deny rules attached to a subnet or network interface, filtering traffic by source, destination, port, and protocol. Think of it as a basic firewall placed at each network boundary — deny-by-default, then explicitly allow only the traffic a workload actually needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VNet peering&lt;/strong&gt; — a connection between two Virtual Networks that lets resources in each reach the other privately, without traffic crossing the public internet. In a Hub-and-Spoke design, spokes peer &lt;em&gt;only&lt;/em&gt; with the hub, deliberately — that's what forces all cross-spoke traffic through the hub's central firewall instead of spokes talking to each other directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private Link&lt;/strong&gt; — a way to reach an Azure PaaS service (a database, a storage account) over a private IP address inside your own VNet, instead of the service's normal public endpoint. Without it, "private" data still has to traverse a public endpoint to reach it — Private Link removes that public exposure entirely.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  1. Hub-and-Spoke VNet Topology
&lt;/h1&gt;

&lt;p&gt;The foundation of a segmented Azure network is the Hub-and-Spoke topology.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hub VNet&lt;/strong&gt; hosts centralized security appliances — Azure Firewall, VPN/ExpressRoute gateways, Azure Bastion — and acts as the single point of connectivity to on-premises networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spoke VNets&lt;/strong&gt; host the actual application and data workloads. They peer with the Hub but &lt;em&gt;never&lt;/em&gt; peer directly with each other.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                      On-Premises / Internet
                              |
                              v
                    +-------------------+
                    |      Hub VNet      |
                    |    10.0.0.0/16     |
                    |  Azure Firewall    |
                    +----+----------+----+
                         |          |
                 peering |          | peering
                         v          v
            +-------------------+  +-------------------+
            |   Spoke VNet A     |  |   Spoke VNet B     |
            |   10.1.0.0/16      |  |   10.2.0.0/16      |
            |   App Tier         |  |   Data Tier        |
            +-------------------+  +-------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because spokes never peer with each other, all inter-spoke ("east-west") traffic is forced through the Hub, giving you a single choke point to inspect and filter every flow.&lt;/p&gt;

&lt;p&gt;Do not assume peering alone enforces this. VNet peering only establishes reachability — the routing and firewall rules described in the next section are what actually force traffic through the Hub.&lt;/p&gt;




&lt;h1&gt;
  
  
  2. Forced Tunneling with User Defined Routes
&lt;/h1&gt;

&lt;p&gt;By default, a VM's outbound traffic goes straight to the internet. To force it through the Hub firewall instead, attach a &lt;strong&gt;User Defined Route (UDR)&lt;/strong&gt; to every spoke subnet that sends a default route (&lt;code&gt;0.0.0.0/0&lt;/code&gt;) to the firewall's private IP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Spoke Subnet (10.1.1.0/24)
         |
         v
  Route Table: 0.0.0.0/0 -&amp;gt; Azure Firewall private IP
         |
         v
  Azure Firewall (Hub VNet, AzureFirewallSubnet)
         |
    Application Rules (allowed FQDNs only)
         |
         v
      Internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is often called &lt;strong&gt;forced tunneling&lt;/strong&gt;. Without a route table attached to the subnet, workloads bypass the firewall entirely regardless of how well the firewall itself is configured.&lt;/p&gt;




&lt;h1&gt;
  
  
  3. Centralized Egress via Azure Firewall
&lt;/h1&gt;

&lt;p&gt;Once traffic is forced through the Hub, Azure Firewall becomes the enforcement point for outbound access.&lt;/p&gt;

&lt;p&gt;Configure &lt;strong&gt;Application Rules&lt;/strong&gt; to allow outbound HTTPS traffic only to the fully qualified domain names (FQDNs) a workload actually needs — for example &lt;code&gt;api.github.com&lt;/code&gt; or &lt;code&gt;mcr.microsoft.com&lt;/code&gt; — and let everything else be dropped by the default deny.&lt;/p&gt;

&lt;p&gt;Do not treat the firewall as a set-and-forget appliance. FQDN allow-lists need to be reviewed whenever an application takes on a new external dependency, or legitimate traffic will start failing instead of being logged as a security event.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/firewall/overview" rel="noopener noreferrer"&gt;Azure Firewall overview&lt;/a&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  4. Micro-Segmentation with NSGs and Application Security Groups
&lt;/h1&gt;

&lt;p&gt;Network Security Groups (NSGs) act as virtual firewalls on subnets and network interfaces. A resilient NSG design follows two rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deny all by default&lt;/strong&gt; — the lowest-priority rule should deny all inbound and outbound traffic, so anything not explicitly allowed is blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group by role, not by IP&lt;/strong&gt; — Application Security Groups (ASGs) let you write rules against roles like &lt;code&gt;Web-ASG&lt;/code&gt; or &lt;code&gt;DB-ASG&lt;/code&gt; instead of specific IP addresses that change as resources scale.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow-Web-To-DB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&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;"priority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"direction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Inbound"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"protocol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sourcePortRange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;"destinationPortRange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1433"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sourceApplicationSecurityGroups"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".../applicationSecurityGroups/Web-ASG"&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;"destinationApplicationSecurityGroups"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".../applicationSecurityGroups/DB-ASG"&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="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule above only permits SQL traffic (port 1433) from members of &lt;code&gt;Web-ASG&lt;/code&gt; to members of &lt;code&gt;DB-ASG&lt;/code&gt;. Everything else hitting the data-tier subnet is caught by the deny-all rule at the bottom of the NSG.&lt;/p&gt;




&lt;h1&gt;
  
  
  5. Securing PaaS Services with Azure Private Link
&lt;/h1&gt;

&lt;p&gt;By default, Azure PaaS services — Storage Accounts, Azure SQL, Key Vault — are reachable over the public internet, protected only by identity and firewall rules on the service itself. &lt;strong&gt;Azure Private Link&lt;/strong&gt; removes the public attack surface entirely by projecting the service into your VNet with a private IP address.&lt;/p&gt;

&lt;p&gt;The configuration flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disable public network access on the PaaS resource (Azure SQL, Key Vault, etc.).&lt;/li&gt;
&lt;li&gt;Create a Private Endpoint for that resource inside a dedicated subnet in the data-tier spoke.&lt;/li&gt;
&lt;li&gt;Point a Private DNS Zone (for example &lt;code&gt;privatelink.database.windows.net&lt;/code&gt; or &lt;code&gt;privatelink.vaultcore.azure.net&lt;/code&gt;) at the private IP, so existing connection strings resolve privately without any application code changes.&lt;/li&gt;
&lt;li&gt;Workloads in peered spokes can now reach the service without traffic ever leaving the Microsoft backbone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not skip step 3. Disabling public access and creating the Private Endpoint without the matching Private DNS Zone is a common failure mode — the resource is technically private, but clients still try to resolve the public hostname and connections fail.&lt;/p&gt;




&lt;h1&gt;
  
  
  6. Identity as the Real Perimeter
&lt;/h1&gt;

&lt;p&gt;Network segmentation limits &lt;em&gt;where&lt;/em&gt; traffic can go. It does not decide &lt;em&gt;who&lt;/em&gt; is allowed to send it. Zero Trust's "verify explicitly" principle means access decisions should be anchored in identity, not network location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    |
    v
Managed Identity
    |
    v
Microsoft Entra ID
    |
    v
Azure SQL / Key Vault (RBAC-scoped)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;strong&gt;managed identities&lt;/strong&gt; for workload-to-PaaS authentication instead of connection strings or shared keys, and scope Microsoft Entra role assignments to the minimum required — a VM being on the "trusted" subnet should never be treated as sufficient authorization on its own.&lt;/p&gt;




&lt;h1&gt;
  
  
  7. Validate the Architecture, Don't Assume It
&lt;/h1&gt;

&lt;p&gt;A Zero Trust network is only as good as its last verified state. Configuration drift — a route table detached during troubleshooting, a public-access flag re-enabled by a script — silently reopens the perimeter you built.&lt;/p&gt;

&lt;p&gt;Automate two checks continuously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DNS resolution&lt;/strong&gt; — confirm PaaS hostnames still resolve to private (RFC 1918) addresses, not public ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port reachability&lt;/strong&gt; — confirm only the ports permitted by your NSG rules are actually reachable from a given subnet.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resolve hostname
      |
      v
  Private IP? --- No --&amp;gt; ALERT: public exposure
      |
     Yes
      |
      v
Test allowed ports
      |
      v
  Only expected ports open? --- No --&amp;gt; ALERT: NSG drift
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this validation after every deployment, not just once at build time — drift happens after go-live, not during it.&lt;/p&gt;




&lt;h1&gt;
  
  
  8. Observability
&lt;/h1&gt;

&lt;p&gt;A segmented network still needs centralized visibility. Route the following into Azure Monitor / Log Analytics:&lt;/p&gt;

&lt;h3&gt;
  
  
  Network Logs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;NSG flow logs (allowed/denied flows per rule)&lt;/li&gt;
&lt;li&gt;Azure Firewall logs (application, network, and DNS rule hits)&lt;/li&gt;
&lt;li&gt;Private DNS Zone query logs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Identity Logs
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Entra sign-in logs&lt;/li&gt;
&lt;li&gt;Managed identity token requests&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Infrastructure Metrics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Firewall throughput and rule processing latency&lt;/li&gt;
&lt;li&gt;Private Endpoint connection state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful operational summary might look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+------------------------------------------------+
|           ZERO TRUST NETWORK HEALTH             |
+------------------------------------------------+
| Firewall Denies/min | NSG Denies/min | Drift    |
|         14          |        3       |  None    |
+------------------------------------------------+
| Private Endpoints    | Public Exposure          |
|    HEALTHY (4/4)     |    None Detected         |
+------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  9. Recommended Production Architecture
&lt;/h1&gt;

&lt;p&gt;Putting the pieces together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                         Internet / On-Prem
                                |
                                v
                       +------------------+
                       |    Hub VNet      |
                       |   10.0.0.0/16    |
                       |  Azure Firewall  |
                       +--------+---------+
                                |
                        Forced Tunneling (UDR)
                                |
                 +--------------+--------------+
                 |                             |
                 v                             v
        +-----------------+           +-----------------+
        |  Spoke A: App    |           |  Spoke B: Data   |
        |  10.1.0.0/16     |           |  10.2.0.0/16     |
        |  NSG + ASGs      |           |  Private Endpoints|
        +-----------------+           +--------+----------+
                                                |
                                        Private DNS Zones
                                                |
                                                v
                                     Azure SQL / Key Vault

        Identity     -&amp;gt; Microsoft Entra ID + Managed Identities
        Monitoring   -&amp;gt; Azure Monitor / NSG Flow Logs / Firewall Logs
        Validation   -&amp;gt; Automated DNS + port-reachability checks
        IaC          -&amp;gt; Bicep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  Operational Checklist
&lt;/h1&gt;

&lt;p&gt;Before considering a Zero Trust network deployment complete, verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Spoke VNets peer only with the Hub, never with each other.&lt;/li&gt;
&lt;li&gt;[ ] Every spoke subnet has a route table forcing &lt;code&gt;0.0.0.0/0&lt;/code&gt; to the firewall.&lt;/li&gt;
&lt;li&gt;[ ] Azure Firewall Application Rules allow only required FQDNs.&lt;/li&gt;
&lt;li&gt;[ ] Every NSG ends with an explicit deny-all rule.&lt;/li&gt;
&lt;li&gt;[ ] NSG rules reference Application Security Groups, not hardcoded IPs, where possible.&lt;/li&gt;
&lt;li&gt;[ ] Public network access is disabled on every PaaS resource that has a Private Endpoint.&lt;/li&gt;
&lt;li&gt;[ ] Matching Private DNS Zones are linked to every VNet that needs to resolve a private endpoint.&lt;/li&gt;
&lt;li&gt;[ ] Workload-to-PaaS authentication uses managed identities, not shared keys.&lt;/li&gt;
&lt;li&gt;[ ] DNS resolution and port-reachability are validated automatically after every deployment.&lt;/li&gt;
&lt;li&gt;[ ] NSG flow logs and Firewall logs are flowing into Azure Monitor.&lt;/li&gt;
&lt;li&gt;[ ] IaC (Bicep) is the source of truth — no manual portal changes to routing or NSGs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Final Takeaway
&lt;/h1&gt;

&lt;p&gt;Zero Trust networking in Azure is not a single toggle switch — it is a layered defense strategy where each layer assumes the others might fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Segmentation → forced tunneling → firewall enforcement → micro-segmentation → private connectivity → identity → continuous validation → observability.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The strongest architecture assumes a subnet or credential &lt;em&gt;will&lt;/em&gt; eventually be compromised, and is designed so that compromise stays contained.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero Trust = Segmentation + Identity + Private Connectivity + Continuous Validation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Zero Trust · Hub-and-Spoke · Azure Firewall · NSG · Private Link · Bicep&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/zero-trust-azure-networking" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>zerotrust</category>
      <category>azurefirewall</category>
      <category>security</category>
      <category>vnet</category>
    </item>
    <item>
      <title>FinOps in Practice: Stop Wasting Money on Idle Azure Resources</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:26:00 +0000</pubDate>
      <link>https://dev.to/rdgmh/finops-in-practice-stop-wasting-money-on-idle-azure-resources-5lj</link>
      <guid>https://dev.to/rdgmh/finops-in-practice-stop-wasting-money-on-idle-azure-resources-5lj</guid>
      <description>&lt;h2&gt;
  
  
  Introduction: Where Azure Cost Waste Hides
&lt;/h2&gt;

&lt;p&gt;Cloud waste rarely comes from one obviously expensive resource. It often accumulates from dozens or hundreds of small decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disks left behind after VM deletion&lt;/li&gt;
&lt;li&gt;Unused public IP addresses&lt;/li&gt;
&lt;li&gt;Stopped or underutilized VMs&lt;/li&gt;
&lt;li&gt;Oversized Azure SQL databases&lt;/li&gt;
&lt;li&gt;Old snapshots&lt;/li&gt;
&lt;li&gt;Unused storage&lt;/li&gt;
&lt;li&gt;Overprovisioned Kubernetes nodes&lt;/li&gt;
&lt;li&gt;Excessive log retention&lt;/li&gt;
&lt;li&gt;Non-production resources running 24/7&lt;/li&gt;
&lt;li&gt;Resources without owners or cost-center tags&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal of FinOps is not simply to &lt;strong&gt;"spend less."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The goal is to ensure that every cloud resource has a business purpose and that the organization gets measurable value from its cloud investment.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/governance/resource-graph/overview" rel="noopener noreferrer"&gt;Azure Resource Graph overview&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A good FinOps workflow looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Discover
   ↓
Measure
   ↓
Analyze
   ↓
Optimize
   ↓
Automate
   ↓
Monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the how, the what — three tools this guide relies on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Azure Resource Graph&lt;/strong&gt; — a query service that lets you search across &lt;em&gt;every&lt;/em&gt; resource in a subscription (or many subscriptions) at once, using a SQL-like query language (KQL). Where the Azure Portal shows you resources one blade at a time, Resource Graph answers questions like "show me every unattached disk across the whole tenant" in a single query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Policy&lt;/strong&gt; — a service that enforces rules about what's allowed to exist in a subscription, either by blocking non-compliant resources at creation or by continuously auditing existing ones against a rule. It's the mechanism that turns "we should tag everything" from a wish into something actually checked automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Advisor&lt;/strong&gt; — a built-in recommendation engine that analyzes your actual usage patterns and suggests specific actions (resize this VM, delete that idle resource) based on Microsoft's own telemetry and best-practice rules, rather than requiring you to write the analysis query yourself.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  1. Finding Orphaned Managed Disks
&lt;/h1&gt;

&lt;p&gt;One of the easiest places to begin is with unattached Azure Managed Disks.&lt;/p&gt;

&lt;p&gt;When a VM is deleted, its disks may remain depending on the deletion configuration and lifecycle policies.&lt;/p&gt;

&lt;p&gt;These disks can continue generating storage charges even though they are no longer serving an active workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Azure Resource Graph Query
&lt;/h2&gt;

&lt;p&gt;Run the following KQL query in &lt;strong&gt;Azure Resource Graph Explorer&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources
| where type =~ "microsoft.compute/disks"
| extend diskState = tostring(properties.diskState)
| where diskState =~ "Unattached"
| project
    name,
    resourceGroup,
    subscriptionId,
    location,
    diskState,
    sku = tostring(sku.name),
    diskSizeGB = toint(properties.diskSizeGB),
    resourceId = id
| order by diskSizeGB desc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a useful inventory of potentially orphaned disks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Important
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Do not automatically delete every unattached disk.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An unattached disk may still be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A backup disk&lt;/li&gt;
&lt;li&gt;A disaster-recovery resource&lt;/li&gt;
&lt;li&gt;A migration artifact&lt;/li&gt;
&lt;li&gt;A staging resource&lt;/li&gt;
&lt;li&gt;A manually managed data disk&lt;/li&gt;
&lt;li&gt;Required for an upcoming deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use an approval workflow before deletion.&lt;/p&gt;

&lt;p&gt;A safer process is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unattached Disk
      ↓
Identify Owner
      ↓
Check Tags
      ↓
Check Activity / Age
      ↓
Confirm Business Purpose
      ↓
Approval
      ↓
Delete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  2. Finding Unattached Public IP Addresses
&lt;/h1&gt;

&lt;p&gt;Public IP addresses can also become orphaned when resources are deleted.&lt;/p&gt;

&lt;p&gt;Use Azure Resource Graph to identify Public IP resources that do not have an active IP configuration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources
| where type =~ "microsoft.network/publicipaddresses"
| extend ipConfig = tostring(properties.ipConfiguration.id)
| where isempty(ipConfig)
| project
    name,
    resourceGroup,
    subscriptionId,
    location,
    sku = tostring(sku.name),
    resourceId = id
| order by name asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Again, treat the result as a &lt;strong&gt;candidate cleanup list&lt;/strong&gt;, not an automatic deletion list.&lt;/p&gt;

&lt;p&gt;A public IP may have a legitimate purpose even if it is temporarily unattached.&lt;/p&gt;




&lt;h1&gt;
  
  
  3. Finding Unattached Network Interfaces
&lt;/h1&gt;

&lt;p&gt;Network interfaces can also remain after VM deletion.&lt;/p&gt;

&lt;p&gt;A useful Resource Graph query is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources
| where type =~ "microsoft.network/networkinterfaces"
| extend vmId = tostring(properties.virtualMachine.id)
| where isempty(vmId)
| project
    name,
    resourceGroup,
    subscriptionId,
    location,
    resourceId = id
| order by name asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This helps identify NICs that are no longer associated with a VM.&lt;/p&gt;

&lt;p&gt;Before deleting them, check whether they are being referenced by another architecture component or reserved for future deployment.&lt;/p&gt;




&lt;h1&gt;
  
  
  4. Find Old Snapshots
&lt;/h1&gt;

&lt;p&gt;Snapshots can accumulate over time, particularly in environments with frequent testing, migration, or backup activities.&lt;/p&gt;

&lt;p&gt;Use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources
| where type =~ "microsoft.compute/snapshots"
| project
    name,
    resourceGroup,
    subscriptionId,
    location,
    diskSizeGB = toint(properties.diskSizeGB),
    timeCreated = todatetime(properties.timeCreated),
    resourceId = id
| order by timeCreated asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can then identify snapshots that exceed your organization's retention period.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Snapshot age &amp;gt; 90 days
        ↓
Check retention policy
        ↓
Check owner
        ↓
Check backup requirements
        ↓
Delete if approved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  5. Identify Underutilized Virtual Machines
&lt;/h1&gt;

&lt;p&gt;Deleting unused resources is only one part of FinOps.&lt;/p&gt;

&lt;p&gt;A potentially larger saving opportunity is &lt;strong&gt;right-sizing active resources&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For VMs, examine metrics such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU utilization&lt;/li&gt;
&lt;li&gt;Memory utilization&lt;/li&gt;
&lt;li&gt;Network throughput&lt;/li&gt;
&lt;li&gt;Disk I/O&lt;/li&gt;
&lt;li&gt;Application performance&lt;/li&gt;
&lt;li&gt;Peak utilization&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VM Size: Standard_D8s_v5

Average CPU: 8%
Peak CPU:    22%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the workload consistently operates far below its allocated capacity, investigate whether the VM can be downsized.&lt;/p&gt;

&lt;p&gt;However, do not right-size based solely on average CPU.&lt;/p&gt;

&lt;p&gt;Consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Peak traffic&lt;/li&gt;
&lt;li&gt;Memory requirements&lt;/li&gt;
&lt;li&gt;Application latency&lt;/li&gt;
&lt;li&gt;Disk performance&lt;/li&gt;
&lt;li&gt;Network requirements&lt;/li&gt;
&lt;li&gt;Business-critical workloads&lt;/li&gt;
&lt;li&gt;Seasonal demand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A better approach is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure → Analyze → Test → Right-size → Monitor&lt;/strong&gt;&lt;/p&gt;




&lt;h1&gt;
  
  
  6. Azure SQL Right-Sizing
&lt;/h1&gt;

&lt;p&gt;Azure SQL is another important FinOps optimization area.&lt;/p&gt;

&lt;p&gt;Organizations frequently provision more compute than the workload actually requires.&lt;/p&gt;

&lt;p&gt;Review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU utilization&lt;/li&gt;
&lt;li&gt;Data I/O&lt;/li&gt;
&lt;li&gt;Log I/O&lt;/li&gt;
&lt;li&gt;Storage utilization&lt;/li&gt;
&lt;li&gt;Query performance&lt;/li&gt;
&lt;li&gt;DTU/vCore utilization&lt;/li&gt;
&lt;li&gt;Connection counts&lt;/li&gt;
&lt;li&gt;Workload patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Analyze at least several weeks of production data where possible, rather than making decisions from a single day's metrics.&lt;/p&gt;




&lt;h1&gt;
  
  
  7. Azure SQL Serverless
&lt;/h1&gt;

&lt;p&gt;For workloads with intermittent or unpredictable usage, &lt;strong&gt;Azure SQL Database serverless compute&lt;/strong&gt; can be an option.&lt;/p&gt;

&lt;p&gt;Serverless can dynamically scale compute based on workload and can automatically pause compute during periods of inactivity when configured and supported by the workload.&lt;/p&gt;

&lt;p&gt;This can be particularly useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development environments&lt;/li&gt;
&lt;li&gt;Test environments&lt;/li&gt;
&lt;li&gt;Low-volume applications&lt;/li&gt;
&lt;li&gt;Intermittent workloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;However, serverless is not automatically cheaper for every database.&lt;/p&gt;

&lt;p&gt;Evaluate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Workload pattern&lt;/li&gt;
&lt;li&gt;Auto-pause requirements&lt;/li&gt;
&lt;li&gt;Resume latency&lt;/li&gt;
&lt;li&gt;Minimum compute&lt;/li&gt;
&lt;li&gt;Storage requirements&lt;/li&gt;
&lt;li&gt;Application behaviour&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FinOps means choosing the &lt;strong&gt;right pricing model for the workload&lt;/strong&gt;, not simply choosing the feature with the lowest advertised price.&lt;/p&gt;




&lt;h1&gt;
  
  
  8. Azure SQL Elastic Pools
&lt;/h1&gt;

&lt;p&gt;If an organization operates many Azure SQL databases with inconsistent utilization, &lt;strong&gt;Elastic Pools&lt;/strong&gt; can allow databases to share a pool of compute resources.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:

Database A → Dedicated Compute
Database B → Dedicated Compute
Database C → Dedicated Compute
Database D → Dedicated Compute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Potential alternative:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;After:

          Azure SQL Elastic Pool
        +------------------------+
        |                        |
        | DB A  DB B  DB C  DB D |
        |                        |
        +------------------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can improve resource utilization when database workloads have different usage patterns.&lt;/p&gt;

&lt;p&gt;Always validate workload characteristics before migrating.&lt;/p&gt;




&lt;h1&gt;
  
  
  9. Azure Advisor and Cost Management
&lt;/h1&gt;

&lt;p&gt;Do not rely exclusively on custom Resource Graph queries.&lt;/p&gt;

&lt;p&gt;Use Azure's native FinOps capabilities as well.&lt;/p&gt;

&lt;p&gt;Important services include:&lt;/p&gt;

&lt;h3&gt;
  
  
  Azure Cost Management
&lt;/h3&gt;

&lt;p&gt;Use it to analyze:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Actual spend&lt;/li&gt;
&lt;li&gt;Forecasted spend&lt;/li&gt;
&lt;li&gt;Cost by subscription&lt;/li&gt;
&lt;li&gt;Cost by resource group&lt;/li&gt;
&lt;li&gt;Cost by service&lt;/li&gt;
&lt;li&gt;Cost by tag&lt;/li&gt;
&lt;li&gt;Cost trends&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Azure Advisor
&lt;/h3&gt;

&lt;p&gt;Use recommendations related to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Right-sizing&lt;/li&gt;
&lt;li&gt;Idle resources&lt;/li&gt;
&lt;li&gt;Reserved capacity&lt;/li&gt;
&lt;li&gt;Savings opportunities&lt;/li&gt;
&lt;li&gt;Reliability&lt;/li&gt;
&lt;li&gt;Security&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The combination provides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Azure Cost Management
        +
Azure Advisor
        +
Azure Resource Graph
        +
Azure Monitor
        ↓
FinOps Visibility
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  10. Enforce Resource Tagging
&lt;/h1&gt;

&lt;p&gt;FinOps becomes significantly harder when resources do not have clear ownership.&lt;/p&gt;

&lt;p&gt;Establish a mandatory tagging strategy.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Environment = Production
Owner       = Platform-Team
CostCenter  = Engineering
Application = Customer-API
Project     = SaaS-Platform
ManagedBy   = Terraform
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;strong&gt;Azure Policy&lt;/strong&gt; to audit or enforce required tags.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resource Created
       ↓
Azure Policy
       ↓
Required Tags?
    /         YES        NO
   |          |
 Allow     Deny/Audit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be careful with strict &lt;code&gt;Deny&lt;/code&gt; policies in production. Introduce tagging policies gradually and validate their impact before enforcement.&lt;/p&gt;




&lt;h1&gt;
  
  
  11. Automate Dev/Test Shutdown
&lt;/h1&gt;

&lt;p&gt;Non-production environments are often left running overnight.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer VM
      |
      +---- Monday–Friday
      |       08:00–19:00
      |
      +---- Weekend
              OFF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For eligible workloads, implement automated schedules using Azure-native automation capabilities.&lt;/p&gt;

&lt;p&gt;Possible approaches include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure Automation&lt;/li&gt;
&lt;li&gt;Azure Functions&lt;/li&gt;
&lt;li&gt;Logic Apps&lt;/li&gt;
&lt;li&gt;VM auto-shutdown capabilities&lt;/li&gt;
&lt;li&gt;Scheduled automation workflows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact implementation should account for VM dependencies and development workflows.&lt;/p&gt;




&lt;h1&gt;
  
  
  12. Detect Resources Without Owners
&lt;/h1&gt;

&lt;p&gt;A powerful FinOps governance strategy is to identify resources that cannot be attributed to a team or application.&lt;/p&gt;

&lt;p&gt;Example Resource Graph query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Resources
| extend owner = tostring(tags["Owner"])
| extend environment = tostring(tags["Environment"])
| extend costCenter = tostring(tags["CostCenter"])
| where isempty(owner)
    or isempty(environment)
    or isempty(costCenter)
| project
    name,
    type,
    resourceGroup,
    subscriptionId,
    location,
    owner,
    environment,
    costCenter,
    resourceId = id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a governance backlog.&lt;/p&gt;

&lt;p&gt;Instead of immediately deleting these resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Missing Metadata
      ↓
Identify Owner
      ↓
Assign Cost Center
      ↓
Classify Workload
      ↓
Optimize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h1&gt;
  
  
  13. Build an Automated FinOps Workflow
&lt;/h1&gt;

&lt;p&gt;A mature FinOps process should move beyond manually running queries.&lt;/p&gt;

&lt;p&gt;A practical workflow is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Azure Resources
      |
      v
Azure Resource Graph
      |
      v
Identify Candidates
      |
      v
Azure Monitor / Cost Management
      |
      v
Validate Utilization
      |
      v
FinOps Rules
      |
      v
Approval
      |
      +--------+
      |        |
      v        v
 Optimize   Delete
      |        |
      +--------+
           |
           v
     Cost Reduction
           |
           v
       Reporting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Automation can be implemented using:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Azure Functions&lt;/li&gt;
&lt;li&gt;Azure Automation&lt;/li&gt;
&lt;li&gt;Logic Apps&lt;/li&gt;
&lt;li&gt;GitHub Actions&lt;/li&gt;
&lt;li&gt;Azure DevOps&lt;/li&gt;
&lt;li&gt;PowerShell&lt;/li&gt;
&lt;li&gt;Python&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For destructive actions, use an &lt;strong&gt;approval or quarantine stage&lt;/strong&gt; rather than immediately deleting resources.&lt;/p&gt;




&lt;h1&gt;
  
  
  14. Create a Resource Quarantine Strategy
&lt;/h1&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Detect → Delete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Detect
   ↓
Tag as Candidate
   ↓
Notify Owner
   ↓
Grace Period
   ↓
Final Validation
   ↓
Approval
   ↓
Delete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, a candidate resource could receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FinOpsStatus = CleanupCandidate
CleanupAfter = 2026-04-01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives owners an opportunity to recover resources that were incorrectly identified.&lt;/p&gt;




&lt;h1&gt;
  
  
  15. Measure FinOps Success
&lt;/h1&gt;

&lt;p&gt;FinOps should produce measurable business outcomes.&lt;/p&gt;

&lt;p&gt;Track metrics such as:&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloud Waste
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Potential Waste / Total Cloud Spend
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Optimization Savings
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Baseline Cost - Optimized Cost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Resource Ownership
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tagged Resources / Total Resources × 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Idle Resource Reduction
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Idle Resources Before
        ↓
Idle Resources After
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also measure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monthly cloud spend&lt;/li&gt;
&lt;li&gt;Cost per application&lt;/li&gt;
&lt;li&gt;Cost per customer&lt;/li&gt;
&lt;li&gt;Cost per transaction&lt;/li&gt;
&lt;li&gt;Cost per environment&lt;/li&gt;
&lt;li&gt;Forecast accuracy&lt;/li&gt;
&lt;li&gt;Optimization savings&lt;/li&gt;
&lt;li&gt;Commitment utilization&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Operational Checklist
&lt;/h1&gt;

&lt;p&gt;Before implementing automated cleanup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Enable Azure Cost Management reporting.&lt;/li&gt;
&lt;li&gt;[ ] Review Azure Advisor recommendations.&lt;/li&gt;
&lt;li&gt;[ ] Inventory resources with Azure Resource Graph.&lt;/li&gt;
&lt;li&gt;[ ] Identify unattached managed disks.&lt;/li&gt;
&lt;li&gt;[ ] Identify unattached NICs.&lt;/li&gt;
&lt;li&gt;[ ] Identify unattached public IPs.&lt;/li&gt;
&lt;li&gt;[ ] Review old snapshots.&lt;/li&gt;
&lt;li&gt;[ ] Identify underutilized VMs.&lt;/li&gt;
&lt;li&gt;[ ] Review Azure SQL utilization.&lt;/li&gt;
&lt;li&gt;[ ] Evaluate serverless workloads.&lt;/li&gt;
&lt;li&gt;[ ] Evaluate SQL Elastic Pools.&lt;/li&gt;
&lt;li&gt;[ ] Establish mandatory tagging.&lt;/li&gt;
&lt;li&gt;[ ] Configure Azure Policy.&lt;/li&gt;
&lt;li&gt;[ ] Implement dev/test shutdown schedules.&lt;/li&gt;
&lt;li&gt;[ ] Establish resource ownership.&lt;/li&gt;
&lt;li&gt;[ ] Create a cleanup-candidate workflow.&lt;/li&gt;
&lt;li&gt;[ ] Require approval before destructive actions.&lt;/li&gt;
&lt;li&gt;[ ] Monitor savings after optimization.&lt;/li&gt;
&lt;li&gt;[ ] Report FinOps KPIs to stakeholders.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Final Takeaway
&lt;/h1&gt;

&lt;p&gt;FinOps is not a one-time cleanup exercise.&lt;/p&gt;

&lt;p&gt;It is a continuous engineering discipline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discover → Measure → Optimize → Govern → Automate → Monitor&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The objective is not to make Azure as cheap as possible.&lt;/p&gt;

&lt;p&gt;The objective is to make Azure &lt;strong&gt;efficient, predictable, accountable, and aligned with business value&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A mature Azure FinOps practice combines:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure Cost Management + Azure Advisor + Azure Resource Graph + Azure Monitor + Azure Policy + Automation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That combination turns cloud cost management from a monthly finance exercise into an &lt;strong&gt;automated engineering practice&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FinOps · Azure Cost Optimization · Resource Graph · Azure Policy · Governance · Cloud Economics&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/finops-azure-idle-resource-cleanup" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>finops</category>
      <category>governance</category>
      <category>resourcegraph</category>
      <category>azurepolicy</category>
    </item>
    <item>
      <title>Supabase vs. MongoDB vs. Firebase: A Real App, Three Backends</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:25:26 +0000</pubDate>
      <link>https://dev.to/rdgmh/supabase-vs-mongodb-vs-firebase-a-real-app-three-backends-4d1d</link>
      <guid>https://dev.to/rdgmh/supabase-vs-mongodb-vs-firebase-a-real-app-three-backends-4d1d</guid>
      <description>&lt;h2&gt;
  
  
  Comparing Marketing Pages Is Not Comparing Databases
&lt;/h2&gt;

&lt;p&gt;"Supabase vs. Firebase vs. MongoDB" comparisons are common and almost all of them compare feature lists, not running systems. This one builds the same small app — a notes tool with signup, create, and list — against all three, actually runs each one, and reports exactly what happened, including the parts that didn't go smoothly. The app is deliberately minimal so the comparison isolates what each backend does differently, not what the app happens to need.&lt;/p&gt;

&lt;p&gt;Before Step 1, three terms this comparison leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row Level Security (RLS)&lt;/strong&gt; — Postgres's own mechanism (which Supabase builds directly on top of) for enforcing per-row access control &lt;em&gt;inside the database itself&lt;/em&gt;. A policy like "users can only see rows where &lt;code&gt;user_id&lt;/code&gt; matches their own ID" is enforced by Postgres on every query, regardless of what client or API hits it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firestore Security Rules&lt;/strong&gt; — a separate, non-SQL rules language that Firestore evaluates on every request, independent of the database engine underneath. Functionally similar goal to RLS, but a different language, different deployment mechanism, and — as this article found — a different operational model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BaaS (Backend as a Service)&lt;/strong&gt; — a category both Supabase and Firebase belong to: a hosted platform that provides authentication, a database, and an auto-generated or managed API, so an app team writes little or no backend code. MongoDB, by contrast, is just a database — everything above the data layer (auth, API, authorization) has to be built separately.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — The app, minimal on purpose
&lt;/h2&gt;

&lt;p&gt;One route to create a note, one to list them, gated behind authentication:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* create a note, owned by req.userId */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* list notes, scoped to req.userId */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this specific feature set:&lt;/strong&gt; create-and-list-scoped-to-a-user exercises exactly the place these three backends differ most — authentication, and per-row authorization. A feature set any smaller wouldn't touch authorization at all; anything larger would start comparing app complexity instead of backend complexity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Supabase: self-hosted, zero backend code
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase init
supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second command pulled and started twelve containers — Postgres, GoTrue (auth), PostgREST (auto-generated REST API), Realtime, Kong (API gateway), Studio, and supporting services — on a single &lt;code&gt;Standard_B2s&lt;/code&gt; Azure VM (3.8 GB RAM). All twelve came up healthy.&lt;/p&gt;

&lt;p&gt;The entire "backend" for this app is one SQL migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can manage their own notes"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No server, no route handlers, no auth middleware. PostgREST inspects this table and its RLS policy and immediately exposes a real, working REST API for it. Verified end to end against the real running stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:54321/auth/v1/signup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"test@notescompare.dev","password":"testpassword123"}'&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; real JWT returned&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:54321/rest/v1/notes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Prefer: return=representation"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"title":"First real note","body":"via PostgREST","user_id":"'&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s1"&gt;'"}'&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; [{"id":"4cdcbee7-...","user_id":"ee5a831c-...","title":"First real note", ...}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters more than "Supabase has a nice dashboard":&lt;/strong&gt; the RLS policy is enforced by Postgres itself, on every request, through an API that required writing zero lines of server code. The auth token, the row-scoping, and the REST endpoint all came from a fourteen-line SQL file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — MongoDB: same feature, real hand-written server required
&lt;/h2&gt;

&lt;p&gt;MongoDB has no auto-generated API and no built-in per-document authorization. Everything Supabase gave for free had to be written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Missing token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;insertedId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expiresIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1h&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against a real self-hosted &lt;code&gt;mongo:8&lt;/code&gt; container on the same VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3001/auth/signup &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"test3@mongocompare.dev","password":"testpassword123"}'&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; {"access_token":"eyJhbGciOi..."}&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST localhost:3001/notes &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"title":"First mongo note","body":"via Express"}'&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; {"userId":"6aab8be9...","title":"First mongo note", ...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works, and it works well — MongoDB's own document model and query API are genuinely pleasant. But the honest comparison is the line count: Supabase's backend is fourteen lines of SQL; MongoDB's equivalent required a real password-hashing scheme, a JWT signing/verification layer, and manually filtering every query by &lt;code&gt;userId&lt;/code&gt; in application code — and that authorization logic has to be correctly repeated on every single query by hand, with nothing at the database layer enforcing it if a future route forgets to filter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Firebase: a real rejection, and a real tooling wall
&lt;/h2&gt;

&lt;p&gt;Firebase Auth worked immediately — signup against the real Identity Toolkit REST API returned a genuine ID token with no setup beyond a project's public API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=&lt;/span&gt;&lt;span class="nv"&gt;$API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"email":"test@notescompare.dev","password":"testpassword123","returnSecureToken":true}'&lt;/span&gt;
&lt;span class="c"&gt;# -&amp;gt; real idToken returned&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing to Firestore with that token was correctly rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&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;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Missing or insufficient permissions."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PERMISSION_DENIED"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rejection is Firestore's security rules doing exactly their job — the project's existing production rules didn't recognize a brand-new collection this test tried to write to, and Firestore defaults to denying anything a rule doesn't explicitly allow. The correct fix is deploying an updated rule scoping that collection to its owner, the same shape as the Supabase RLS policy above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;match /notes_compare_lab/{noteId} {
  allow read, delete: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == resource.data.userId;
  allow create: if request.auth != null &amp;amp;&amp;amp; request.auth.uid == request.resource.data.userId;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploying that rule non-interactively hit a real wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Request to https://firebaserules.googleapis.com/v1/projects/eguari:test had HTTP Error: 403,
The caller does not have permission
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Firebase CLI's stored login session lacked the scope needed to test and deploy security rules, and re-authenticating with broader scope requires an interactive browser login (&lt;code&gt;firebase login --reauth&lt;/code&gt;), which explicitly refuses to run in a non-interactive session: &lt;code&gt;"Cannot run login in non-interactive mode."&lt;/code&gt; A fresh brand-new Firebase project hit an even earlier version of the same wall — &lt;code&gt;serviceusage.services.use&lt;/code&gt; permission denied, minutes after the project's own creation, on the account that had just created it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is the actual finding, not a workaround-and-move-on:&lt;/strong&gt; Supabase and MongoDB were both fully reproducible from a script, self-hosted, with no dependency on an interactive human session. Firebase's tooling, at least for rules deployment and fresh-project setup, assumes a human is present to click through a browser at least once. That's a real operational difference between the three — not a knock on Firestore's security model itself, which behaved correctly by rejecting an unauthorized write.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 — What actually differs, stated plainly
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Supabase (self-hosted)&lt;/th&gt;
&lt;th&gt;MongoDB (self-hosted)&lt;/th&gt;
&lt;th&gt;Firebase&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend code needed for this app&lt;/td&gt;
&lt;td&gt;None (SQL only)&lt;/td&gt;
&lt;td&gt;~70 lines (auth + API)&lt;/td&gt;
&lt;td&gt;None (client SDK only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization enforcement&lt;/td&gt;
&lt;td&gt;Postgres RLS, database-level&lt;/td&gt;
&lt;td&gt;Application code, must be repeated per query&lt;/td&gt;
&lt;td&gt;Firestore Security Rules, database-level&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hostable / portable&lt;/td&gt;
&lt;td&gt;Yes — full stack via &lt;code&gt;supabase start&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes — official Docker image&lt;/td&gt;
&lt;td&gt;No — hosted only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-interactive automation&lt;/td&gt;
&lt;td&gt;Fully scriptable&lt;/td&gt;
&lt;td&gt;Fully scriptable&lt;/td&gt;
&lt;td&gt;Assumes an interactive human session for rules/project setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query model&lt;/td&gt;
&lt;td&gt;SQL (Postgres)&lt;/td&gt;
&lt;td&gt;Document (BSON)&lt;/td&gt;
&lt;td&gt;Document (protobuf-backed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realtime&lt;/td&gt;
&lt;td&gt;Built in (Postgres logical replication)&lt;/td&gt;
&lt;td&gt;Requires building it yourself (change streams)&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The honest answer to "which is better" is that it depends on which row of that table matters most to a given team. If self-hosting and full automation matter — CI pipelines, ephemeral test environments, air-gapped deployments — Supabase and MongoDB are both genuinely reproducible from a script; Firebase, at least as tested here, is not. If minimizing backend code matters most, Supabase's RLS-enforced auto-API and Firebase's client-SDK model both beat MongoDB's "write it yourself" reality — but only Supabase's version of that convenience turned out to be scriptable end to end in this test. None of these conclusions came from reading documentation; they came from actually running all three and writing down exactly what happened, rejection messages included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/notes-compare-lab" rel="noopener noreferrer"&gt;notes-compare-lab&lt;/a&gt; — all three implementations, the exact commands run, and the real error messages this article quotes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against Supabase CLI, MongoDB 8, and the Firebase CLI as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase · MongoDB · Firebase · PostgreSQL · Firestore · Backend as a Service&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/supabase-vs-mongodb-vs-firebase-real-comparison" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>mongodb</category>
      <category>firebase</category>
      <category>backendasaservice</category>
    </item>
    <item>
      <title>Docker Compose for Real Local Dev Parity</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:24:51 +0000</pubDate>
      <link>https://dev.to/rdgmh/docker-compose-for-real-local-dev-parity-p9o</link>
      <guid>https://dev.to/rdgmh/docker-compose-for-real-local-dev-parity-p9o</guid>
      <description>&lt;h2&gt;
  
  
  The Compose File That Looks Right on Paper
&lt;/h2&gt;

&lt;p&gt;Most Docker Compose tutorials show an app service and a database service, wire them together with &lt;code&gt;depends_on&lt;/code&gt;, and call it done. The gap between that and a Compose setup that actually mirrors production is usually invisible until the app starts before the database is ready to accept connections, or a "restart" quietly wipes data nobody meant to lose.&lt;/p&gt;

&lt;p&gt;This article builds a real two-service app — Express talking to Postgres, with an endpoint that actually writes and reads rows — and proves two specific things that a Compose file &lt;em&gt;looks&lt;/em&gt; like it does but doesn't automatically do: that the app genuinely waits for the database to be ready, and that data survives a full teardown, not just a container restart.&lt;/p&gt;

&lt;p&gt;Before Step 1, one term this walkthrough leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt;&lt;/strong&gt; — plain &lt;code&gt;depends_on: [db]&lt;/code&gt; only waits for the database &lt;em&gt;container to start&lt;/em&gt;, not for Postgres inside it to actually be accepting connections — those are different moments, often seconds apart. &lt;code&gt;condition: service_healthy&lt;/code&gt; makes Compose wait for the dependency's own &lt;code&gt;healthcheck&lt;/code&gt; to pass first, which is the actual guarantee an app needs before its first query.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — An app with a real reason to need a database
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/visits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CREATE TABLE IF NOT EXISTS visits (id SERIAL PRIMARY KEY, seen_at TIMESTAMPTZ DEFAULT now())&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO visits DEFAULT VALUES&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT COUNT(*) FROM visits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;visits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why a visit counter and not a health check stub:&lt;/strong&gt; the whole point of this article is proving data persistence, which requires a route that actually writes something meaningful to Postgres and a way to observe that value changing and then surviving. A counter that increments on every request is the smallest thing that does both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — The Compose file, with the two guarantees made explicit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;DB_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
      &lt;span class="na"&gt;DB_PORT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5432&lt;/span&gt;
      &lt;span class="na"&gt;DB_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appuser&lt;/span&gt;
      &lt;span class="na"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-devpassword}&lt;/span&gt;
      &lt;span class="na"&gt;DB_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose_lab&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17-alpine&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appuser&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${DB_PASSWORD:-devpassword}&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;compose_lab&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db-data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;appuser&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;compose_lab"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;DB_HOST: db&lt;/code&gt; works at all:&lt;/strong&gt; Compose creates a private network for the whole file automatically, and every service can reach every other service by its service name as a hostname — &lt;code&gt;app&lt;/code&gt; connects to &lt;code&gt;db&lt;/code&gt;, not to &lt;code&gt;localhost&lt;/code&gt; or a hardcoded IP, because Compose's built-in DNS resolves &lt;code&gt;db&lt;/code&gt; to whichever container is currently running that service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the healthcheck runs &lt;code&gt;pg_isready&lt;/code&gt;, not just a TCP check:&lt;/strong&gt; Postgres's own port can accept a TCP connection slightly before the database has finished its startup sequence and is ready to run real queries. &lt;code&gt;pg_isready&lt;/code&gt; is Postgres's own readiness probe — the same signal a production orchestrator would use — so &lt;code&gt;condition: service_healthy&lt;/code&gt; is waiting on the same guarantee a real deployment would.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the volume is named, not a host bind mount:&lt;/strong&gt; &lt;code&gt;db-data:/var/lib/postgresql/data&lt;/code&gt; creates a Docker-managed named volume, independent of any specific container. When the &lt;code&gt;db&lt;/code&gt; container is removed and recreated, Compose reattaches the same volume — the data was never inside the container's own writable layer to begin with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Bringing it up for real (no local Docker, so this ran on a real Azure VM)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container compose-app-db-1 Starting
 Container compose-app-db-1 Started
 Container compose-app-db-1 Waiting
 Container compose-app-db-1 Healthy
 Container compose-app-app-1 Starting
 Container compose-app-app-1 Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;Waiting&lt;/code&gt; → &lt;code&gt;Healthy&lt;/code&gt; step, appearing between the database starting and the app starting, is &lt;code&gt;condition: service_healthy&lt;/code&gt; doing its actual job — visible in the log, not just configured and hoped for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/visits
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/visits
curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/visits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&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="nl"&gt;"visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&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="nl"&gt;"visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4 — Proving persistence means tearing everything down, not restarting
&lt;/h2&gt;

&lt;p&gt;A container &lt;em&gt;restart&lt;/em&gt; is a weak test — the same container, same writable layer, same everything, just re-executed. Real proof means removing the containers entirely and recreating them from the Compose file, which is what &lt;code&gt;docker compose down&lt;/code&gt; followed by &lt;code&gt;docker compose up&lt;/code&gt; actually does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose down
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; Container compose-app-app-1 Removed
 Container compose-app-db-1 Removed
 Network compose-app_default Removed
 Network compose-app_default Created
 Container compose-app-db-1 Created
 Container compose-app-app-1 Created
 Container compose-app-db-1 Healthy
 Container compose-app-app-1 Started
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3000/visits
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"visits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both containers were fully removed and rebuilt from scratch — new container IDs, new network — and the counter picked up at 5, exactly where it left off, instead of resetting to 1. That's the named volume doing its job: the containers are disposable, the data isn't, and this is the one moment this article actually proves that distinction rather than describing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;A Compose file with &lt;code&gt;depends_on&lt;/code&gt; and a &lt;code&gt;volumes:&lt;/code&gt; block &lt;em&gt;looks&lt;/em&gt; like it guarantees startup ordering and data persistence — most of the syntax exists specifically for that purpose. Whether it actually delivers on either guarantee is a different question, answered by watching the &lt;code&gt;Waiting&lt;/code&gt; → &lt;code&gt;Healthy&lt;/code&gt; transition happen in real output, and by tearing the whole stack down and confirming the data comes back rather than assuming a restart proved anything. Local dev parity with production isn't the YAML syntax — it's whether the failure modes production cares about (a service starting before its dependency is ready, a container recreation losing state) are things you've actually watched not happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/docker-multistage-lab" rel="noopener noreferrer"&gt;docker-multistage-lab&lt;/a&gt; — the &lt;code&gt;compose-app/&lt;/code&gt; directory has the full Express + Postgres app, the Compose file, and a &lt;code&gt;.env.example&lt;/code&gt; for the database password.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against Docker Compose v5.5.1 as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker · Docker Compose · PostgreSQL · Local Development · Health Checks&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/docker-compose-local-dev-parity" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>dockercompose</category>
      <category>postgres</category>
      <category>localdevelopment</category>
    </item>
    <item>
      <title>Rootless Containers: Converting a Real Image and Fixing What Breaks</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:24:18 +0000</pubDate>
      <link>https://dev.to/rdgmh/rootless-containers-converting-a-real-image-and-fixing-what-breaks-23ai</link>
      <guid>https://dev.to/rdgmh/rootless-containers-converting-a-real-image-and-fixing-what-breaks-23ai</guid>
      <description>&lt;h2&gt;
  
  
  Why Most Images Still Run as Root
&lt;/h2&gt;

&lt;p&gt;Docker Bench for Security, CIS benchmarks, and every container security checklist say the same thing: don't run your container process as root. Almost every tutorial Dockerfile does it anyway, because the fix looks like adding one line — &lt;code&gt;USER node&lt;/code&gt; — and stopping there works for the simplest possible app. It doesn't work the moment the app touches the filesystem, and most articles about rootless containers never show you that failure, because they never tried to reproduce it.&lt;/p&gt;

&lt;p&gt;This article takes the multi-stage app from the first article in this series, adds one small feature that writes a file at startup, converts it to run as a non-root user, and lets it actually crash — with the real error, not a description of one — before showing the fix.&lt;/p&gt;

&lt;p&gt;Before Step 1, one term this walkthrough leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;node&lt;/code&gt; user&lt;/strong&gt; — the official &lt;code&gt;node&lt;/code&gt; Docker images ship a pre-created, non-root user literally named &lt;code&gt;node&lt;/code&gt; (UID 1000), specifically so you don't have to create your own with &lt;code&gt;useradd&lt;/code&gt;. &lt;code&gt;USER node&lt;/code&gt; in a Dockerfile switches every subsequent instruction, and the final container process, to run as that user instead of root — but it does nothing about file ownership for anything already copied into the image before that line runs.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1 — Giving the app a reason to fail
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATA_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STARTUP_LOG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DATA_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;started.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/healthz&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DATA_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;STARTUP_LOG&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`started at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`listening on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this one change matters for the article:&lt;/strong&gt; the previous two articles in this series used an app that never wrote to the filesystem at runtime, which is exactly why a naive &lt;code&gt;USER&lt;/code&gt; line would have worked fine on them — and would have taught nothing. Real applications write logs, cache files, or uploaded content somewhere. &lt;code&gt;fs.mkdirSync&lt;/code&gt; on startup is the smallest possible version of that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — The naive conversion
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this looks correct and isn't:&lt;/strong&gt; every &lt;code&gt;COPY&lt;/code&gt; and &lt;code&gt;RUN&lt;/code&gt; instruction before &lt;code&gt;USER node&lt;/code&gt; executes as root by default — that's Docker's own default, not something this Dockerfile opted into. &lt;code&gt;WORKDIR /app&lt;/code&gt; creates &lt;code&gt;/app&lt;/code&gt; owned by root. &lt;code&gt;COPY --from=build /app/dist ./dist&lt;/code&gt; copies files in as root. The &lt;code&gt;USER node&lt;/code&gt; line switches who runs the &lt;em&gt;container process&lt;/em&gt;, but it has no effect on files and directories that already exist with root ownership from the layers built before it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — Running it for real and reading the actual crash
&lt;/h2&gt;

&lt;p&gt;No local Docker daemon was available for this either, so the built image was run on Azure Container Instances instead — pull it, run it, read the logs, same idea as running it locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az acr build &lt;span class="nt"&gt;--registry&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt; rootless-lab:broken &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.rootless-broken &lt;span class="nb"&gt;.&lt;/span&gt;

&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az acr login &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt; &lt;span class="nt"&gt;--expose-token&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; tsv &lt;span class="nt"&gt;--query&lt;/span&gt; accessToken&lt;span class="si"&gt;)&lt;/span&gt;
az container create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rootless-broken-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt;.azurecr.io/rootless-lab:broken &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registry-login-server&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt;.azurecr.io &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registry-username&lt;/span&gt; 00000000-0000-0000-0000-000000000000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registry-password&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cpu&lt;/span&gt; 1 &lt;span class="nt"&gt;--memory&lt;/span&gt; 1 &lt;span class="nt"&gt;--restart-policy&lt;/span&gt; Never
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az container logs &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rootless-broken-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node:fs:1370
  const result = binding.mkdir(
                         ^

Error: EACCES: permission denied, mkdir '/app/data'
    at Object.mkdirSync (node:fs:1370:26)
    at Server.&amp;lt;anonymous&amp;gt; (file:///app/dist/server.js:12:8)
    ...
  errno: -13,
  code: 'EACCES',
  syscall: 'mkdir',
  path: '/app/data'
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container group's own state confirmed it: &lt;code&gt;"state": "Failed"&lt;/code&gt;. This is the exact failure mode that makes teams either give up on rootless containers or — worse — quietly add &lt;code&gt;USER root&lt;/code&gt; back in a "temporary" fix that never gets reverted. Neither is necessary once the actual cause is visible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — The real fix: own the directory before switching users
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /app/data &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; node:node /app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;chown -R node:node /app&lt;/code&gt; has to happen before &lt;code&gt;USER node&lt;/code&gt;, not after:&lt;/strong&gt; &lt;code&gt;chown&lt;/code&gt; itself requires root privileges to change ownership to a &lt;em&gt;different&lt;/em&gt; user — running it as &lt;code&gt;node&lt;/code&gt; would fail for the same reason the app's own &lt;code&gt;mkdir&lt;/code&gt; failed. The ordering matters: root creates and pre-provisions the directory the app will need, hands ownership to &lt;code&gt;node&lt;/code&gt;, and only then steps aside.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 — Verifying the fix, including who's actually running
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az acr build &lt;span class="nt"&gt;--registry&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt; rootless-lab:fixed &lt;span class="nt"&gt;-f&lt;/span&gt; Dockerfile.rootless-fixed &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# ...create container the same way, pointing at :fixed...&lt;/span&gt;

az container logs &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rootless-fixed-test
&lt;span class="c"&gt;# listening on 3000&lt;/span&gt;

curl http://&amp;lt;container-ip&amp;gt;:3000/healthz
&lt;span class="c"&gt;# {"status":"ok"}&lt;/span&gt;

az container &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; rootless-fixed-test &lt;span class="nt"&gt;--exec-command&lt;/span&gt; &lt;span class="s2"&gt;"id"&lt;/span&gt;
&lt;span class="c"&gt;# uid=1000(node) gid=1000(node)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate pieces of evidence, not one: the container's own logs show it started without crashing, &lt;code&gt;curl&lt;/code&gt; proves the HTTP server actually answers, and &lt;code&gt;az container exec ... id&lt;/code&gt; proves the process inside is genuinely running as UID 1000, not root — the actual property this whole exercise was for, confirmed rather than assumed from reading the Dockerfile.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;The gap between "add &lt;code&gt;USER node&lt;/code&gt;" and "actually run rootless without breaking" is exactly one &lt;code&gt;chown&lt;/code&gt; — but that gap is invisible until something inside the container tries to write to a directory it doesn't own, and most demo apps never do. The real lesson generalizes past this one &lt;code&gt;fs.mkdirSync&lt;/code&gt; call: any container that logs to a file, caches to disk, or accepts uploads needs its writable directories explicitly handed to the non-root user before the app ever starts, and the only way to know you've done that correctly is to run it and watch it not crash — not to read the Dockerfile and assume it's fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/docker-multistage-lab" rel="noopener noreferrer"&gt;docker-multistage-lab&lt;/a&gt; — includes &lt;code&gt;Dockerfile.rootless-broken&lt;/code&gt; (reproduces the real &lt;code&gt;EACCES&lt;/code&gt; crash) and &lt;code&gt;Dockerfile.rootless-fixed&lt;/code&gt;, both runnable exactly as shown above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against current Azure CLI (&lt;code&gt;az acr build&lt;/code&gt;, &lt;code&gt;az container create&lt;/code&gt;, &lt;code&gt;az container exec&lt;/code&gt;) as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker · Rootless Containers · Container Security · Azure Container Instances&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/docker-rootless-containers-converting-what-breaks" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>rootlesscontainers</category>
      <category>azurecontainerinstances</category>
    </item>
    <item>
      <title>Networking MYRIX: Hub-Spoke VNets, Bastion, and Load-Balancer Health on Azure</title>
      <dc:creator>Raphael Gab-Momoh</dc:creator>
      <pubDate>Thu, 17 Sep 2026 07:23:43 +0000</pubDate>
      <link>https://dev.to/rdgmh/networking-myrix-hub-spoke-vnets-bastion-and-load-balancer-health-on-azure-ph9</link>
      <guid>https://dev.to/rdgmh/networking-myrix-hub-spoke-vnets-bastion-and-load-balancer-health-on-azure-ph9</guid>
      <description>&lt;h2&gt;
  
  
  Why This Article Isn't About the App
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://myrix.web.app" rel="noopener noreferrer"&gt;MYRIX&lt;/a&gt; is an AI-native competitive action-strategy game built with Flutter and the Flame engine — real, playable, running today on Firebase Hosting. But this article isn't about the game. It's about a question every previous article in this series has quietly skipped: &lt;strong&gt;how does traffic actually get to a VM, and how much of that path should be exposed to the internet at all?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every prior Azure article in this series (Container Apps, App Service, a plain VM) put the compute resource directly behind a public IP or a managed platform's own ingress. This one does neither. MYRIX's container runs on a VM with &lt;strong&gt;no public IP whatsoever&lt;/strong&gt; — not for the app, not for SSH. Every drop of traffic that reaches it, whether that's a player's browser or an engineer's terminal, passes through a piece of Azure networking built specifically to carry it there. That's the actual subject: VNets, subnets, peering, Bastion, and a Load Balancer health probe that does real monitoring work before Azure Monitor ever gets involved.&lt;/p&gt;

&lt;p&gt;Before Step 1, the concepts this design leans on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hub-spoke topology&lt;/strong&gt; — one central VNet (the &lt;em&gt;hub&lt;/em&gt;) holds shared infrastructure (here, Azure Bastion); one or more &lt;em&gt;spoke&lt;/em&gt; VNets hold workloads (here, the MYRIX VM). Spokes don't talk to each other directly; everything routes through, or is managed from, the hub. It's the same pattern a real Azure landing zone uses at much larger scale — this article builds the smallest version of it that's still structurally real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VNet peering&lt;/strong&gt; — a private, Microsoft-backbone connection between two VNets, as if they were one network, without any traffic touching the public internet. Peering is not automatically symmetric: each VNet needs its own peering resource pointing at the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Bastion&lt;/strong&gt; — a managed jump-host service. Instead of giving a VM a public IP so you can SSH into it, Bastion sits in the hub VNet with its own public IP, and tunnels an SSH session from your terminal, through Azure's backbone, to the VM's private IP. The VM's SSH port is never internet-facing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load Balancer health probe&lt;/strong&gt; — a Standard Load Balancer doesn't just forward traffic; it repeatedly checks a URL path on each backend instance and only routes traffic to instances that are currently passing. This is networking-layer health monitoring, distinct from and earlier than anything Azure Monitor does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the full shape of it before any command runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                     INTERNET
                        |
                        v
        +----------------------------+
        |     Standard Load          |
        |     Balancer               |
        |  :80 -&amp;gt; /healthz probe     |
        +----------------------------+
                        |
                        v
        +----------------------------+
        |        SPOKE VNET          |
        |        10.1.0.0/16         |
        |                            |
        |    snet-web  10.1.0.0/24   |
        |         |                  |
        |         v                  |
        |    +------------------+    |
        |    |    vm-myrix      |    |
        |    |    private IP    |    |
        |    |    :80    :22    |    |
        |    +------------------+    |
        +----------------------------+
                        ^
                        |
                   VNet Peering
                        |
        +----------------------------+
        |         HUB VNET           |
        |        10.0.0.0/16         |
        |                            |
        |   AzureBastionSubnet       |
        |       10.0.0.0/26          |
        |            |               |
        |            v               |
        |       Azure Bastion        |
        +----------------------------+
                        ^
                        |
                  Engineer SSH
              (via Bastion tunnel)


                  MONITORING

     vm-myrix -&amp;gt; AMA -&amp;gt; DCR -&amp;gt; Log Analytics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two paths reach this deployment, and they never cross: internet traffic comes in only through the Load Balancer on port 80; management access comes in only through Bastion, tunneled to port 22. Neither path ever touches a public IP that belongs to the VM itself — it doesn't have one.&lt;/p&gt;

&lt;p&gt;Every command below was run against a live subscription and verified before being written here — including one real failure, caught and fixed during the container build, documented in Step 5 rather than smoothed over.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 — Resource group and two non-overlapping VNets
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myrix-net-rg
&lt;span class="nv"&gt;LOC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eastus
az group create &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt;

az network vnet create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vnet-hub &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address-prefixes&lt;/span&gt; 10.0.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-name&lt;/span&gt; AzureBastionSubnet &lt;span class="nt"&gt;--subnet-prefixes&lt;/span&gt; 10.0.0.0/26

az network vnet create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vnet-spoke &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address-prefixes&lt;/span&gt; 10.1.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-name&lt;/span&gt; snet-web &lt;span class="nt"&gt;--subnet-prefixes&lt;/span&gt; 10.1.0.0/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why the subnet must be named exactly &lt;code&gt;AzureBastionSubnet&lt;/code&gt;:&lt;/strong&gt; Azure Bastion refuses to deploy into a subnet with any other name — this isn't a convention, it's a hard requirement enforced at deployment time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the two VNets use non-overlapping address ranges (10.0.0.0/16 vs 10.1.0.0/16):&lt;/strong&gt; peered VNets route traffic between each other based on IP address ranges. If both VNets used the same range, Azure would have no way to tell which network a given private IP actually belongs to, and peering would fail outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check that without doing binary math:&lt;/strong&gt; the number after the slash (the CIDR prefix) says how many of the 32 bits in an IPv4 address are fixed as the "network" part — the rest are free to vary as host addresses. A smaller number after the slash means fewer fixed bits and more available addresses: &lt;code&gt;/16&lt;/code&gt; fixes the first 16 bits (the first two octets) and leaves 65,536 addresses free; &lt;code&gt;/26&lt;/code&gt;, used for &lt;code&gt;AzureBastionSubnet&lt;/code&gt; below, fixes 26 bits and leaves only 64 — plenty for a subnet that doesn't host application VMs.&lt;/p&gt;

&lt;p&gt;For two &lt;code&gt;/16&lt;/code&gt; ranges, the easiest way to confirm they don't overlap is to just compare the second octet: &lt;code&gt;10.0.0.0/16&lt;/code&gt; covers every address from &lt;code&gt;10.0.0.0&lt;/code&gt; to &lt;code&gt;10.0.255.255&lt;/code&gt;; &lt;code&gt;10.1.0.0/16&lt;/code&gt; covers &lt;code&gt;10.1.0.0&lt;/code&gt; to &lt;code&gt;10.1.255.255&lt;/code&gt;. Those never share an address, because the second octet (&lt;code&gt;0&lt;/code&gt; vs &lt;code&gt;1&lt;/code&gt;) already differs — the last two octets don't even need checking. The general rule for planning a set of VNets this way: give each one a different value in whichever octet your prefix fixes. With &lt;code&gt;/16&lt;/code&gt;s starting &lt;code&gt;10.x.0.0&lt;/code&gt;, that's up to 256 non-overlapping VNets (&lt;code&gt;10.0&lt;/code&gt; through &lt;code&gt;10.255&lt;/code&gt;) without ever needing a calculator — just confirm the fixed part of the address differs between any two ranges you're about to peer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 — Peer the VNets in both directions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet peering create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; hub-to-spoke &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; vnet-hub &lt;span class="nt"&gt;--remote-vnet&lt;/span&gt; vnet-spoke &lt;span class="nt"&gt;--allow-vnet-access&lt;/span&gt;

az network vnet peering create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; spoke-to-hub &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; vnet-spoke &lt;span class="nt"&gt;--remote-vnet&lt;/span&gt; vnet-hub &lt;span class="nt"&gt;--allow-vnet-access&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this is two commands, not one:&lt;/strong&gt; peering is defined per-VNet, not per-pair. &lt;code&gt;hub-to-spoke&lt;/code&gt; tells &lt;code&gt;vnet-hub&lt;/code&gt; it can reach &lt;code&gt;vnet-spoke&lt;/code&gt;; without the second command, that permission would only exist in one direction, and a resource in the spoke would have no route back to anything in the hub — including, critically, Bastion.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 — An NSG that only trusts Bastion for SSH
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nsg create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; nsg-web

az network nsg rule create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; nsg-web &lt;span class="nt"&gt;-n&lt;/span&gt; Allow-HTTP-Inbound &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 100 &lt;span class="nt"&gt;--direction&lt;/span&gt; Inbound &lt;span class="nt"&gt;--access&lt;/span&gt; Allow &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-address-prefixes&lt;/span&gt; Internet &lt;span class="nt"&gt;--destination-port-ranges&lt;/span&gt; 80

az network nsg rule create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nsg-name&lt;/span&gt; nsg-web &lt;span class="nt"&gt;-n&lt;/span&gt; Allow-SSH-From-Bastion &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--priority&lt;/span&gt; 110 &lt;span class="nt"&gt;--direction&lt;/span&gt; Inbound &lt;span class="nt"&gt;--access&lt;/span&gt; Allow &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-address-prefixes&lt;/span&gt; 10.0.0.0/26 &lt;span class="nt"&gt;--destination-port-ranges&lt;/span&gt; 22

az network vnet subnet update &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; vnet-spoke &lt;span class="nt"&gt;--name&lt;/span&gt; snet-web &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-security-group&lt;/span&gt; nsg-web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why the SSH rule's source is &lt;code&gt;10.0.0.0/26&lt;/code&gt; and not &lt;code&gt;Internet&lt;/code&gt;:&lt;/strong&gt; that CIDR is exactly the &lt;code&gt;AzureBastionSubnet&lt;/code&gt; range from Step 1 — nothing else. Since the VM has no public IP at all (confirmed in Step 4), this rule is less about stopping internet attackers, who couldn't reach port 22 either way, and more about making the intended access path explicit and enforced at the network layer rather than relying on "well, there's no public IP" as the only control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second real bug, caught only after the Load Balancer was wired up in Step 6:&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt;ing the Load Balancer's public IP on &lt;code&gt;/healthz&lt;/code&gt; timed out completely, even though the container was confirmed running and answering locally on the VM. The cause was a second NSG this article hadn't accounted for — &lt;code&gt;az vm create&lt;/code&gt; in Step 4, run without an explicit &lt;code&gt;--nsg&lt;/code&gt; flag, silently provisions its own default NSG directly on the VM's network interface, separate from &lt;code&gt;nsg-web&lt;/code&gt; attached to the subnet here. That NIC-level NSG only had a default SSH rule; traffic on port 80 was allowed by &lt;code&gt;nsg-web&lt;/code&gt; at the subnet but blocked by the NIC's own NSG, and Azure evaluates both — a request only gets through if every NSG in the path allows it. The fix was replacing the NIC's auto-created NSG with &lt;code&gt;nsg-web&lt;/code&gt; itself, so the same rule set governs both layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network nic update &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrixVMNic &lt;span class="nt"&gt;--network-security-group&lt;/span&gt; nsg-web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl http://&amp;lt;lb-public-ip&amp;gt;/healthz&lt;/code&gt; returned &lt;code&gt;ok&lt;/code&gt; immediately after. The lesson generalizes past this one command: a VM's effective inbound rules are the &lt;em&gt;intersection&lt;/em&gt; of every NSG attached anywhere along the path — subnet and NIC both — not just the one you remembered attaching.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 — Azure Bastion, and a VM with no public IP
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pip-bastion &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt;

az network bastion create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; bastion-myrix &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; vnet-hub &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; pip-bastion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bastion takes several minutes to provision — it's a managed PaaS resource, not a VM you can inspect mid-deploy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrix &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; Ubuntu2204 &lt;span class="nt"&gt;--size&lt;/span&gt; Standard_B2s &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; vnet-spoke &lt;span class="nt"&gt;--subnet&lt;/span&gt; snet-web &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-username&lt;/span&gt; azureuser &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;--public-ip-address ""&lt;/code&gt; matters more than it looks:&lt;/strong&gt; &lt;code&gt;az vm create&lt;/code&gt; allocates a public IP by default if you don't override it. Passing an explicit empty string is the only way to opt out — forgetting this flag would silently undo the entire point of putting Bastion in front of the VM.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To actually reach the VM for management&lt;/strong&gt;, Bastion needs its native-client tunneling feature turned on (off by default) before a local terminal can tunnel through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network bastion update &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; bastion-myrix &lt;span class="nt"&gt;--enable-tunneling&lt;/span&gt; &lt;span class="nb"&gt;true

&lt;/span&gt;az network bastion tunnel &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; bastion-myrix &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-resource-id&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az vm show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrix &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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-port&lt;/span&gt; 22 &lt;span class="nt"&gt;--port&lt;/span&gt; 2222
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second command blocks, holding open a local port 2222 that forwards through Bastion to the VM's SSH port. In a second terminal, &lt;code&gt;ssh azureuser@localhost -p 2222&lt;/code&gt; lands on the VM — having gone through Azure's backbone the entire way, never touching a public IP that belongs to the VM.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 — Building the image, and a real bug caught by the build itself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ACR_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myrixacr&lt;span class="nv"&gt;$RANDOM&lt;/span&gt;
az acr create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt; &lt;span class="nt"&gt;--sku&lt;/span&gt; Basic &lt;span class="nt"&gt;--admin-enabled&lt;/span&gt; &lt;span class="nb"&gt;false

&lt;/span&gt;az acr build &lt;span class="nt"&gt;--registry&lt;/span&gt; &lt;span class="nv"&gt;$ACR_NAME&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt; myrix:latest &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Dockerfile is a two-stage build — a &lt;code&gt;cirruslabs/flutter&lt;/code&gt; image runs &lt;code&gt;flutter build web --release&lt;/code&gt;, and an &lt;code&gt;nginx:alpine&lt;/code&gt; runtime stage serves the compiled output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ghcr.io/cirruslabs/flutter:3.38.4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pubspec.yaml pubspec.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;flutter pub get
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;flutter build web &lt;span class="nt"&gt;--release&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;nginx:1.27-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/build/web /usr/share/nginx/html&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; nginx.conf /etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The first &lt;code&gt;az acr build&lt;/code&gt; run actually failed&lt;/strong&gt;, with a real Dart compile error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib/components/gem_component.dart:143:63:
Error: 'Color' isn't a type.
lib/components/gem_component.dart:145:18:
Error: The method 'Path' isn't defined for the type 'GemComponent'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's confusing on its face — &lt;code&gt;gem_component.dart&lt;/code&gt; clearly imports &lt;code&gt;package:flutter/material.dart&lt;/code&gt;, which exports both &lt;code&gt;Color&lt;/code&gt; and &lt;code&gt;Path&lt;/code&gt;, and the exact same file compiled cleanly on a local &lt;code&gt;flutter build web --release&lt;/code&gt; run before this. The difference was the &lt;strong&gt;build context sent to ACR&lt;/strong&gt;: there was no &lt;code&gt;.dockerignore&lt;/code&gt;, so &lt;code&gt;COPY . .&lt;/code&gt; copied the local machine's own &lt;code&gt;.dart_tool/package_config.json&lt;/code&gt; — a file &lt;code&gt;flutter pub get&lt;/code&gt; had already generated fresh inside the container one step earlier — clobbering it with a version full of absolute paths to this Mac's local Flutter SDK and pub cache. Inside the container, those paths don't exist, package resolution silently breaks, and the compiler reports &lt;code&gt;material.dart&lt;/code&gt;'s own exports as undefined types. The fix was a &lt;code&gt;.dockerignore&lt;/code&gt; excluding &lt;code&gt;.dart_tool&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, and &lt;code&gt;.git&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.dart_tool
build
.git
.idea
*.iml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rebuilding after that fix succeeded. This is the same class of lesson as the ORIN article's &lt;code&gt;import.meta&lt;/code&gt; bug — a failure that only exists because two build environments (local machine, remote container) silently diverged, and the fix is making sure they can't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6 — A Standard Load Balancer whose health probe is the first monitor
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pip-lb &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt;

az network lb create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; lb-myrix &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOC&lt;/span&gt; &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; pip-lb &lt;span class="nt"&gt;--frontend-ip-name&lt;/span&gt; feip-myrix &lt;span class="nt"&gt;--backend-pool-name&lt;/span&gt; beap-myrix

az network lb probe create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--lb-name&lt;/span&gt; lb-myrix &lt;span class="nt"&gt;-n&lt;/span&gt; probe-healthz &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; Http &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--path&lt;/span&gt; /healthz

az network lb rule create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--lb-name&lt;/span&gt; lb-myrix &lt;span class="nt"&gt;-n&lt;/span&gt; rule-http &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol&lt;/span&gt; Tcp &lt;span class="nt"&gt;--frontend-port&lt;/span&gt; 80 &lt;span class="nt"&gt;--backend-port&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--frontend-ip-name&lt;/span&gt; feip-myrix &lt;span class="nt"&gt;--backend-pool-name&lt;/span&gt; beap-myrix &lt;span class="nt"&gt;--probe-name&lt;/span&gt; probe-healthz

&lt;span class="nv"&gt;NIC_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az vm show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrix &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"networkProfile.networkInterfaces[0].id"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;))&lt;/span&gt;
az network nic ip-config address-pool add &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--nic-name&lt;/span&gt; &lt;span class="nv"&gt;$NIC_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ip-config-name&lt;/span&gt; ipconfig-vm-myrix &lt;span class="nt"&gt;--lb-name&lt;/span&gt; lb-myrix &lt;span class="nt"&gt;--address-pool&lt;/span&gt; beap-myrix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;nginx.conf&lt;/code&gt; shipped in the container defines the probe's target explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/healthz&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;access_log&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="s"&gt;"ok&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s"&gt;n"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this is a networking concept and not just a monitoring one:&lt;/strong&gt; the Load Balancer polls &lt;code&gt;GET /healthz&lt;/code&gt; on the VM every few seconds by default, entirely on its own, with no Azure Monitor alert or human involved. An instance that stops answering gets pulled out of rotation automatically — this is the first layer of health handling, and it happens at the load-balancing layer before any metric, log, or alert ever fires. This is also the only path that exposes anything about MYRIX to the public internet at all: port 80, and only port 80, on the Load Balancer's public IP, forwarded to the VM's private IP inside the spoke VNet.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7 — VM-level monitoring: Log Analytics, the Azure Monitor Agent, and a Data Collection Rule
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az monitor log-analytics workspace create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--workspace-name&lt;/span&gt; law-myrix

az vm extension &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--vm-name&lt;/span&gt; vm-myrix &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; AzureMonitorLinuxAgent &lt;span class="nt"&gt;--publisher&lt;/span&gt; Microsoft.Azure.Monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Installing the extension alone doesn't send anything anywhere.&lt;/strong&gt; The Azure Monitor Agent needs to be told what to collect and where to send it — that's a separate resource, a Data Collection Rule (DCR), which has to exist and be explicitly associated with the VM before any telemetry actually reaches the workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; dcr-myrix.json &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "location": "&lt;/span&gt;&lt;span class="nv"&gt;$LOC&lt;/span&gt;&lt;span class="sh"&gt;",
  "properties": {
    "dataSources": {
      "performanceCounters": [
        {
          "name": "perfCounterDataSource",
          "streams": ["Microsoft-Perf"],
          "samplingFrequencyInSeconds": 60,
          "counterSpecifiers": [
            "&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;Processor(_Total)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;% Processor Time",
            "&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;Memory(*)&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sh"&gt;% Used Memory"
          ]
        }
      ]
    },
    "destinations": {
      "logAnalytics": [
        { "workspaceResourceId": "&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az monitor log-analytics workspace show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--workspace-name&lt;/span&gt; law-myrix &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="sh"&gt;", "name": "law-destination" }
      ]
    },
    "dataFlows": [
      { "streams": ["Microsoft-Perf"], "destinations": ["law-destination"] }
    ]
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;az monitor data-collection rule create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dcr-myrix &lt;span class="nt"&gt;--rule-file&lt;/span&gt; dcr-myrix.json

az monitor data-collection rule association create &lt;span class="nt"&gt;--name&lt;/span&gt; dcr-assoc-myrix &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rule-id&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az monitor data-collection rule show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; dcr-myrix &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="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az vm show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrix &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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual chain is &lt;strong&gt;VM → Azure Monitor Agent → Data Collection Rule → Log Analytics workspace&lt;/strong&gt;, not "install the agent, data appears." The DCR here defines one stream (&lt;code&gt;Microsoft-Perf&lt;/code&gt;, carrying CPU and memory counters) and one destination (&lt;code&gt;law-myrix&lt;/code&gt;); a production setup would typically add Syslog or additional counter streams to the same rule rather than creating a second one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this is a second, separate layer from Step 6's probe:&lt;/strong&gt; the Load Balancer's probe answers one binary question — "is port 80 responding right now?" It has no idea whether the VM is at 95% memory, whether disk space is running low, or what the CPU trend looked like over the last six hours. This DCR-routed telemetry is what turns "up or down" into a queryable history in Log Analytics.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8 — Alerts on both signals
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VM_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az vm show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vm-myrix &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;
az monitor metrics alert create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; alert-vm-cpu-high &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt; &lt;span class="nv"&gt;$VM_ID&lt;/span&gt; &lt;span class="nt"&gt;--condition&lt;/span&gt; &lt;span class="s2"&gt;"avg Percentage CPU &amp;gt; 85"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"MYRIX VM sustained high CPU"&lt;/span&gt; &lt;span class="nt"&gt;--window-size&lt;/span&gt; 5m &lt;span class="nt"&gt;--evaluation-frequency&lt;/span&gt; 5m

&lt;span class="nv"&gt;LB_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az network lb show &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; lb-myrix &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;
az monitor metrics alert create &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; alert-lb-unhealthy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt; &lt;span class="nv"&gt;$LB_ID&lt;/span&gt; &lt;span class="nt"&gt;--condition&lt;/span&gt; &lt;span class="s2"&gt;"avg DipAvailability &amp;lt; 100"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"MYRIX backend failing /healthz probe"&lt;/span&gt; &lt;span class="nt"&gt;--window-size&lt;/span&gt; 5m &lt;span class="nt"&gt;--evaluation-frequency&lt;/span&gt; 1m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;DipAvailability&lt;/code&gt; is the more urgent alert of the two:&lt;/strong&gt; it's the Load Balancer's own metric for "percentage of backend instances currently passing their health probe." Alerting on it dropping below 100% catches the exact moment &lt;code&gt;/healthz&lt;/code&gt; stops answering — independent of whether CPU, memory, or any other VM-level metric still looks perfectly normal. A container can hang or deadlock without ever spiking CPU; this alert catches that failure mode, the VM-level one doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;Every other article in this series exposed a compute resource to the internet more or less directly — a Container App's own ingress, an App Service's default hostname, a VM with a public IP and an open port. This one deliberately didn't. The only thing reachable from the internet at all is port 80 on a Load Balancer's public IP; the VM itself, and the SSH access needed to manage it, sit entirely inside a private network that only Bastion can reach. That's not extra complexity for its own sake — it's what "networking concepts" actually buys an application: a much smaller, more deliberate surface area than "give everything a public IP and lock it down with firewall rules after the fact."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Repository:&lt;/strong&gt; &lt;a href="https://github.com/raphgm/myrix" rel="noopener noreferrer"&gt;myrix&lt;/a&gt; — the real Flutter/Flame game repository, now with the Dockerfile, nginx health endpoint, and this article's complete networking setup documented in &lt;code&gt;docs/AZURE_NETWORKING.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Reviewed against current Azure CLI (&lt;code&gt;az network vnet&lt;/code&gt;, &lt;code&gt;az network bastion&lt;/code&gt;, &lt;code&gt;az network lb&lt;/code&gt;, &lt;code&gt;az monitor&lt;/code&gt;) as of September 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Azure Networking · VNet Peering · Azure Bastion · Load Balancer · Azure Monitor · Hub-Spoke Topology&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://raphaelgmomoh.pages.dev/articles/myrix-azure-hub-spoke-networking-monitoring" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>azurenetworking</category>
      <category>vnetpeering</category>
      <category>azurebastion</category>
      <category>loadbalancer</category>
    </item>
  </channel>
</rss>
