Devops from Scratch 1: Dockerize a TypeScript Express App
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:
- What is TypeScript and REST as well as how very basic HTTP works,
- a rough idea what Docker is.
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!