I love TypeScript, and I love Docker. Putting these two together, however, can be a giant pain in the ass.
Today I am going to walk you through a very basic multi-stage Docker setup with a TypeScript/Node project.
This setup addresses the biggest challenge I found when working with this tech stack... getting my TypeScript to compile to JavaScript in production, and being able to develop in a running container that watches for changes made in my TypeScript Code.
All code for this tutorial can be found here :)
justDare / TypeScript-Node-Docker
TypeScript + Node + Docker setup for dev and prod with hot reloading
Prefer YouTube? Check out the video tutorial here:
Step 1: Creating a server with TypeScript & Express
Let's whip up a simple Express server with TypeScript and get it running locally (we'll dockerize it after!).
Make a directory for the project and cd in there:
mkdir ts-node-docker
cd ts-node-docker
Initialize a node project and add whatever values you want when prompted (I just skip everything by mashing enter...):
npm init
Next, install TypeScript as a dev dependancy:
npm i typescript --save-dev
Once that's downloaded, create a tsconfig.json file:
npx tsc --init
Now we should have a tsconfig.json in the root of out project directory, lets edit the following entries in there:
"baseUrl": "./src"
"target": "esnext"
"moduleResolution": "node"
"outdir": "./build"
The baseUrl tells TS that our .ts source code files will be in the ./src folder.
The target can be whatever version of JS you like, I go with esnext.
The moduleResolution must be set to node for node projects.
The outdir tells TS where to put the compiled JavaScript code when the TS files are compiled.
Next, let's install express, and then its typings as a dev dependancy:
npm i --save express
npm i -D @types/express
Cool, we are ready to code up our server. Let's make a src/ folder at the root of our project and add an index.ts file.
In index.ts, add the following code:
import express from 'express';
const app = express();
app.listen(4000, () => {
console.log(`server running on port 4000`);
});
That's all we'll need to start our server, but now we need to get this thing running and watching for changes we make to the code.
For that, we'll use ts-node and nodemon, intall that now:
npm i -D ts-node nodemon
With nodemon, we can watch files while the code is running, and ts-node just lets us run node projects written in TS very easily.
I like to have my nodemon setup in a config file, so I'll add a nodemon.json file to the root of my project folder and add the following options:
{
"verbose": true,
"ignore": [],
"watch": ["src/**/*.ts"],
"execMap": {
"ts": "node --inspect=0.0.0.0:9229 --nolazy -r ts-node/register"
}
}
The key takeaways here are the watch command (which tells nodemon what files it should watch for), and the ts option in execMap.
This tells nodemon how to handle TS files. We run them with node, throw in some debugging flags, and register ts-node.
Okay, now we can add scripts to our package.json that uses nodemon to start our project. Go ahead and add the following to your package.json:
"scripts": {
"start": "NODE_PATH=./build node build/index.js",
"build": "tsc -p .",
"dev": "nodemon src/index.ts",
}
The dev command starts our project with nodemon. The build command compiles our code into JavaScript, and the start command runs our built project.
We specify the NODE_PATH to tell our built application where the root of our project is.
You should now be able to run the application with hot reloading like so:
npm run dev
Great! Now let's dockerize this thing 🐳
Step 2: Docker Development & Production Step
If you haven't installed Docker, do that now. I also recommend their desktop app, both of which can be found on their website.
Next, let's add a Dockerfile to the root of our project directory and add the following code for the development step:
FROM node:14 as base
WORKDIR /home/node/app
COPY package*.json ./
RUN npm i
COPY . .
This pulls in a node image, sets a working directory for our container, copies our package.json and installs it, and then copies all of our project code into the container.
Now, in the same file, add the production step:
FROM base as production
ENV NODE_PATH=./build
RUN npm run build
This extends our development step, sets our environment variable, and builds the TS code ready to run in production.
Notice we haven't added any commands to run the development or production build, that's what our docker-compose files will be for!
Create a docker-compose.yml file at the root of our directory and add the following:
version: '3.7'
services:
ts-node-docker:
build:
context: .
dockerfile: Dockerfile
target: base
volumes:
- ./src:/home/node/app/src
- ./nodemon.json:/home/node/app/nodemon.json
container_name: ts-node-docker
expose:
- '4000'
ports:
- '4000:4000'
command: npm run dev
This creates a container called ts-node-docker, uses our dockerfile we created, and runs the build step (see the target).
It also creates volumes for our source code and nodemon config, you'll need this to enable hot-reloading!
Finally, it maps a port on our machine to the docker container (this has to be the same port we setup with express).
Once that's done, we can build our docker image:
docker-compose build
You should be able to see the build steps in your terminal.
Next, we can run the container as follows:
docker-compose up -d
Success! You should now have a container running that picks up on any changes you make to your TypeScript source code. I highly recommend using the docker desktop app to view the containers you have running.
You can stop the container like so:
docker-compose down
Now we are also going to want to run this thing in production, so let's create a separate docker-compose.prod.yml for that:
version: '3.7'
services:
ts-node-docker:
build:
target: production
command: node build/index.js
This file is going to work together with our first docker-compose file, but it will overwrite the commands we want to change in production.
So, in this case, we are just going to target the production step of our Dockerfile instead, and run node build/index.js instead of npm run dev so we can start our compiled project.
To start our container in production, run:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
This tells docker-compose which files to use, the later files will overwrite any steps in the prior files.
You should now have the built application running just how it would be in production, no hot reloading needed here!
Lastly, I hate typing out all these docker commands, so I'll create a Makefile in the root of my project and add the following commands that can be executed from the command line (eg make up
):
up:
docker-compose up -d
up-prod:
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up
down:
docker-compose down
If you made it all the way to the end, congrats, and thank you. Hopefully, this made somebody's day a lot easier while trying to integrate these two awesome technologies together.
If you liked this, I post tutorials and tech-related videos over on my YouTube channel as well.
We have a growing tech-related Discord Channel too, so feel free to pop by.
Happy coding! 👨💻 🎉
Top comments (15)
In order to make the production container, I had to add
--build
to the docker up command, so theup-prod
Makefile entry became:docker-compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
Great article! We would need to restart the container on installing a new module right?
Thanks Ryan! Yes you would. Most of it gets cached so rebuilding after installing another package should be much faster than the initial build.
💯
Great article, thanks
Thank you! I was wasting so much time flipping between developing with npm scripts and then testing with docker...hot reloading for the win!
This was very well done. Thanks!
Nice
Doesn't this make a production image containing all the devDependencies? That's not what we want
Saved me from a massive headache. Thanks a lot!
Excellent article!!