Building and deploying React application in Docker

2024-05-11

This post describes how to build and deploy a React application in a Docker container. The post assumes that you have a React application that you want to deploy and that you have a Docker environment set up. After following the steps in this post, you should have a React application that is built in a Docker container and deployed to a server.

Dockerfile#

The application is written in pure React, without any server components. That means, it builds into a set of static files and can be served with a web server. Initially, I tried to use Nginx, but it didn't go well. I didn't want to spend more time and switched to the serve package. The Dockerfile looks like this:

####################################################################################################
## Builder
####################################################################################################
FROM node:22 as builder

# Set up a working directory
WORKDIR /usr/src/myapp

# Copy the package.json and package-lock.json and install dependencies
COPY package.json package-lock.json ./
RUN npm install

# Accept environment variable
ARG REACT_APP_CLERK_PUBLISHABLE_KEY

# Copy the source code into the Docker image
COPY . .
RUN REACT_APP_CLERK_PUBLISHABLE_KEY=${REACT_APP_CLERK_PUBLISHABLE_KEY} npm run build

####################################################################################################
## Final image
####################################################################################################
FROM alpine:latest

# Install Node.js and npm
RUN apk add --update nodejs npm

# Install serve globally
RUN npm install -g serve

COPY --from=builder /usr/src/myapp/build /myapp

WORKDIR /myapp

# Start the server
ENTRYPOINT ["serve", "-s", "."]

# Expose the port serve runs on
EXPOSE 3000

The serve thing might be a performance issue in production environment, but it's fine for development. This line RUN REACT_APP_CLERK_PUBLISHABLE_KEY=${REACT_APP_CLERK_PUBLISHABLE_KEY} npm run build helps to pass an environment variable to the build process - since it is a React application, the environment variables should be built-in during the build process.

I tried to use node:alpine for the image, but it takes more than 1GB of space. So, here I use node:22 which takes only around 60 MB.

Github Workflow#

The Github Workflow is used to automate the build and push process. The workflow looks like this:

#
name: Build and Push Docker Dev Image

on:
  push:
    branches:
      - main

env:
  IMAGE_NAME: myappbin-client

jobs:
  push:
    runs-on: self-hosted
    permissions:
      packages: write
      contents: read

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

      - name: Build Docker Image
        run: docker build . --file Dockerfile --tag $IMAGE_NAME --label "runnumber=${GITHUB_RUN_ID}" --build-arg REACT_APP_CLERK_PUBLISHABLE_KEY=${{ secrets.DEV_CLERK_PUBLISHABLE_KEY }}

      - name: Log in to registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin

      - name: Push image
        run: |
          IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
          # This changes all uppercase characters to lowercase.
          IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
          # This strips the git ref prefix from the version.
          VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
          # This strips the "v" prefix from the tag name.
          [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
          # This uses the Docker `latest` tag convention.
          [ "$VERSION" == "main" ] && VERSION=latest
          echo IMAGE_ID=$IMAGE_ID
          echo VERSION=$VERSION
          docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
          docker push $IMAGE_ID:$VERSION

      - name: Trigger Portainer Webhook
        run: curl -X POST ${{ secrets.DEV_PORTAINER_WEBHOOK_URL }}

This line --build-arg REACT_APP_CLERK_PUBLISHABLE_KEY=${{ secrets.DEV_CLERK_PUBLISHABLE_KEY }} helps to pass an environment variable to the build process.

Regarding the self-hosted runner and Trigger Portainer Webhook, please refer to my another post about deploying Rust on arm64 for more details.

Configuring nginx#

The Nginx configuration is absolutely standard. Assuming that the container is listening on port 8001, the proxy-part looks like this:

    location / {
        proxy_pass http://127.0.0.1:8001;
        proxy_set_header Host $http_host;
        proxy_set_header X-NginX-Proxy true;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }

Conclusion#

A React application can easily be deployed in a Docker container. The serve package is a good choice for serving static files. The Github Workflow is a good way to automate the build and push process. The Nginx reverse-proxy configuration is standard and easy to set up.