Creating a web application using Yarn workspace, TypeScript, esbuild, React, and Express — advanced

This article will guide you through setting up a basic web application using Yarn's workspace, TypeScript, esbuild, Express, and React. At the end of this tutorial you will have a fully buildable and deployable web application.

  1. Setting up the project — part 1
  2. Adding code — part 2
  3. Building the app — part 3
  4. Going further — advanced
    1. Docker
    2. Building and publishing our Docker image with GitHub Actions
    3. Deploying our Docker image to Heroku automatically
    4. Summary

Want to see the full code? Check out the repository on GitHub at halftheopposite/tutorial-app.


This post comes as an appendix to the previous 3-part tutorial on building a modern web application. The focus of this appendix is to help you deploy your application using containers (Docker), continuous integration (GitHub Actions), and continuous deployment (Heroku). The information below can be applied to any existing application, but for simplicity I will use the app that we have built so far.

Docker 🐳

This section will assume that you are already familiar with the concept of containers.

To be able to create an image out of our code, we will need to have Docker installed on our machine. To see how to install it based on the OS, please take a moment to look at the official documentation.

Dockerfile

To generate a Docker image, the first step is to create a Dockerfile at the root of our project (these steps could be done entirely through the CLI, but using a configuration file is the default way of defining build steps).

1FROM node:14.15.5-alpine
2
3WORKDIR /usr/src/app
4
5# Install dependencies early so that if some files in our app
6# change, Docker won't have to download the dependencies again,
7# and instead will start from the next step ("COPY . .").
8COPY ./package.json .
9COPY ./yarn.lock .
10COPY ./packages/app/package.json ./packages/app/
11COPY ./packages/common/package.json ./packages/common/
12COPY ./packages/server/package.json ./packages/server/
13RUN yarn
14
15# Copy all files of our app (except files specified in the .gitignore)
16COPY . .
17
18# Build app
19RUN yarn build
20
21# Port
22EXPOSE 3000
23
24# Serve
25CMD [ "yarn", "serve" ]

I'll try to detail as much as possible what is going on here and why the order of these steps is important:

  1. FROM tells Docker to use the specified base image for the current context. In our case, we want to have an environment ready to run Node.js applications.
  2. WORKDIR sets the current working directory in the container.
  3. COPY copies a file or folder from the current local directory (project's root), to the working directory in the container. As you can see, we only copy files related to dependencies in this step. This is because Docker caches each result of a command on each build as a layer. Since we want to optimize build time and bandwidth, we only want to reinstall dependencies if they have changed (which happens less often than files changes in general).
  4. RUN executes a command in the shell.
  5. EXPOSE is the internal port used for the container (has nothing to do with the PORT env for our app). Any value should be good here, but if you want to know more you can have a look at the official documentation.
  6. CMD purpose is to provide defaults for executing a container.

If you'd like to learn more about these keywords you can check the Dockerfile reference.

Adding a .dockerignore

Using a .dockerignore file is not mandatory but highly recommended to:

  • Ensure you're not copying junk files to your container.
  • Make the use of the COPY command easier.

It works exactly like a .gitignore file if you're already familiar with it. You can copy the content below into a .dockerignore file at the same level of your Dockerfile and it will be picked up automatically.

1README.md
2
3# Git
4.gitignore
5
6# Logs
7yarn-debug.log
8yarn-error.log
9
10# Binaries
11node_modules
12*/*/node_modules
13
14# Builds
15*/*/build
16*/*/dist
17*/*/script.js

Feel free to add any files you'd like to ignore to lighten up your final image.

Building

Now that our app is ready for Docker, we need a way to generate an actual image out of it. To do so, we will add a new command to our root package.json:

1{
2  "name": "my-app",
3  "version": "1.0.0",
4  "license": "MIT",
5  "private": true,
6  "workspaces": ["packages/*"],
7  "devDependencies": {
8    "esbuild": "^0.9.6",
9    "ts-node": "^9.1.1",
10    "typescript": "^4.2.3"
11  },
12  "scripts": {
13    "app": "yarn workspace @my-app/app",
14    "common": "yarn workspace @my-app/common",
15    "server": "yarn workspace @my-app/server",
16    "build": "ts-node ./scripts/build.ts",
17    "serve": "node ./packages/server/dist/index.js",
18    "docker": "docker build . -t my-app" // Add this line
19  }
20}

The docker build . -t my-app commands tells docker to use the current (.) directory to look for a Dockerfile and to name the resulting image (-t) as my-app.

Make sure that you have the Docker daemon running in order to use the docker command in your terminal.

Now that the command is in our project's scripts, you can run it with yarn docker.

You should expect the following terminal output once you run the command:

1Sending build context to Docker daemon  76.16MB
2Step 1/12 : FROM node:14.15.5-alpine
3 ---> c1babb15a629
4Step 2/12 : WORKDIR /usr/src/app
5 ---> b593905aaca7
6Step 3/12 : COPY ./package.json .
7 ---> e0046408059c
8Step 4/12 : COPY ./yarn.lock .
9 ---> a91db028a6f9
10Step 5/12 : COPY ./packages/app/package.json ./packages/app/
11 ---> 6430ae95a2f8
12Step 6/12 : COPY ./packages/common/package.json ./packages/common/
13 ---> 75edad061864
14Step 7/12 : COPY ./packages/server/package.json ./packages/server/
15 ---> e8afa17a7645
16Step 8/12 : RUN yarn
17 ---> 2ca50e44a11a
18Step 9/12 : COPY . .
19 ---> 0642049120cf
20Step 10/12 : RUN yarn build
21 ---> Running in 15b224066078
22yarn run v1.22.5
23$ ts-node ./scripts/build.ts
24Done in 3.51s.
25Removing intermediate container 15b224066078
26 ---> 9dce2d505c62
27Step 11/12 : EXPOSE 3000
28 ---> Running in f363ce55486b
29Removing intermediate container f363ce55486b
30 ---> 961cd1512fcf
31Step 12/12 : CMD [ "yarn", "serve" ]
32 ---> Running in 7debd7a72538
33Removing intermediate container 7debd7a72538
34 ---> df3884d6b3d6
35Successfully built df3884d6b3d6
36Successfully tagged my-app:latest

And that's it! Our image is now created and registred on our machine for Docker to use. If you wish to list the available Docker images, you can run the docker image ls command:

1docker image ls
2REPOSITORY    TAG       IMAGE ID        CREATED          SIZE
3my-app        latest    df3884d6b3d6    4 minutes ago    360MB

Running with the command like

To run an available Docker image through the command line is pretty straightforward: docker run -d -p 3000:3000 my-app.

  • -d runs the container in detached mode (in the background).
  • -p sets the port on which to expose the container (format is [host port]:[container port]). So if we wanted to expose port 3000 inside the container (remember our EXPOSE argument in the Dockerfile) to port 8000 outside the container, we would pass 8000:3000 to the -p flag.

You can confirm that your container is running with docker ps. This will list all running containers:

If you have other requirements and questions regarding the launch of containers you will find more information here

1docker ps
2CONTAINER ID    IMAGE     COMMAND                  CREATED          STATUS          PORTS                    NAMES
371465a89b58b    my-app    "docker-entrypoint.s…"   7 seconds ago    Up 6 seconds    0.0.0.0:3000->3000/tcp   determined_shockley

Now, open your browser and navigate to the following URL http://localhost:3000/ to see you application running 🚀!

Building and publishing a Docker image with GitHub Actions

This step is not required if you only want to deploy your application on Heroku.

Now that we know how to build and run a Docker image locally, we want to learn how to do this automatically. This process is called Continuous Integration and usually relies on external services to start a serie of automatic build steps for example (linting, testing, building).

In this tutorial we will use GitHub's Actions to trigger an automatic Docker build and image publishing to GitHub packages whenever your commit some changes on the master (or main) branch.

At the root of the project, create the following nested folders .github/workflows/. Once these folder added, create a docker-publish.yml file into workflows/ and copy the content of the snippet below into it:

1name: Publish Docker image
2
3on:
4  push:
5    branches:
6      - master
7      - main
8
9jobs:
10  push_to_registry:
11    name: Push Docker image to GitHub Packages
12    runs-on: ubuntu-latest
13    steps:
14      - name: Checkout repository
15        uses: actions/checkout@v2
16
17      - name: Publish to GitHub Packages
18        uses: docker/build-push-action@v1
19        with:
20          username: ${{ github.actor }}
21          password: ${{ secrets.GITHUB_TOKEN }}
22          registry: docker.pkg.github.com
23          repository: USERNAME/REPOSITORY/NAME
24          tag_with_ref: true

This action is split in two steps:

  1. Checkout the master (or main) branch of the repository.
  2. Build a Docker image and publish it to the repository's packages (GitHub Packages).

Before doing anything more you'll need to make some changes to the repository property:

  • Replace USERNAME with your own GitHub username
  • Replace REPOSITORY with the title of your repository
  • Replace NAME with a name for your package

You can now commit this file and make some changes to your code (or run a CI worlflow manually) to see the magic happen.

Packages will be published in your profile page on GitHub. If you click on a published package, you will be able to see the fully qualified name to pull and run your Docker image (ex: docker.pkg.github.com/USERNAME/REPOSITORY/NAME:latest).

Deploying our Docker image to Heroku automatically

GitHub Actions are now ready, and the last thing we need is to finally run our application. Many hosting providers exist, but few offer automatic deploys with a Docker image AND a free tier. That's why we'll go with Heroku which provide both.

Creating an application in Heroku

I won't dive into too many details about Heroku, instead I will provide a step-by-step guide to setup the environment in accordance to our GitHub Action, but feel free to look for more on your side.

  1. Log in Heroku (or create a new account if you don't have any yet)
  2. Go to your app dashboard (or click https://dashboard.heroku.com/apps)
  3. Click on New > Create new app (or click https://dashboard.heroku.com/new-app)
  4. Type an available name, select the region you prefer, and press Create app
  5. Now, navigate to your account settings (or click https://dashboard.heroku.com/account)
  6. Scroll down to the API Key section and create a new key if none exist

And that's it! You should keep this page open as we'll need to use the API key in our workflow.

Implementing the GitHub Action

Our Heroku application instance is now ready and we can start implementing our action in GitHub. Navigate to the .github/workflows/ folder (or create it if you've skipped the previous step), and add a heroku-deploy.yml file into it.

1name: Deploy to Heroku
2
3on:
4  push:
5    branches:
6      - master
7      - main
8
9jobs:
10  build:
11    runs-on: ubuntu-latest
12    steps:
13      - name: Checkout repository
14        uses: actions/checkout@v2
15
16        # This action will automatically build our docker image using
17        # the Dockerfile in the project's root, and publish it to our
18        # Heroku application instance.
19      - name: Deploy to Heroku
20        id: heroku
21        uses: jctaveras/heroku-deploy@v2.1.3
22        with:
23          email: ${{ secrets.HEROKU_EMAIL }}
24          api_key: ${{ secrets.HEROKU_API_KEY }}
25          app_name: ${{ secrets.HEROKU_APP_NAME }}
26          dockerfile_path: '.'

The variables appearing as ${{ secrets.[...] }} are called GitHub secrets. They are special marks that the GitHub runner will replace at build time with values in your project's settings. This avoids publishing sensitive information directly into your code and to make it easier to share open-source projects.

To create the secrets we need, follow a few simple steps (also available here):

  1. On GitHub, navigate to the main page of the repository.
  2. Under your repository name, click Settings.
  3. In the left sidebar, click Secrets.
  4. Click New repository secret.
  5. Type a name for your secret in the Name (ex: HEROKU_EMAIL) input box.
  6. Enter the value for your secret.
  7. Click Add secret.

You must do this for each of our 3 properties:

  • HEROKU_EMAIL is the email you use to log into your Heroku account
  • HEROKU_API_KEY is the API key we created
  • HEROKU_APP_NAME is the the of your application in Heroku

Once everything is filled up, you will need to push a new commit on your master (or main) branches, or trigger a new CI workflow. If everything went according to plan your application should now be available on a similar URL http://YOUR_APP_ID.herokuapp.com/ (make sure to replace YOUR_APP_ID with your application's name).

Summary

If you've read up to here, than kudos to you! You've learned how to containerize an application, host a Docker image, and deploy an application automatically to Heroku. This should get you started on the right path the develop any modern web app that comes to your mind.

If you still have some questions regarding the project architecture and code, feel free to reach out to me and to checkout the halftheopposite/tutorial-app GitHub repository coming with this article.

Posted on March 18, 2021

Like this article?

Have some questions? Email me at contact[at]halftheopposite.dev.