CI/CD with GitHub Actions: Automatically Build and Deploy to AWS
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
- Get the application from Article 6 onto a GitHub repository.
- Set up OIDC between GitHub and AWS, create an IAM role for the pipeline.
- Attach an instance profile to EC2 so it can pull images from ECR.
- Write the workflow: build → push ECR → deploy EC2.
- 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-appapplication from Article 6 (withserver.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
AmazonEC2ContainerRegistryPowerUserallows 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 thedevops-key.pemfile (the whole thing, including theBEGIN/ENDlines).
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-commandinstead 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 oflatest, 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 viahttp://<EC2_IP>. Make sure the EC2's Security Group opens port 80. --restart unless-stoppedso 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.