Welcome to Chapter 13! So far, we’ve meticulously built a robust, production-ready Node.js application, complete with a well-structured codebase, comprehensive testing, secure authentication, and a Dockerized environment. In the previous chapter, we finalized our Docker setup, ensuring our application can be consistently built and run across different environments. Now, it’s time to automate the process of getting our code from development to a deployable artifact.

This chapter will guide you through setting up a Continuous Integration/Continuous Deployment (CI/CD) pipeline using GitHub Actions. Our primary goal is to automate the building of our Docker image, running tests, and pushing the resulting image to AWS Elastic Container Registry (ECR). This automation is crucial for modern development workflows, enabling faster, more reliable, and consistent deployments. By the end of this chapter, any code pushed to our main branch will automatically trigger a workflow that validates our code, builds a new Docker image, and makes it available in ECR, ready for deployment.

Planning & Design

A well-designed CI/CD pipeline is the backbone of efficient software delivery. For our Node.js application, we’ll design a pipeline that integrates seamlessly with GitHub and AWS.

Component Architecture

The following diagram illustrates the workflow we’ll establish:

flowchart TD A["Developer Pushes Code to GitHub"] -->|Trigger Workflow| C["GitHub Actions"] C -->|Checkout Code| D["Build & Test Node.js App"] D -->|Build Docker Image| E["Docker Build"] E -->|Authenticate to AWS ECR| F["AWS ECR Login"] F -->|Push Docker Image| G["AWS Elastic Container Registry (ECR)"] G --> H["Image Ready for Deployment"]

Explanation:

  1. Developer Pushes Code: Any push event to our main branch (or a pull request merge) will initiate the CI/CD workflow.
  2. GitHub Repository: Our source code repository, hosted on GitHub.
  3. GitHub Actions: GitHub’s native CI/CD service. It orchestrates the entire pipeline based on our defined workflow file.
  4. Checkout Code: The workflow starts by fetching our latest code from the repository.
  5. Build & Test Node.js App: This step involves installing Node.js dependencies, running linting checks, and executing our unit and integration tests. This ensures code quality and correctness before proceeding.
  6. Docker Build: If tests pass, a Docker image of our application is built using the Dockerfile we prepared in the previous chapter.
  7. Authenticate to AWS ECR: GitHub Actions will use AWS credentials (stored securely as GitHub Secrets) to authenticate with AWS ECR.
  8. Push Docker Image: The newly built and tagged Docker image is pushed to our dedicated ECR repository.
  9. AWS Elastic Container Registry (ECR): A fully managed Docker container registry that makes it easy to store, manage, and deploy Docker container images.
  10. Image Ready for Deployment: Once in ECR, the Docker image is ready to be pulled by services like AWS ECS (which we’ll cover in the next chapter) for deployment.

File Structure

We will introduce a new directory and file for our GitHub Actions workflow:

.
├── .github/
│   └── workflows/
│       └── main.yml  # Our CI/CD workflow definition
├── src/
├── tests/
├── Dockerfile
├── package.json
└── ...

Step-by-Step Implementation

Let’s get started with setting up our CI/CD pipeline.

1. Setup/Configuration

Before we write our GitHub Actions workflow, we need to prepare our AWS environment and secure our credentials.

a) Create an AWS ECR Repository

We need a place in AWS to store our Docker images. ECR provides this functionality.

  1. Log in to AWS Console: Go to https://aws.amazon.com/console/ and log in.
  2. Navigate to ECR: Search for “ECR” in the services search bar and select “Elastic Container Registry”.
  3. Create Repository:
    • Click on “Create repository”.
    • Visibility settings: Choose “Private”.
    • Repository name: Enter a descriptive name, e.g., nodejs-api-repo.
    • Leave other settings as default for now.
    • Click “Create repository”.

Keep note of your Repository URI (e.g., 123456789012.dkr.ecr.your-region.amazonaws.com/nodejs-api-repo). We’ll need this later.

b) Create an AWS IAM User for GitHub Actions

For GitHub Actions to interact with AWS ECR, it needs secure credentials. We’ll create a dedicated IAM user with minimal necessary permissions following the principle of least privilege.

  1. Navigate to IAM: Search for “IAM” in the AWS console and select “Identity and Access Management”.
  2. Create User:
    • Go to “Users” in the left navigation pane and click “Create user”.
    • User name: Enter github-actions-user.
    • AWS credential type: Check “Access key - Programmatic access”.
    • Click “Next”.
  3. Set Permissions:
    • Select “Attach policies directly”.
    • Search for and select the policy AmazonECRContainerRegistryPowerUser. This policy grants sufficient permissions to push and pull images from ECR. For production, you might create a custom policy with even tighter restrictions, but this is a good starting point.
    • Click “Next”.
  4. Review and Create: Review the user details and click “Create user”.
  5. Store Credentials: Crucially, copy the Access key ID and Secret access key immediately. These will only be shown once. If you lose them, you’ll have to create new credentials.
c) Configure GitHub Repository Secrets

To secure our AWS credentials, we will store them as secrets in our GitHub repository. This prevents them from being exposed in our workflow files or logs.

  1. Navigate to GitHub Repository Settings: Go to your project’s GitHub repository.
  2. Go to Secrets: Click on “Settings” -> “Secrets and variables” -> “Actions”.
  3. Add Repository Secrets:
    • Click “New repository secret”.
    • Name: AWS_ACCESS_KEY_ID
    • Secret: Paste the Access key ID you copied from AWS.
    • Click “Add secret”.
    • Repeat for the secret key:
      • Name: AWS_SECRET_ACCESS_KEY
      • Secret: Paste the Secret access key you copied from AWS.
      • Click “Add secret”.
    • Add one more for the AWS region:
      • Name: AWS_REGION
      • Secret: Enter your AWS region, e.g., us-east-1, eu-west-2.
      • Click “Add secret”.

2. Core Implementation

Now that our environment is configured, let’s create the GitHub Actions workflow file.

a) Create GitHub Actions Workflow File

Create the directory .github/workflows at the root of your project, and then create a file named main.yml inside it.

File: .github/workflows/main.yml

name: CI/CD Pipeline to AWS ECR

on:
  push:
    branches:
      - main # Trigger on push to the main branch
  pull_request:
    branches:
      - main # Optionally trigger on pull requests to main for early feedback

env:
  AWS_REGION: ${{ secrets.AWS_REGION }}
  ECR_REPOSITORY: nodejs-api-repo # Replace with your ECR repository name
  IMAGE_TAG: ${{ github.sha }} # Use commit SHA as image tag for uniqueness

jobs:
  build-and-push:
    runs-on: ubuntu-latest # The type of runner that the job will run on

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4 # Action to check out your repository code

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20' # Ensure this matches your project's Node.js version

      - name: Install dependencies
        run: npm ci # Use npm ci for clean installs in CI environments

      - name: Run tests
        run: npm test # Execute your project's test suite

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

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

      - name: Build and tag Docker image
        run: |
          docker build -t ${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} .
          docker tag ${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }} ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
        # The 'latest' tag is useful for development/staging, but for production,
        # using the commit SHA or a semantic version tag is highly recommended
        # to ensure immutability and easy rollback.

      - name: Push Docker image to ECR
        run: |
          docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ env.IMAGE_TAG }}
          docker push ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest

Explanation of the Workflow File:

  • name: A descriptive name for your workflow.
  • on: Defines when the workflow should run. Here, it triggers on push and pull_request events to the main branch.
  • env: Defines environment variables available to all jobs and steps in the workflow.
    • AWS_REGION: Pulled from GitHub secrets.
    • ECR_REPOSITORY: Your ECR repository name.
    • IMAGE_TAG: We’re using github.sha which is the full SHA of the commit that triggered the workflow. This creates a unique and traceable tag for each image. We also tag latest for convenience.
  • jobs: A workflow run is made up of one or more jobs.
    • build-and-push: The name of our single job.
    • runs-on: ubuntu-latest: Specifies the virtual environment where the job will execute.
    • steps: A sequence of tasks that are executed as part of the job.
      • Checkout repository: Uses actions/checkout@v4 to clone your repository into the runner.
      • Setup Node.js: Uses actions/setup-node@v4 to install the specified Node.js version.
      • Install dependencies: Runs npm ci to install project dependencies. npm ci is preferred over npm install in CI environments for faster, more reliable, and reproducible builds as it uses package-lock.json.
      • Run tests: Executes npm test. This is a critical step for CI. If tests fail, the workflow stops, preventing a broken image from being pushed.
      • Configure AWS credentials: Uses aws-actions/configure-aws-credentials@v4 to configure the AWS CLI with our secret credentials.
      • Login to Amazon ECR: Uses aws-actions/amazon-ecr-login@v2 to authenticate Docker with our ECR registry. The id: login-ecr allows us to reference its output (like the registry URI) in subsequent steps.
      • Build and tag Docker image:
        • docker build -t ...: Builds the Docker image using our Dockerfile in the current directory (.). It tags the image with the repository name and the unique commit SHA.
        • docker tag ...: Creates an additional latest tag for convenience, pointing to the same image. In a production setup, you would typically use more specific version tags or only the SHA for immutable deployments.
      • Push Docker image to ECR: Pushes both the SHA-tagged and latest-tagged images to ECR.

3. Testing This Component

To test our CI/CD pipeline, you simply need to push a change to your main branch.

  1. Commit your changes:

    git add .github/workflows/main.yml
    git commit -m "feat: Add GitHub Actions CI/CD for ECR"
    
  2. Push to GitHub:

    git push origin main
    
  3. Monitor GitHub Actions:

    • Go to your GitHub repository.
    • Click on the “Actions” tab.
    • You should see a new workflow run initiated by your push. Click on it to view the progress of each step.
    • All steps should pass successfully.
  4. Verify Image in AWS ECR:

    • Once the GitHub Actions workflow completes successfully, go back to your AWS Console.
    • Navigate to ECR and select your nodejs-api-repo repository.
    • You should see at least two new image tags: one with the full commit SHA (e.g., 1a2b3c4d5e6f...) and one with latest.

Production Considerations

Building a CI/CD pipeline isn’t just about automation; it’s about building a reliable and secure system.

  • Security (IAM & GitHub Secrets):
    • Least Privilege: We followed this by granting only AmazonECRContainerRegistryPowerUser permissions to our github-actions-user. In a real-world scenario, you might create an even more granular custom policy that only allows pushing to specific repositories.
    • GitHub Secrets: Using GitHub Secrets is paramount for protecting sensitive credentials. Never hardcode AWS keys or other secrets directly in your workflow files.
  • Performance:
    • Docker Layer Caching: The docker build command benefits from Docker’s layer caching. If the base image or earlier layers haven’t changed, Docker reuses cached layers, speeding up builds. Ensure your Dockerfile is optimized to take advantage of this.
    • npm ci vs npm install: As mentioned, npm ci is faster and more reliable in CI environments.
  • Error Handling and Notifications:
    • GitHub Actions provides built-in notifications for workflow failures. You can configure email notifications directly in GitHub repository settings under “Notifications” or integrate with external services like Slack using GitHub Actions marketplace actions (e.g., rtCamp/action-slack-notify).
    • Ensure your npm test command exits with a non-zero code on failure to stop the workflow.
  • Image Tagging Strategy:
    • Using github.sha for unique image tags is a best practice for production. It ensures immutability and makes it easy to trace which code version corresponds to which deployed image.
    • The latest tag is convenient but should be used with caution in production, as it can be ambiguous. For production deployments, always reference specific, immutable tags (like the commit SHA or a semantic version).
  • Environment Variables for Docker Build: If your application needs environment variables during the Docker build process (e.g., NODE_ENV=production), ensure they are passed correctly in your docker build command using --build-arg. For runtime environment variables, these will be handled during deployment (e.g., via ECS task definitions).

Code Review Checkpoint

At this point, you should have:

  • AWS ECR Repository: A private repository created in AWS ECR.
  • AWS IAM User: A dedicated IAM user with programmatic access and AmazonECRContainerRegistryPowerUser policy attached.
  • GitHub Secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION configured in your GitHub repository secrets.
  • GitHub Actions Workflow: A .github/workflows/main.yml file that:
    • Triggers on pushes to main.
    • Checks out code, sets up Node.js, installs dependencies, and runs tests.
    • Configures AWS credentials and logs into ECR.
    • Builds and tags a Docker image (with commit SHA and latest).
    • Pushes the Docker image to your ECR repository.

This setup forms the foundation of our automated deployment process.

Common Issues & Solutions

  1. AccessDeniedException when pushing to ECR:
    • Issue: The IAM user credentials used by GitHub Actions do not have sufficient permissions to push images to ECR.
    • Solution: Double-check that the AmazonECRContainerRegistryPowerUser policy (or a custom policy with equivalent permissions) is attached to the github-actions-user in AWS IAM. Ensure the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets are correct and correspond to this IAM user.
  2. docker build fails in GitHub Actions:
    • Issue: The Docker build process encounters an error, often due to issues in the Dockerfile or missing files.
    • Solution: Review the logs in the GitHub Actions run carefully. The output from docker build will indicate the specific error. Common causes include incorrect paths in COPY commands, missing build dependencies, or syntax errors in the Dockerfile. Ensure your Dockerfile works locally before pushing.
  3. Error: No credentials found. or Not authorized to perform: ecr:GetAuthorizationToken:
    • Issue: GitHub Actions failed to configure AWS credentials or log into ECR.
    • Solution: Verify that AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION are correctly set as repository secrets in GitHub, and that the aws-actions/configure-aws-credentials@v4 step and aws-actions/amazon-ecr-login@v2 steps are configured correctly in your workflow file.
  4. npm test fails unexpectedly in CI:
    • Issue: Tests pass locally but fail in the CI environment.
    • Solution: This can happen due to environment differences. Check Node.js versions (ensure setup-node matches your local version), environment variables, or platform-specific issues. Make sure your tests are robust and don’t rely on local environment specifics. Review the test logs in GitHub Actions for detailed error messages.

Testing & Verification

To ensure everything is correctly set up and working:

  1. Make a minor code change: Introduce a small, harmless change to a non-critical file (e.g., add a comment to src/app.ts).
  2. Commit and push: Commit this change and push it to your main branch.
  3. Monitor GitHub Actions: Observe the workflow run on the “Actions” tab of your GitHub repository.
    • Verify that all steps (Checkout, Setup Node.js, Install dependencies, Run tests, Configure AWS credentials, Login to ECR, Build & tag Docker image, Push Docker image) complete successfully with green checkmarks.
  4. Verify ECR Images: Once the workflow finishes, navigate to your ECR repository in the AWS Console. Confirm that a new image with the latest commit SHA tag and an updated latest tag are present.

If all these checks pass, congratulations! You have successfully implemented a robust CI/CD pipeline for your Node.js application, automating the build, test, and container image publishing process.

Summary & Next Steps

In this chapter, we achieved a significant milestone by establishing a Continuous Integration/Continuous Deployment (CI/CD) pipeline using GitHub Actions and AWS ECR. We configured AWS credentials securely, created an ECR repository, and crafted a workflow that automatically builds, tests, and pushes our Dockerized Node.js application to ECR upon every push to the main branch. This automation dramatically improves our development efficiency, ensures code quality, and prepares our application for rapid deployment.

With our Docker images now residing in AWS ECR, ready for consumption, the next logical step is to deploy them to a scalable and managed container orchestration service. In Chapter 14: Deploying to AWS ECS Fargate, we will leverage AWS Elastic Container Service (ECS) with the Fargate launch type to deploy our application, ensuring high availability, scalability, and ease of management. We’ll define task definitions, services, and clusters to bring our production-ready Node.js application to life in the cloud.