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.
- Setting up the project — part 1
- Adding code — part 2
- Building the app — part 3
- Going further — advanced
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:
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.WORKDIR
sets the current working directory in the container.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).RUN
executes a command in the shell.EXPOSE
is the internal port used for the container (has nothing to do with thePORT
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.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:
1→ docker 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 port3000
inside the container (remember ourEXPOSE
argument in the Dockerfile) to port8000
outside the container, we would pass8000: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
1→ docker 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:
- Checkout the
master
(ormain
) branch of the repository. - 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.
- Log in Heroku (or create a new account if you don't have any yet)
- Go to your app dashboard (or click https://dashboard.heroku.com/apps)
- Click on
New > Create new app
(or click https://dashboard.heroku.com/new-app) - Type an available name, select the region you prefer, and press
Create app
- Now, navigate to your account settings (or click https://dashboard.heroku.com/account)
- 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):
- On GitHub, navigate to the main page of the repository.
- Under your repository name, click
Settings
. - In the left sidebar, click
Secrets
. - Click
New repository secret
. - Type a name for your secret in the
Name
(ex:HEROKU_EMAIL
) input box. - Enter the value for your secret.
- 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 accountHEROKU_API_KEY
is the API key we createdHEROKU_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