blog.hipsquare.net

Devops from Scratch 1: Dockerize a TypeScript Express App

Cover Image for Devops from Scratch 1: Dockerize a TypeScript Express App
Felix Christl
Felix Christl

At HipSquare, we have monthly brown bag sessions. In each sessions, one of our colleagues presents a topic, often technical, but not always. In 2022, we held a series of brown bag sessions on DevOps basics. This article is the first in a series following along the brown bag sessions. Along the next articles, you will learn to build, package and deploy a TypeScript app using Docker.

Goal of this article

In this article, we will set up a simple TypeScript application that provides a REST endpoint. We will then build a Docker image based on that application and run it in a Docker container.

Prerequisites

For the article, we assume some basic knowledge:

Also, make sure you have some basic tooling installed:

  • NodeJS. Writing the article, we worked with Node 16, but it should work with any newer version, too.
  • We use Yarn as a package manager. Make sure you have it installed.
  • Docker Desktop will be required later on.

Basic setup

So let's get started! First, we will bootstrap our sample application.

Create a new folder and initialize a Node project:

mkdir -p my-node-app/src
cd my-node-app
yarn init

You can just answer all questions with their default values.

Install Express, which we will use to set up our HTTP server and REST endpoint:

yarn add express

Install TypeScript and the types to work with Express:

yarn add -D typescript @types/express

TypeScript config

Now is the time to open your code editor.

Either create the TypeScript configuration for this project using yarn tsc --init, which creates a default tsconfig.json file in the current directory, or copy the following configuration to tsconfig.json:

{
  "compilerOptions": {
    "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs", /* Specify what module code is generated. */
    "rootDir": "./src", /* Specify the root folder within your source files. */
    "outDir": "./dist", /* Specify an output folder for all emitted files. */
    "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
    "strict": true, /* Enable all strict type-checking options. */
    "skipLibCheck": true /* Skip type checking all .d.ts files. */
  }
}

TypeScript compilation test run

Let's test if our TypeScript config works. For that, it needs something to compile -- so create an empty src/index.ts file.

To make our lives a little easier, let's add a build script to package.json:

"scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
}

Verify that works by running yarn build. You should now have a dist directory with an index.js file in it.

Write an HTTP service

Now that we have our basic setup nailed, let's write a simple HTTP service in src/index.ts:

import express from "express";

const app = express();
app.get("/", (req, res) => res.send("Hello World"));
app.listen(process.env.PORT || 3000);

This does nothing more but create an Express app, listen for requests on port 3000, and answer them with "Hello World".

Test the app

Let's test our app and make sure it works. Build it, then start it, and then verify it responds to HTTP requests:

yarn build
yarn start

Now open http://localhost:3000 in your browser and verify you see a "Hello World" message. Alternatively, use curl:

curl http://localhost:3000

Make sure to stop the app again (e.g. using Ctrl + C), we will need the port later for Docker.

Create a Dockerfile

Congratulations, you just wrote a REST service! Now let's make that available in a Docker container.

Docker containers are created from Docker images. A Docker image basically is a tiny Linux system. It contains a program, ready to run. Docker images are created from instruction files called Dockerfile. A Dockerfile contains a command in each line, with commands either describing how to build the Docker image, or what the images do when they are started as containers.

A Docker image can be based on another Docker image, which allows for efficient reuse. We will use that to our advantage.

Create a new Dockerfile in the root of the project directory.

Add a first line:

FROM node:alpine

The FROM command defines the base image to use for our Docker image. As we build a Node application, it's practical to use a Docker image that already has Node pre-installed. node:alpine is such image.

While our image does not yet do much but extend the node:alpine image, we can already build it from the Dockerfile. Let's give it a shot:

docker build -t my-node-app .

With this command, we instruct Docker to build an image and tag it with the name my-node-app. This is the tag we can later use to interact with the image, and to create a container from the image.

Build the app in Docker

So far, our image does not do anything meaningful. Let's change that by introducing some new commands to our Dockerfile. A quick overview of the commands we will use:

  • ADD: You can add files from the current directory to a Docker container by using ADD,
  • WORKDIR: You can set the default working directory for all commands inside the Docker container,
  • RUN commands are used to build the image,
  • CMD commands are used to run the image.

To avoid adding too many files to an image, you can use a .dockerignore file, same syntax as .gitignore. Create a new .dockerignore file and add node_modules to it:

node_modules

Now extend the Dockerfile:

# we still use our base image
FROM node:alpine

# add everything from the project directory (except the files in .dockerignore) to the image.
# files are copied into the /app directory of the image, which is created if it does not yet exist.
ADD . /app

# all commands after this will be executed inside the /app directory
WORKDIR /app

# install dependencies
RUN yarn

# build the app
RUN yarn build

Again, try if the Docker image builds:

docker build -t my-node-app .

Run the app in Docker

So far, our image has a built version of the app in it, but if we would start a container based on it, Docker would not know how to start our app. Let's add an instruction to the end of the file to let Docker know what to do when a container is started based on the image:

FROM node:alpine
ADD . /app
WORKDIR /app
RUN yarn
RUN yarn build

# the CMD command tells docker to run yarn start when a container is started based on the image
CMD yarn start

As a last step, let's let Docker know that we expose our app on port 3000. This is not strictly necessary, but a nice gesture to users of our image who will have an easier time discovering which ports are used:

FROM node:alpine
ADD . /app
WORKDIR /app
RUN yarn
RUN yarn build
CMD yarn start

# tell Docker that we want to expose port 3000 
EXPOSE 3000

Give it a spin

Now let's give it a spin and actually start our application by starting a container based on our image:

docker build -t my-node-app .
docker run -p 3000:3000 my-node-app

By executing docker run, we start a container based on the my-node-app image. The -p 3000:3000 argument tells Docker to expose the container's 3000 port on your machine under port 3000.

Test the app by opening http://localhost:3000 again.

Congratulations, you just built a REST service in Docker from scratch!

Stop the container

Now that we are done with testing, let's stop the container. First, get an overview of your running containers:

docker ps

You see a table of all running Docker containers. Take your container's ID and use it to stop the container:

docker stop cfae80529414

Conclusion

Now you know how to set up a Node project and containerize it with Docker. Docker containers make it easy to deploy your code to servers - we will show you how in the next article of this series!