Build a Container Package for your React App with Docker and GitHub Actions

Kaye Alvarado - Oct 10 - - Dev Community

This is the first part of a series for Code Pipeline: Deploying a React App in AWS. There's a lot of blogs already around this topic but there's a particular use-case that I want to document in an easy-to-follow guide for DevOps Engineers.

Below are some of the technologies that I will briefly touch on in the entirety of this series (some at a later point): #containers #docker #dockerfile #react #GitHubActions #ecr #ecs #fargate #iam

What am I Doing?

Image description

The image above depicts the sequence of steps that we're trying to do here. Taking from a React UI code, we will build a simple pipeline in Github Actions to package it into a container using a Dockerfile and store the package in Amazon ECR (Elastic Container Registry) for future deployment which we'll tackle in the next parts of the series.

Create a React Application

React has a Quick Start guide here to do the first step. In my case, I want to create a React App based on a TypeScript template so I will append --template typescript to the creation command.

npx create-react-app react-ui --template typescript
Enter fullscreen mode Exit fullscreen mode

This will create a directory called react-ui in the current folder with an initial project structure of the application. Navigate to the folder and from here, run the app locally on your machine.

cd react-ui
npm start
Enter fullscreen mode Exit fullscreen mode

Image description

Now, let's create a Dockerfile in the same directory and put the following code:

FROM node:20-alpine as build
WORKDIR /app
ENV PATH=/app/node_modules/.bin:$PATH
COPY ./package*.json /app/
RUN npm install
COPY . /app
RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

npm install ensures that all dependencies are installed in the container runtime. As best practice, we can add a .dockerignore file in the react-ui directory so that the script will explicitly ignore files or directories that are not needed when moving files around. For example, I can add node_modules in .dockerignore so that it will not be copied in the container being built when I run the copy command.

Build and Tag the Image: From the root directory, run the following command to build and tag the image:

docker build -f Dockerfile -t react-ui:latest .
Enter fullscreen mode Exit fullscreen mode

Then, check the details:

$ docker image ls
REPOSITORY   TAG         IMAGE ID       CREATED         SIZE
react-ui     latest      63ac15re6a37   1 minute ago    135MB
Enter fullscreen mode Exit fullscreen mode

Test Run the Application: Run the container using the following command:

docker run -it -p 80:3000 --rm react-ui:latest
Enter fullscreen mode Exit fullscreen mode

You can use any port when running the container to route to the exposed port 3000. You can then use this same port when testing the application in a web browser http://localhost:80

Think like a DevOps Engineer: Build a Pipeline for Reusability

Now, everytime a software developer updates the react app code, it doesn't make sense for someone to manually run these commands on their machine to deploy the changes to a production server.

It is recommended to store the code in a repository like GitHub. Then we can make use of a workflow to automatically run scripts ensuring accuracy in deployments.

In my GitHub Workflow, I will trigger a pipeline that has the following steps:

  • Checkout the react code from GitHub to the GitHub runner
  • Generate a random tag to the package
  • Build the image, then push the image to ECR

Checkout the Code from GitHub

The action below is one of the commonly used actions in building a GitHub Actions pipeline. This will simply download the code being built to the GitHub runner that spins up.

      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
          ref: ${{ github.ref_name }}
Enter fullscreen mode Exit fullscreen mode

Generate a Random Tag to the Package

There are several ways on how to uniquely tag a package in ECR. For my use-case, I'd like a simple logic to add a date in the tag so I will add an action to pull the date.

      - name: "Get current date"
        id: date
        run: echo "::set-output name=date::$(date +'%Y%m%d')"
Enter fullscreen mode Exit fullscreen mode

Using this, I can create an environment variable that can be used on other actions with this string, along with another random number such as the github.run_number.

IMAGE_TAG: ${{ steps.date.outputs.date }}-${{ github.run_number }}
Enter fullscreen mode Exit fullscreen mode

Now, let's slightly modify the Dockerfile with a few more commands. Assume that the react application now includes packages that needs to be downloaded from 3rd party package repositories. We would need to include an .npmrc file temporarily to the Dockerfile to allow the image runtime to pull the package with authorization to the package repository.

FROM node:20-alpine AS build
ARG AUTH
ARG EMAIL
WORKDIR /app
ENV PATH=/app/node_modules/.bin:$PATH
COPY ./package*.json /app/
COPY ./.npmrc /app/
RUN echo "//registry.npmjs.org/:_auth=$AUTH" >> .npmrc && \
    echo "email = $EMAIL" >> .npmrc && \
    echo "always-auth = true" >> .npmrc && \    
    npm install --force && \
    rm -f .npmrc
COPY . /app

FROM node:20-alpine 
WORKDIR /app
COPY --from=build /app /app

RUN npm run build

EXPOSE 3000

CMD ["npm", "start"]
Enter fullscreen mode Exit fullscreen mode

Notice here that I added two arguments to pass the authentication at runtime. I will then remove the .npmrc file from the package after the npm install. I also added another stage to also remove the auth from the docker history (basically doing multi-stage builds as explained here). There are other more modern ways to solve this like using Docker build secrets, details are also explained on the linked blog. Wow, I like saying "also"! 😅

Build the Image and Push it to Amazon ECR

Now that everything is ready let's continue with the pipeline. To further interact with other AWS services from the runner, we will need to authenticate in AWS and also (also?!) login to Amazon ECR.

      - name: "Configure AWS credentials"
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }}
          aws-region: us-east-1
          role-session-name: ReactDeployment
          role-duration-seconds: 3600
          role-skip-session-tagging: true

      - name: "Login to Amazon ECR"
        id: ecr-config
        uses: aws-actions/amazon-ecr-login@v1
        with:
          mask-password: true
Enter fullscreen mode Exit fullscreen mode

Now my docker commands will look something like this:

      - name: "Build & tag, then push image to Amazon ECR"
        env:
          ECR_REGISTRY: ${{ steps.ecr-config.outputs.registry }}
          ECR_REPOSITORY: ${{ env.IMAGE_NAME }}
          IMAGE_TAG: ${{ steps.date.outputs.date }}-${{ github.run_number }}
        run: |
          docker build -f Dockerfile --build-arg AUTH=${{ secrets.AUTH }} --build-arg EMAIL=${{ secrets.EMAIL }} -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
Enter fullscreen mode Exit fullscreen mode

As a bonus, I added some logic below on how I intend to identify my "latest" image in ECR, which is based on the date that it was pushed:

      - name: Get latest image tag
        id: get-latest-tag
        run: |
          image=$(aws ecr describe-images \
            --repository-name react-ui \
            --output text \
            --query 'sort_by(imageDetails,&imagePushedAt)[-1].imageTags[0]')
          echo "latest=$image" >> $GITHUB_ENV
Enter fullscreen mode Exit fullscreen mode

That's it! Stay tuned on the next part of this series where I intend to explain one of the options to deploy this image in AWS. See you then!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .