<?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: Mayank Thakur</title>
    <description>The latest articles on DEV Community by Mayank Thakur (@mayankthakur001).</description>
    <link>https://dev.to/mayankthakur001</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%2F4089964%2Fc1654db4-8e98-44bb-9ec0-8a42bb19f29d.png</url>
      <title>DEV Community: Mayank Thakur</title>
      <link>https://dev.to/mayankthakur001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mayankthakur001"/>
    <language>en</language>
    <item>
      <title>GlobalMart — CI/CD Pipeline on AWS</title>
      <dc:creator>Mayank Thakur</dc:creator>
      <pubDate>Sun, 23 Aug 2026 17:01:00 +0000</pubDate>
      <link>https://dev.to/mayankthakur001/globalmart-cicd-pipeline-on-aws-4lh4</link>
      <guid>https://dev.to/mayankthakur001/globalmart-cicd-pipeline-on-aws-4lh4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Zero-touch deployment of a React e-commerce app from GitHub to EC2 using AWS CodePipeline and CodeDeploy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Push to &lt;code&gt;main&lt;/code&gt;, walk away. The pipeline builds the app, versions the artifact in S3, and rolls it&lt;br&gt;
out to EC2 with health checks and automatic rollback — no SSH, no manual &lt;code&gt;scp&lt;/code&gt;, no downtime window.&lt;/p&gt;




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

&lt;h2&gt;
  
  
  Why this project exists
&lt;/h2&gt;

&lt;p&gt;Manually deploying a build to a server is fine once. It stops being fine the tenth time: someone&lt;br&gt;
copies the wrong folder, forgets to restart the web server, or deploys from a dirty local branch,&lt;br&gt;
and nobody can say which commit is live.&lt;/p&gt;

&lt;p&gt;This project replaces that with a pipeline where the commit SHA is the single source of truth for&lt;br&gt;
what is running in production. The goal was not "get a React app onto EC2" — it was to build the&lt;br&gt;
release path properly: versioned artifacts, an agent-driven deploy with lifecycle hooks, health&lt;br&gt;
verification before traffic is served, and rollback on failure.&lt;/p&gt;



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

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    DEV[Developer] --&amp;gt;|git push main| GH[GitHub repository]
    GH --&amp;gt;|CodeStar Connection webhook| CP[AWS CodePipeline]

    subgraph CP_STAGES [Pipeline]
      direction TB
      SRC[Source stage] --&amp;gt; BUILD[Build stage&amp;lt;br/&amp;gt;CodeBuild + Node.js]
      BUILD --&amp;gt; DEPLOY[Deploy stage&amp;lt;br/&amp;gt;CodeDeploy]
    end

    CP --&amp;gt; SRC
    BUILD --&amp;gt;|versioned artifact| S3[(S3 artifact bucket)]
    S3 --&amp;gt; DEPLOY
    DEPLOY --&amp;gt;|pull + lifecycle hooks| AGENT[CodeDeploy agent on EC2]
    AGENT --&amp;gt; NGINX[Nginx serving /var/www/globalmart]
    NGINX --&amp;gt; USER[End user]

    AGENT -.-&amp;gt;|deploy + app logs| CW[CloudWatch Logs]
    CP -.-&amp;gt;|state change events| SNS[SNS notification]

    IAM[IAM roles:&amp;lt;br/&amp;gt;pipeline, build, deploy, instance profile] -.-&amp;gt; CP_STAGES&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Trust boundaries worth noting: the pipeline role can read the artifact bucket and start&lt;br&gt;
deployments; the &lt;strong&gt;EC2 instance profile&lt;/strong&gt; can only read that one bucket. The instance never&lt;br&gt;
holds credentials that can modify the pipeline. Compromising the web server does not give an&lt;br&gt;
attacker the ability to ship code.&lt;/p&gt;


&lt;h2&gt;
  
  
  Pipeline stages
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Failure behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Source&lt;/td&gt;
&lt;td&gt;GitHub via CodeStar Connections&lt;/td&gt;
&lt;td&gt;Webhook fires on push to &lt;code&gt;main&lt;/code&gt;, source zipped to S3&lt;/td&gt;
&lt;td&gt;Pipeline stops, no artifact produced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;AWS CodeBuild (&lt;code&gt;buildspec.yml&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm ci&lt;/code&gt; then &lt;code&gt;npm run build&lt;/code&gt;, output packaged with deploy scripts&lt;/td&gt;
&lt;td&gt;Pipeline stops before anything reaches the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy&lt;/td&gt;
&lt;td&gt;AWS CodeDeploy (&lt;code&gt;appspec.yml&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Agent pulls artifact, runs lifecycle hooks, swaps web root&lt;/td&gt;
&lt;td&gt;Automatic rollback to last successful revision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CodeDeploy runs &lt;strong&gt;OneAtATime&lt;/strong&gt; against the deployment group, with the deployment marked failed if&lt;br&gt;
&lt;code&gt;ValidateService&lt;/code&gt; cannot get a 200 from the app — so a broken build never stays live.&lt;/p&gt;
&lt;h3&gt;
  
  
  Deployment lifecycle hooks
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ApplicationStop     → scripts/stop_server.sh      stop nginx gracefully
BeforeInstall       → scripts/before_install.sh   install deps, clear old web root
Install             → (CodeDeploy copies files)   artifact → /var/www/globalmart
AfterInstall        → scripts/after_install.sh    set ownership + permissions
ApplicationStart    → scripts/start_server.sh     start and enable nginx
ValidateService     → scripts/validate_service.sh curl health check, fail non-200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Repository layout
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Globalmart-cicd-pipeline/
├── appspec.yml              # CodeDeploy instructions — MUST be at repo root
├── buildspec.yml            # CodeBuild build steps
├── scripts/
│   ├── before_install.sh
│   ├── after_install.sh
│   ├── start_server.sh
│   ├── stop_server.sh
│   └── validate_service.sh
├── src/                     # React application source
├── public/
├── screenshots/             # pipeline runs, deploy events, live app
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;buildspec.yml&lt;/code&gt;
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;

&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runtime-versions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nodejs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="na"&gt;pre_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
  &lt;span class="na"&gt;post_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;echo "Build completed for commit $CODEBUILD_RESOLVED_SOURCE_VERSION"&lt;/span&gt;

&lt;span class="na"&gt;artifacts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;build/**/*&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;appspec.yml&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;scripts/**/*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;appspec.yml&lt;/code&gt; and &lt;code&gt;scripts/&lt;/code&gt; must be listed in artifacts — if they are not in the bundle,&lt;br&gt;
CodeDeploy has nothing to execute and fails immediately.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;appspec.yml&lt;/code&gt;
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.0&lt;/span&gt;
&lt;span class="na"&gt;os&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux&lt;/span&gt;

&lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/build&lt;/span&gt;
    &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/www/globalmart&lt;/span&gt;

&lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ApplicationStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scripts/stop_server.sh&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
      &lt;span class="na"&gt;runas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;BeforeInstall&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scripts/before_install.sh&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;300&lt;/span&gt;
      &lt;span class="na"&gt;runas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;AfterInstall&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scripts/after_install.sh&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;
      &lt;span class="na"&gt;runas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;ApplicationStart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scripts/start_server.sh&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;
      &lt;span class="na"&gt;runas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
  &lt;span class="na"&gt;ValidateService&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scripts/validate_service.sh&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;120&lt;/span&gt;
      &lt;span class="na"&gt;runas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;scripts/validate_service.sh&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The hook that makes this a real pipeline rather than a file copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..10&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}"&lt;/span&gt; http://localhost/ &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check passed on attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;0
  &lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Attempt &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt; returned &lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;, retrying..."&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;3
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check failed — triggering rollback"&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  IAM roles used
&lt;/h2&gt;

&lt;p&gt;Four distinct roles, each scoped to one job. This is the part most tutorials collapse into one&lt;br&gt;
over-permissioned role.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Attached to&lt;/th&gt;
&lt;th&gt;Permissions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CodePipelineServiceRole&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CodePipeline&lt;/td&gt;
&lt;td&gt;Read/write artifact bucket, start CodeBuild, create CodeDeploy deployments, use the CodeStar connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CodeBuildServiceRole&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CodeBuild project&lt;/td&gt;
&lt;td&gt;Write artifacts to S3, write CloudWatch Logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CodeDeployServiceRole&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CodeDeploy application&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AWSCodeDeployRole&lt;/code&gt; — describe and tag EC2 instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EC2InstanceProfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;EC2 instance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;s3:GetObject&lt;/code&gt; on the artifact bucket only, plus CloudWatch agent permissions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The instance profile is intentionally read-only and single-bucket. No &lt;code&gt;s3:*&lt;/code&gt;, no wildcard resource.&lt;/p&gt;


&lt;h2&gt;
  
  
  Setup: reproduce this pipeline
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Prepare the EC2 instance
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Amazon Linux 2023, t3.micro, security group: 22 (your IP only) + 80 (0.0.0.0/0)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf update &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx ruby wget
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nginx

&lt;span class="c"&gt;# CodeDeploy agent (region-specific bucket)&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/ec2-user
wget https://aws-codedeploy-ap-south-1.s3.ap-south-1.amazonaws.com/latest/install
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ./install
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./install auto
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status codedeploy-agent    &lt;span class="c"&gt;# must be active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Tag the instance &lt;code&gt;Name=globalmart-prod&lt;/code&gt; — CodeDeploy targets the deployment group by tag.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Create the AWS resources
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;S3 bucket for artifacts, versioning enabled, all public access blocked&lt;/li&gt;
&lt;li&gt;CodeDeploy application (EC2/On-premises) + deployment group targeting the instance tag&lt;/li&gt;
&lt;li&gt;CodeBuild project pointing at &lt;code&gt;buildspec.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;CodePipeline: Source (GitHub via CodeStar Connection) → Build → Deploy&lt;/li&gt;
&lt;li&gt;Attach the instance profile to the EC2 instance&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  3. Verify
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;--allow-empty&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"test: trigger pipeline"&lt;/span&gt;
git push origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then confirm, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;CodePipeline shows Source succeeded within ~30 seconds&lt;/li&gt;
&lt;li&gt;CodeBuild logs show &lt;code&gt;npm run build&lt;/code&gt; completing&lt;/li&gt;
&lt;li&gt;CodeDeploy shows all five lifecycle events green&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl http://&amp;lt;EC2-public-IP&amp;gt;&lt;/code&gt; returns the app&lt;/li&gt;
&lt;li&gt;On the instance: &lt;code&gt;tail -f /var/log/aws/codedeploy-agent/codedeploy-agent.log&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  Troubleshooting: what actually broke
&lt;/h2&gt;

&lt;p&gt;Real failures hit while building this, and the fixes. Kept here because these are the things&lt;br&gt;
tutorials skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ApplicationStop&lt;/code&gt; failed on the very first deployment&lt;/strong&gt;&lt;br&gt;
The hook script from the &lt;em&gt;previous&lt;/em&gt; revision runs during &lt;code&gt;ApplicationStop&lt;/code&gt;, and on deployment #1&lt;br&gt;
there is no previous revision. Fix: use "Ignore ApplicationStop lifecycle event failures" on the&lt;br&gt;
first run, or make the script idempotent with &lt;code&gt;systemctl stop nginx || true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;InstallError: The deployment failed because a specified file already exists&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
CodeDeploy will not overwrite files it did not place there. Fix: &lt;code&gt;rm -rf /var/www/globalmart/*&lt;/code&gt;&lt;br&gt;
in &lt;code&gt;before_install.sh&lt;/code&gt;, or set the file overwrite behaviour on the deployment group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ScriptFailed&lt;/code&gt; with exit code 126&lt;/strong&gt;&lt;br&gt;
Shell scripts committed without the execute bit. Fix: &lt;code&gt;git update-index --chmod=+x scripts/*.sh&lt;/code&gt;,&lt;br&gt;
or &lt;code&gt;chmod +x&lt;/code&gt; inside &lt;code&gt;before_install.sh&lt;/code&gt; before the hooks run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm run build&lt;/code&gt; killed on a t2.micro&lt;/strong&gt;&lt;br&gt;
1 GB of RAM is not enough for a Vite/CRA production build; the OOM killer takes the process and&lt;br&gt;
the log just says "Killed." Fix: build in CodeBuild rather than on the instance (which is what&lt;br&gt;
this pipeline does), or add swap on the instance if you must build there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment stuck at &lt;code&gt;Pending&lt;/code&gt;, then timed out&lt;/strong&gt;&lt;br&gt;
The CodeDeploy agent could not reach the service. Causes seen: agent not running, no instance&lt;br&gt;
profile attached, or a private subnet with no NAT and no VPC endpoint. Fix: &lt;code&gt;systemctl status&lt;br&gt;
codedeploy-agent&lt;/code&gt;, confirm the instance profile, check egress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AccessDenied&lt;/code&gt; pulling the artifact from S3&lt;/strong&gt;&lt;br&gt;
The instance profile lacked &lt;code&gt;s3:GetObject&lt;/code&gt; on the artifact bucket, and separately the bucket was&lt;br&gt;
in a different region than the deployment. Fix: scope the policy to that bucket ARN and keep&lt;br&gt;
bucket, pipeline, and instance in one region.&lt;/p&gt;


&lt;h2&gt;
  
  
  Monitoring and observability
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CloudWatch Logs: CodeBuild build logs, plus the CodeDeploy agent log shipped from the instance&lt;/li&gt;
&lt;li&gt;CloudWatch Alarm on EC2 &lt;code&gt;StatusCheckFailed&lt;/code&gt; and high CPU&lt;/li&gt;
&lt;li&gt;SNS topic subscribed to CodePipeline state-change events, so a failed deploy emails immediately&lt;/li&gt;
&lt;li&gt;Nginx access and error logs streamed via the CloudWatch agent&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Cost and teardown
&lt;/h2&gt;

&lt;p&gt;Running cost on free tier is effectively zero; outside it, roughly a few dollars a month —&lt;br&gt;
t3.micro on-demand, S3 storage in the low cents, CodePipeline at one dollar per active pipeline&lt;br&gt;
per month, CodeBuild billed per build minute.&lt;/p&gt;

&lt;p&gt;Teardown, in this order, to avoid orphaned charges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. delete the pipeline   2. delete the CodeBuild project&lt;/span&gt;
&lt;span class="c"&gt;# 3. delete the CodeDeploy app + deployment group&lt;/span&gt;
&lt;span class="c"&gt;# 4. empty and delete the artifact bucket (versioned — delete all versions)&lt;/span&gt;
&lt;span class="c"&gt;# 5. terminate EC2   6. release any Elastic IP   7. delete the IAM roles&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unreleased Elastic IP on a terminated instance is the classic surprise bill.&lt;/p&gt;




&lt;h2&gt;
  
  
  Production hardening — what I would add next
&lt;/h2&gt;

&lt;p&gt;Honest list of what this pipeline does &lt;em&gt;not&lt;/em&gt; do yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; &lt;strong&gt;Terraform&lt;/strong&gt; for the whole stack, so the pipeline itself is version-controlled and not click-configured&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Blue/green deployment&lt;/strong&gt; behind an ALB and Auto Scaling group, replacing in-place deploys and eliminating the brief serve gap&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Test and security gates&lt;/strong&gt; in the build stage: unit tests, &lt;code&gt;npm audit&lt;/code&gt;, and SAST as blocking steps&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Manual approval action&lt;/strong&gt; before production, with a staging deployment group ahead of it&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;S3 + CloudFront&lt;/strong&gt; for the static React build instead of EC2 — cheaper, faster, and no server to patch&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Secrets via Parameter Store / Secrets Manager&lt;/strong&gt; injected at build time rather than baked into the artifact&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Container path&lt;/strong&gt;: Docker image to ECR, deployed to ECS Fargate, for reproducible runtime environments&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What this project demonstrates
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;End-to-end CI/CD design: source, build, artifact versioning, deploy, verify, roll back&lt;/li&gt;
&lt;li&gt;CodeDeploy lifecycle hooks and health-gated releases, not just automated file copying&lt;/li&gt;
&lt;li&gt;Least-privilege IAM across four separate service roles&lt;/li&gt;
&lt;li&gt;Linux service administration and web server configuration on EC2&lt;/li&gt;
&lt;li&gt;Debugging distributed deploy failures from agent logs&lt;/li&gt;
&lt;li&gt;Cost awareness and clean resource teardown&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Pipeline executions, CodeDeploy lifecycle events, EC2 status, and the running application &lt;/p&gt;




&lt;h2&gt;
  
  
  Author
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mayank Thakur&lt;/strong&gt; — Cloud, DevOps and SRE&lt;br&gt;
&lt;a href="https://github.com/itzmayank01" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://linkedin.com/in/mayankthakur1" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="mailto:mayankthakur9181@gmail.com"&gt;mayankthakur9181@gmail.com&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>3 Tier Application On EKS</title>
      <dc:creator>Mayank Thakur</dc:creator>
      <pubDate>Sat, 22 Aug 2026 16:45:45 +0000</pubDate>
      <link>https://dev.to/mayankthakur001/3-tier-application-on-eks-oc7</link>
      <guid>https://dev.to/mayankthakur001/3-tier-application-on-eks-oc7</guid>
      <description>&lt;p&gt;I built a 3-tier application running on Amazon EKS, deployed by a GitHub Actions pipeline that runs on every merge to &lt;code&gt;main&lt;/code&gt;. The first version of that pipeline had two secrets sitting in the repo settings: &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; and &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Github- &lt;a href="https://github.com/itzmayank01/3-tier-user-platform-devops" rel="noopener noreferrer"&gt;https://github.com/itzmayank01/3-tier-user-platform-devops&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;They worked. They were also a bad idea, and getting rid of them taught me more about EKS authentication than anything else in the project.&lt;/p&gt;

&lt;p&gt;This post is the setup I ended up with. It includes the part that broke for me and that most tutorials skip: getting past IAM is only half the job, because EKS has its own authorization layer on top.&lt;/p&gt;

&lt;p&gt;Why long-lived keys are a problem&lt;/p&gt;

&lt;p&gt;An IAM access key does not expire. If it leaks, it stays valid until someone notices and revokes it. In a CI pipeline that risk is real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key lives in repository settings, and anyone with admin access to the repo can see who added it&lt;/li&gt;
&lt;li&gt;A malicious or careless workflow change can print it, exfiltrate it, or use it for something unrelated&lt;/li&gt;
&lt;li&gt;Rotating it means updating every repo that uses it, so in practice nobody rotates it&lt;/li&gt;
&lt;li&gt;A fork or a compromised third-party action widens the blast radius&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OIDC replaces that with a token that lives for the length of a single job.&lt;/p&gt;

&lt;p&gt;How OIDC actually works here&lt;/p&gt;

&lt;p&gt;Four steps, and it helps to hold the whole picture in your head before you start:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub Actions mints a short-lived JSON Web Token describing the job: which repo, which branch, which workflow&lt;/li&gt;
&lt;li&gt;Your workflow sends that token to AWS STS&lt;/li&gt;
&lt;li&gt;AWS checks the token signature against a registered identity provider, then checks the claims inside it against your IAM role's trust policy&lt;/li&gt;
&lt;li&gt;If both pass, STS hands back temporary credentials that expire when the job ends&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No secret is stored anywhere. The trust is in the claims, not in a shared string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Register GitHub as an identity provider in IAM
&lt;/h2&gt;

&lt;p&gt;You only do this once per AWS account.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam create-open-id-connect-provider &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt; https://token.actions.githubusercontent.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id-list&lt;/span&gt; sts.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have used GitHub OIDC before, you may remember passing a certificate thumbprint. AWS no longer requires you to manage that for this provider - it verifies and rotates the certificate itself. If your tooling still demands a value, older guides use &lt;code&gt;6938fd4d98bab03faadb97b34396831e3780aea1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check that it landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws iam list-open-id-connect-providers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Create the IAM role and its trust policy
&lt;/h2&gt;

&lt;p&gt;This is where most of the security lives. The trust policy decides &lt;em&gt;which&lt;/em&gt; GitHub jobs are allowed to assume this role.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;trust-policy.json&lt;/code&gt;:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;span class="nl"&gt;"Effect"&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;"Principal"&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;"Federated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com"&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;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRoleWithWebIdentity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&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;"StringEquals"&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;"token.actions.githubusercontent.com:aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts.amazonaws.com"&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;"StringLike"&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;"token.actions.githubusercontent.com:sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"repo:my-org/my-repo:ref:refs/heads/main"&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;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;aws iam create-role &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-name&lt;/span&gt; GitHubActionsEKSDeployRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--assume-role-policy-document&lt;/span&gt; file://trust-policy.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read the &lt;code&gt;sub&lt;/code&gt; condition twice.&lt;/strong&gt; It is the only thing standing between "my main branch can deploy" and "anyone who opens a pull request against my repo can deploy". Some patterns and what they mean:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Who can assume the role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:my-org/my-repo:ref:refs/heads/main&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only jobs running on the main branch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:my-org/my-repo:environment:production&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only jobs targeting the &lt;code&gt;production&lt;/code&gt; environment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:my-org/my-repo:pull_request&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Any pull request, including from forks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:my-org/*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every repo in the org&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;repo:*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anyone on GitHub. Never do this.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I use the environment form for production, because GitHub environments let me add a required reviewer on top. IAM enforces which jobs can assume the role; GitHub enforces who can trigger those jobs.&lt;/p&gt;

&lt;p&gt;Then attach permissions. The deploy role needs very little - enough to look up the cluster endpoint:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&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;span class="nl"&gt;"Effect"&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;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eks:DescribeCluster"&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"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:eks:ap-south-1:111122223333:cluster/my-cluster"&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;If the same job also pushes images, add the ECR actions it needs (&lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt;, &lt;code&gt;ecr:BatchCheckLayerAvailability&lt;/code&gt;, &lt;code&gt;ecr:PutImage&lt;/code&gt;, &lt;code&gt;ecr:InitiateLayerUpload&lt;/code&gt;, &lt;code&gt;ecr:UploadLayerPart&lt;/code&gt;, &lt;code&gt;ecr:CompleteLayerUpload&lt;/code&gt;). I prefer two separate roles - one for build and push, one for deploy - so a compromised build step cannot touch the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The workflow
&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to EKS&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

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

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Configure AWS credentials&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::111122223333:role/GitHubActionsEKSDeployRole&lt;/span&gt;
          &lt;span class="na"&gt;role-session-name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gha-deploy-${{ github.run_id }}&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ap-south-1&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;Update kubeconfig&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws eks update-kubeconfig --name my-cluster --region ap-south-1&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;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;kubectl set image deployment/frontend \&lt;/span&gt;
            &lt;span class="s"&gt;frontend=111122223333.dkr.ecr.ap-south-1.amazonaws.com/frontend:${{ github.sha }}&lt;/span&gt;
          &lt;span class="s"&gt;kubectl rollout status deployment/frontend --timeout=120s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;permissions: id-token: write&lt;/code&gt; is not optional.&lt;/strong&gt; Without it, GitHub never mints the token and the credentials step fails with something like &lt;code&gt;Could not assume role with OIDC&lt;/code&gt;. This is the single most common failure, and the error message does not point at the missing permission. Also note that declaring a &lt;code&gt;permissions&lt;/code&gt; block resets &lt;em&gt;all&lt;/em&gt; permissions to none, so you have to list &lt;code&gt;contents: read&lt;/code&gt; explicitly if your job checks out code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag images by commit SHA, not &lt;code&gt;latest&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;${{ github.sha }}&lt;/code&gt; makes every deploy traceable to a commit, and rolling back is just pointing at the previous SHA.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The part tutorials skip
&lt;/h2&gt;

&lt;p&gt;At this point my pipeline authenticated to AWS perfectly and then died on the &lt;code&gt;kubectl&lt;/code&gt; step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: You must be logged in to the server (Unauthorized)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The credentials were fine. The problem is that &lt;strong&gt;EKS has two separate layers&lt;/strong&gt;. IAM decides whether you can talk to the cluster's API endpoint. Kubernetes RBAC decides what you can do once you are there. A brand new IAM role has an AWS identity and no Kubernetes identity at all.&lt;/p&gt;

&lt;p&gt;You have to explicitly map the role into the cluster.&lt;/p&gt;

&lt;h3&gt;
  
  
  The modern way: EKS access entries
&lt;/h3&gt;

&lt;p&gt;Access entries are an EKS API, so you manage cluster access with the same tooling as everything else. First check what your cluster is using:&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 describe-cluster &lt;span class="nt"&gt;--name&lt;/span&gt; my-cluster &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'cluster.accessConfig'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;authenticationMode&lt;/code&gt; is &lt;code&gt;CONFIG_MAP&lt;/code&gt;, switch it:&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-cluster-config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-config&lt;/span&gt; &lt;span class="nv"&gt;authenticationMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;API_AND_CONFIG_MAP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the entry and give it a scope:&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 create-access-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal-arn&lt;/span&gt; arn:aws:iam::111122223333:role/GitHubActionsEKSDeployRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; STANDARD

aws eks associate-access-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cluster-name&lt;/span&gt; my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--principal-arn&lt;/span&gt; arn:aws:iam::111122223333:role/GitHubActionsEKSDeployRole &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-arn&lt;/span&gt; arn:aws:eks::aws:cluster-access-policy/AmazonEKSEditPolicy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--access-scope&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;namespace,namespaces&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AmazonEKSEditPolicy&lt;/code&gt; scoped to one namespace is usually right for a deploy role. It can update workloads and nothing else. Resist reaching for &lt;code&gt;AmazonEKSClusterAdminPolicy&lt;/code&gt; - it is the &lt;code&gt;AdministratorAccess&lt;/code&gt; of EKS.&lt;/p&gt;

&lt;p&gt;One direction-of-travel note: you can move &lt;code&gt;CONFIG_MAP&lt;/code&gt; to &lt;code&gt;API_AND_CONFIG_MAP&lt;/code&gt; to &lt;code&gt;API&lt;/code&gt;, but you cannot go back. Migrate deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  The older way: the aws-auth ConfigMap
&lt;/h3&gt;

&lt;p&gt;If you are on an older cluster still using &lt;code&gt;CONFIG_MAP&lt;/code&gt; mode, you edit &lt;code&gt;aws-auth&lt;/code&gt; in &lt;code&gt;kube-system&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;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;ConfigMap&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;aws-auth&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;mapRoles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;- rolearn: arn:aws:iam::111122223333:role/GitHubActionsEKSDeployRole&lt;/span&gt;
      &lt;span class="s"&gt;username: github-actions&lt;/span&gt;
      &lt;span class="s"&gt;groups:&lt;/span&gt;
        &lt;span class="s"&gt;- deployers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then bind &lt;code&gt;deployers&lt;/code&gt; to a Role or ClusterRole with normal Kubernetes RBAC. Two warnings if you go this route: a bad edit here can lock everyone out of the cluster, including you, and if your IAM role has a path (&lt;code&gt;/ci/GitHubActionsEKSDeployRole&lt;/code&gt;), you must strip the path out of the ARN before EKS will match it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Could not assume role with OIDC&lt;/code&gt;&lt;/strong&gt; - the missing &lt;code&gt;id-token: write&lt;/code&gt; permission, nine times out of ten&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Not authorized to perform sts:AssumeRoleWithWebIdentity&lt;/code&gt;&lt;/strong&gt; - your &lt;code&gt;sub&lt;/code&gt; claim does not match. Print &lt;code&gt;github.ref&lt;/code&gt; and &lt;code&gt;github.workflow_ref&lt;/code&gt; in a debug step and compare them character by character with the trust policy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;You must be logged in to the server (Unauthorized)&lt;/code&gt;&lt;/strong&gt; - IAM is fine, the cluster mapping is missing. This is Step 4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;kubectl&lt;/code&gt; hangs, then times out&lt;/strong&gt; - your cluster API endpoint is private and a hosted runner cannot reach it. You need a self-hosted runner inside the VPC, or a VPC-connected alternative&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The role works from your laptop but not from CI&lt;/strong&gt; - you are probably testing with a user that has broader permissions than the role. Assume the role locally with &lt;code&gt;aws sts assume-role&lt;/code&gt; and retest&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;After the migration my repo has zero AWS secrets. The credentials a job holds are valid for that job only, scoped to one namespace, on one branch. If someone gets a malicious workflow merged, the worst case is bounded by an IAM policy and a Kubernetes RBAC binding instead of by whatever those static keys happened to be allowed to do.&lt;/p&gt;

&lt;p&gt;That is a much better position to defend, and it took an afternoon.&lt;/p&gt;

&lt;p&gt;Next in this series: cutting that same pipeline from five minutes to two and a half with self-hosted runners and parallel stages.&lt;/p&gt;




</description>
      <category>aws</category>
      <category>cicd</category>
      <category>devops</category>
      <category>kubernetes</category>
    </item>
  </channel>
</rss>
