5 min read
My journey into the Cloud Resume Challenge: automating Frontend Deployment with CI/CD

In the second post, we made the website dynamic by adding a live view counter with Lambda, DynamoDB, and JavaScript.
Now it’s time to remove manual deployments and automate the process using CI/CD.

6. CI/CD Pipeline

Version control setup

I created a new GitHub repository called aws-cloud-resume-challenge and organized it into three folders:

  • frontend/ (for the website)
  • backend/ (for the Lambda function)
  • terraform/

A fourth folder .github/workflows/ is used to store GitHub Actions workflows.

Frontend deployment workflow

We’re going to use GitHub Actions to trigger a website upload to S3 whenever there’s a code change. Create a workflow file called frontend-deploy.yml in .github/workflows/:

name: Frontend - Deploy

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: eu-west-3

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install modules
        run: npm ci
        working-directory: frontend

      - name: Build application
        run: npm run build
        working-directory: frontend

      - name: Deploy to S3
        run: aws s3 sync --delete ./dist/ s3://${{ secrets.AWS_BUCKET_ID }}
        working-directory: frontend

      - name: Create CloudFront invalidation
        run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_DISTRIBUTION_ID }} --paths "/*"

This workflow:

  • Builds the frontend
  • Uploads it to the S3 bucket
  • Invalidates the CloudFront cache so new changes are visible immediately

GitHub will provide the environment variables through its Secrets capability. The values for the bucket and distribution ID are straightforward. Next, let’s configure the necessary IAM user to get Key ID and Access key.

Create an IAM User for GitHub Actions

To allow GitHub Actions to interact with AWS, I created an IAM User with a minimal following the least privilege principle.
The user needs permissions to interact with the S3 bucket and invalidate CloudFront caches.

{
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "S3Frontend"
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:PutObjectAcl",
          "s3:DeleteObject",
          "s3:ListBucket",
          "s3:GetObject"
        ]
        Resource = [
          var.s3_bucket_arn,
          "${var.s3_bucket_arn}/*"
        ]
      },
      {
        Sid    = "CloudFrontInvalidation"
        Effect = "Allow"
        Action = [
          "cloudfront:CreateInvalidation",
          "cloudfront:GetInvalidation",
          "cloudfront:ListInvalidations",
          "cloudfront:GetDistribution",
          "cloudfront:ListDistributions"
        ]
        Resource = "*"
      }
    ]
  }

The IAM user is created with Terraform, using the ci_user module.
By default, Terraform does not print Key ID and Access key outputs. You can enable them like this:

output "github_actions_access_key_id" {
  value     = aws_iam_access_key.github.id
  sensitive = true
}

output "github_actions_secret_access_key" {
  value     = aws_iam_access_key.github.secret
  sensitive = true
}

Then run:

$ terraform plan
$ terraform apply
$ terraform output github_actions_access_key_id
$ terraform output github_actions_secret_access_key

and add the values to GitHub Secrets.

7. Frontend Testing with Cypress

I wanted to ensure the website still works before each deployment, so I added end-to-end tests with Cypress.

Configure Cypress

Install Cypress:

npm install cypress --save-dev

Create cypress.config.js under frontend/

import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    supportFile: false,
    baseUrl: "http://localhost:4321",
  },
});

Add a test file in frontend/cypress/e2e/index.cy.js to check title, view counter, blog list, and personal section.

beforeEach(() => {
  cy.visit("/");
});

it("titles are correct", () => {
  cy.get("title").should(
    "have.text",
    "The Cloud Resume Challenge | giorgiodg.cloud"
  );
  cy.get("h1")
    .invoke("text")
    .then((text) => {
      expect(text.trim()).to.equal("The Cloud Resume Challenge");
    });
});

it("view counter is visualized", () => {
  cy.get("#views").contains("Views:");
});

it("there's a blogpost section and contains a list with at least one item", () => {
  cy.get("section")
    .eq(1)
    .within(() => {
      cy.get("ul > li").its("length").should("be.gte", 1);

      cy.get("ul > li").each(($li) => {
        cy.wrap($li).find("a").should("have.attr", "href").and("not.be.empty");
      });
    });
});

it("there's a section with name and personal links", () => {
  cy.get("section")
    .eq(2)
    .within(() => {
      cy.get("h5")
        .invoke("text")
        .then((text) => {
          expect(text.trim()).to.equal("Giorgio Delle Grottaglie");
        });

      cy.get("button").its("length").should("be.gte", 1);
    });
});

Run tests locally to verify the setup:

npx cypress run

Frontend E2E tests workflow

Create frontend-e2e-tests.yml to run Cypress tests in GitHub Actions:

name: Frontend - E2E Tests

on:
  push:
    branches:
      - main
    paths:
      - "frontend/**"
  pull_request:
    paths:
      - "frontend/**"
  workflow_dispatch:

jobs:
  cypress-tests:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Use Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install modules
        run: npm ci
        working-directory: frontend

      - name: Build application
        run: npm run build
        working-directory: frontend

      - name: Start preview server
        run: npm run preview &
        working-directory: frontend

      - name: Run Cypress tests
        uses: cypress-io/github-action@v6
        with:
          wait-on: http://localhost:4321
          working-directory: frontend

This workflow runs automatically when code in frontend/ changes. We can then make deployment dependent on these tests passing.

Deploy workflow dependency

Modify frontend-deploy.yml to trigger only after successful E2E tests:

name: Frontend - Deploy

on:
  workflow_run:
    workflows: ["Frontend - E2E Tests"]
    types:
      - completed

jobs:
  deploy:
    if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' }}
    runs-on: ubuntu-latest

[...]

Next Steps

With this post, I automated frontend deployment and added end-to-end testing. Future enhancements include:

  • Infrastructure automated deployment
  • Lambda automated deployment
  • Lambda E2E testing
  • API Gateway integration
  • Making the Lambda URL dynamic on the frontend
  • Tracking unique users

I’ll prioritize these improvements using the MoSCoW method and implement the most important items based on the time available.

  • AWS
  • cloud
  • cloud resume challenge
  • DevOps