CI/CD with GitHub Actions: Automatically Build and Deploy to AWS

K
Kai··9 min read

In this article we build a complete CI/CD pipeline: push code to GitHub and the system automatically builds the image, pushes it to ECR, and deploys to EC2. This is the article that ties everything from earlier articles together, so it's also the longest and most step-heavy.

CI/CD has two parts: CI (Continuous Integration) automatically builds and tests whenever the code changes; CD (Continuous Deployment/Delivery) automatically pushes that build to the running environment. We use GitHub Actions as the place the pipeline runs.

One important security point: instead of storing an AWS access key in GitHub (a static key, dangerous if leaked), we use OIDC so GitHub Actions temporarily borrows AWS permissions through an IAM role. This is what AWS recommends today.

Goals

  1. Get the application from Article 6 onto a GitHub repository.
  2. Set up OIDC between GitHub and AWS, create an IAM role for the pipeline.
  3. Attach an instance profile to EC2 so it can pull images from ECR.
  4. Write the workflow: build → push ECR → deploy EC2.
  5. Clean up.

Expected cost

  • GitHub Actions: free on a generous level for public repositories; private repositories have a monthly free minutes quota.
  • OIDC provider, IAM role, instance profile: free.
  • EC2 and ECR: charged as in Article 2 and Article 6 (deducted from credit or within the legacy free tier).

The cost driver is still the EC2 running continuously, which gets cleaned up at the end.

Prep

  • The todo-app application from Article 6 (with server.js, package.json, Dockerfile, .dockerignore).
  • A running EC2 (stand it up again like Article 2 if you cleaned up), with Docker installed. If Docker isn't installed on the EC2 yet, SSH in and run:
sudo dnf install docker -y
sudo systemctl enable --now docker
sudo usermod -aG docker ec2-user   # let ec2-user run docker without sudo

Log out and SSH back in so the docker group membership takes effect.

Set a few variables to reuse (run on your machine):

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=ap-southeast-1

Step 1: Get the code onto GitHub

Create a new repository on GitHub (for example named todo-app). In the todo-app directory on your machine:

git init
git add .
git commit -m "Ung dung todo ban dau"
git branch -M main
git remote add origin https://github.com/<USERNAME>/todo-app.git
git push -u origin main

Step 2: Create the OIDC provider for GitHub in AWS

The OIDC provider lets AWS know and trust the tokens GitHub Actions issues. Each account only needs to create this provider once.

The easiest way is via the console: go to IAM > Identity providers > Add provider, choose OpenID Connect, and enter:

  • Provider URL: https://token.actions.githubusercontent.com (click Get thumbprint)
  • Audience: sts.amazonaws.com

Click Add provider. (Older guides had an extra step to enter the thumbprint manually; that's no longer needed.)

Step 3: Create the IAM role for the pipeline

This role is what GitHub Actions borrows permissions from. Its trust policy restricts it to only your repository, only the main branch — no other repo can use it.

Create a trust-policy.json file on your machine (replace <USERNAME> and make sure the account id is correct):

cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:<USERNAME>/todo-app:ref:refs/heads/main"
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name github-actions-todo \
  --assume-role-policy-document file://trust-policy.json

Attach permissions for the role to push images to ECR (using an AWS-managed policy for convenience):

aws iam attach-role-policy \
  --role-name github-actions-todo \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser

AmazonEC2ContainerRegistryPowerUser allows push/pull to ECR but not creating repositories. This is deliberate: CI only needs to push an image into an existing repository, it shouldn't have permission to create/delete repositories. In a real system, you'd write an even narrower policy scoped to just the needed repository.

Because this role can't create a repository, we create the ECR repository once up front (with your admin account, not through the pipeline):

aws ecr create-repository --repository-name todo-app --region ap-southeast-1 \
  || echo "Repository đã tồn tại, bỏ qua"

Get the role's ARN to use in the workflow:

aws iam get-role --role-name github-actions-todo \
  --query "Role.Arn" --output text

Step 4: Give EC2 permission to pull images from ECR

When deploying, EC2 needs to pull the image from ECR. Instead of storing a key on the EC2, we attach an instance profile to it — an IAM role attached to the instance, giving it permissions automatically.

Create a role for EC2 with a trust policy for the EC2 service:

cat > ec2-trust.json <<'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "ec2.amazonaws.com" },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

aws iam create-role \
  --role-name ec2-ecr-pull \
  --assume-role-policy-document file://ec2-trust.json

aws iam attach-role-policy \
  --role-name ec2-ecr-pull \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly

# Create the instance profile and attach the role to it
aws iam create-instance-profile --instance-profile-name ec2-ecr-pull
aws iam add-role-to-instance-profile \
  --instance-profile-name ec2-ecr-pull \
  --role-name ec2-ecr-pull

Attach the instance profile to the running EC2 (get the instance id as in Article 2):

INSTANCE_ID=$(aws ec2 describe-instances \
  --filters Name=instance-state-name,Values=running \
  --query "Reservations[0].Instances[0].InstanceId" --output text)

aws ec2 associate-iam-instance-profile \
  --instance-id $INSTANCE_ID \
  --iam-instance-profile Name=ec2-ecr-pull

Now on the EC2, the aws ecr get-login-password command works without configuring any key.

Step 5: Add secrets for the deploy step

The workflow will SSH into EC2 to deploy. SSH needs a private key and a host address, stored as GitHub Secrets (the repo's Settings > Secrets and variables > Actions):

  • EC2_HOST: the EC2's public IP.
  • EC2_SSH_KEY: the contents of the devops-key.pem file (the whole thing, including the BEGIN/END lines).

There's a practical point to note here. GitHub Actions runners (the ubuntu-latest, GitHub-hosted kind) run on a very wide and constantly changing IP range, so you can't open port 22 to a specific "runner IP". For the runner to SSH into EC2, the Security Group has to open port 22 to 0.0.0.0/0:

aws ec2 authorize-security-group-ingress \
  --group-id <SG_ID_CUA_EC2> --protocol tcp --port 22 --cidr 0.0.0.0/0

⚠️ Opening SSH to the whole world is a security trade-off. It's acceptable for learning (and we'll clean up the EC2 right after the article), but you should not leave it that way in production. Two more correct approaches in practice:

  • AWS SSM (Systems Manager): deploy with aws ssm send-command instead of SSH, with no need to open inbound port 22 and no need for an SSH key. This is the cleanest way.
  • Self-hosted runner: run the runner inside your own VPC, so it SSHes internally and you never open 22 to the Internet.

The series stays at SSH level for clarity; in real work, prefer SSM.

Step 6: Write the workflow

Create the file .github/workflows/deploy.yml in the repo. Replace <ROLE_ARN> with the role ARN from Step 3:

name: Build and Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write   # needed to obtain the OIDC token
  contents: read

env:
  AWS_REGION: ap-southeast-1
  ECR_REPO: todo-app

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

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: <ROLE_ARN>
          aws-region: ${{ env.AWS_REGION }}

      - name: Login to Amazon ECR
        id: ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        env:
          REGISTRY: ${{ steps.ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $REGISTRY/$ECR_REPO:$IMAGE_TAG .
          docker push $REGISTRY/$ECR_REPO:$IMAGE_TAG
          echo "IMAGE=$REGISTRY/$ECR_REPO:$IMAGE_TAG" >> $GITHUB_ENV

      - name: Deploy to EC2 over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.EC2_HOST }}
          username: ec2-user
          key: ${{ secrets.EC2_SSH_KEY }}
          envs: IMAGE,AWS_REGION
          script: |
            aws ecr get-login-password --region $AWS_REGION \
              | docker login --username AWS --password-stdin "${IMAGE%%/*}"
            docker pull "$IMAGE"
            docker stop todo-app || true
            docker rm todo-app || true
            docker run -d --name todo-app -p 80:3000 --restart unless-stopped "$IMAGE"

A few points in the workflow:

  • The image is tagged with github.sha (the commit hash) instead of latest, so each deploy is tied to exactly one commit and is traceable.
  • The deploy step maps the EC2's port 80 to the container's port 3000 (-p 80:3000), so you can access it normally via http://<EC2_IP>. Make sure the EC2's Security Group opens port 80.
  • --restart unless-stopped so the container restarts itself if the EC2 reboots.

Commit and push the workflow file:

git add .github/workflows/deploy.yml
git commit -m "Them pipeline CI/CD"
git push

Step 7: Watch the pipeline run

Open the repo on GitHub and go to the Actions tab. You'll see the workflow just triggered by the push. Click into it to watch each step: configure AWS, ECR login, build, push, deploy. When everything is green, open http://<EC2_HOST> in a browser — that's the app just deployed automatically.

Try changing a line in server.js, commit and push again. The pipeline runs again and the website updates a few minutes later, without you SSHing or typing any deploy command.

If a step is red, open that step's log. Common errors: wrong role ARN, trust policy not matching the repo/branch name, Security Group blocking SSH from GitHub, or Docker not installed on EC2.

🧹 Cleanup

This article creates many things; the cost drivers are EC2 and ECR, the rest are free but worth cleaning up for tidiness.

Delete the cost drivers first:

# Terminate EC2
aws ec2 terminate-instances --instance-ids $INSTANCE_ID

# Delete the ECR repository
aws ecr delete-repository --repository-name todo-app --region $REGION --force

Clean up the IAM resources created (free, but delete them to keep the account clean):

# Pipeline role
aws iam detach-role-policy --role-name github-actions-todo \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
aws iam delete-role --role-name github-actions-todo

# Instance profile and role for EC2
aws iam remove-role-from-instance-profile \
  --instance-profile-name ec2-ecr-pull --role-name ec2-ecr-pull
aws iam delete-instance-profile --instance-profile-name ec2-ecr-pull
aws iam detach-role-policy --role-name ec2-ecr-pull \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
aws iam delete-role --role-name ec2-ecr-pull

The OIDC provider can be kept so you don't have to create it again next time (it's free and poses no risk when no role trusts it). If you want to delete it outright:

aws iam delete-open-id-connect-provider \
  --open-id-connect-provider-arn arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com

On GitHub, delete the repo or delete the secrets if you no longer use them.

Wrap-up

You just built a pipeline that takes code from Git to production automatically, using OIDC to avoid storing a static AWS key, tagging images by commit, and letting EC2 pull the image via an instance role. This is a basic CI/CD pattern, but headed in the right direction with how it's done for real.

In Article 8 we add the missing piece: monitoring. Once the pipeline deploys automatically, we need to know how the app is running and be alerted when something goes wrong — that's CloudWatch's job.