RidvanOzaydin.com

CI/CD With Github


Github Actions is a CI/CD tool that is built into Github. It is a great tool to get started. And it has ever growing plethora of actions. In this blog post i want to share with you how to create a CI/CD pipeline with Github Actions. And the example will be the very much this page you are reading right now. First lets go over the architecture we have for this website.

Architecture

This website relies on AstroJS and is hosted on AWS S3. The CI/CD pipeline will be triggered manually. The pipeline will build the website and deploy it to S3. The pipeline will also invalidate the CloudFront cache so that the changes are reflected immediately.

image architecture

As you can see, nothing fancy here. It’s all well known classic static website hosting using AWS services. Now lets get into the details of the pipeline.

We want to achieve the following:

The above part is what actually makes this blog (in my opinion) interesting. So in short we will need a user with no permissions except assuming a role. And we will have a role that has the necessary permissions to deploy the website. And we will have a GitHub action that will assume the role and deploy the website.

Creating the AWS User and The AWS Role

Below terraform code encapsulates the logic that we described above. It will create a user and a role. The role has only necessary permissions and nothing more. Further improvements can be applied such as restricting the role to only copy to specific S3 bucket and only invalidating specific cloudfront distribution.

# CI/CD Role for publishing web page and invalidating cloudfront cache
resource "aws_iam_role" "cicd_role" {

  name = "cicd-role"
  tags = var.tags

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "sts:AssumeRole",
          "sts:TagSession"
        ]
        Effect = "Allow"
        Principal = {
          AWS = ["_use_your_aws_account_id_here_"]
        }
      }
    ]
  })

  inline_policy {
    name = "access_policy"
    policy = jsonencode({
      Version = "2012-10-17"
      # Cloudfront invalidation
      Statement = [
        {
          Action = [
            "cloudfront:UpdateDistribution",
            "cloudfront:DeleteDistribution",
            "cloudfront:CreateInvalidation"
          ]
          Effect   = "Allow"
          Resource = "*"
        },
        {
          # For uploading to S3
          Action = [
            "s3:DeleteObject",
            "s3:GetBucketLocation",
            "s3:GetObject",
            "s3:ListBucket",
            "s3:PutObject"
          ]
          Effect   = "Allow"
          Resource = "*"
        }
      ]
    })
  }
}

data "aws_iam_policy_document" "cicd_user_policy" {
  statement {
    effect = "Allow"
    actions = [
      "sts:AssumeRole",
      "sts:TagSession"
    ]
    resources = [aws_iam_role.cicd_role.arn]
  }
}

resource "aws_iam_user" "cicd_user" {
  depends_on    = [aws_iam_role.cicd_role]
  name          = "cicd-user"
  tags          = var.tags
  force_destroy = true
}

resource "aws_iam_user_policy" "cicd_user_policy" {
  name   = "cicd_policy"
  user   = aws_iam_user.cicd_user.name
  policy = data.aws_iam_policy_document.cicd_user_policy.json
}

resource "aws_iam_access_key" "cicd_user_access_key" {
  user = aws_iam_user.cicd_user.name
}

Please ensure you have the following lines in your outputs.tf file.

output "key_id" {
  value = aws_iam_access_key.cicd_user_access_key.id
}

output "key_secret" {
  sensitive = true
  value     = aws_iam_access_key.cicd_user_access_key.secret
}

Once you run the script please note the access key and secret key. We will need them in the next step.

terraform apply
terraform output -raw key_secret

Creating the Github workflow

Now that we have the AWS user and the role created. We can create the GitHub workflow. The workflow will be triggered manually. And it will assume the role we created in the previous step. Also please create following secrets in your repository.

aws_access_key_id and aws_secret_access_key are the credentials of the user we created in the previous step. IAM_ROLE is the name of the role we created in the previous step. And AWS_BUCKET is the name of the S3 bucket that we will be deploying the website to.

And finally the workflow file ( you need to have it under .github/workflows folder ):

name: Deploy

on:
  workflow_dispatch:

env:
  AWS_REGION: eu-central-1

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18.x]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: "npm"
      - run: npm install
      - run: npm run build --if-present

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          role-to-assume: ${{ secrets.IAM_ROLE }}
          role-session-name: cicd-session
          aws-region: ${{ env.AWS_REGION }}
          role-duration-seconds: 900
      - name: Upload to S3 Bucket
        # A local file will require uploading if the size of the local file is different than the size of the s3 object,
        run: |
          aws s3 sync ./dist s3://${{ secrets.AWS_BUCKET }}
      - name: Invalidate Cloudfront Distribution
        run: |
          aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION_ID }} --paths "/*"

And that’s it. Here is what i see in my repository when i click on the actions tab.

image github_action

Thanks for reading! And hope it can improve your daily workflow somehow.