How To Create A Production Image For A Node.js + TypeScript App Using Docker Multi-Stage Builds
6th Oct 2022
- Docker
- Node.js
- TypeScript

Photo by Filip Kominik
This article is a step by step guide on how to use Docker multi-stage builds to create a production image that is small, readable and maintainable.
As an example, you will build an Apollo Server application in Node.js and TypeScript.
Table of contents
- Clone the application
- Test production scripts manually
- Write the
Dockerfile- Problem 1 - You don't want
devDependenciesin the final image - Problem 2 - Multiple
RUNcommands increase the image size - Problem 3 - The
Dockerfileis hard to read and prone to mistakes
- Problem 1 - You don't want
- Run the production image on port
4000
1. Clone the application
Clone this repository into a folder of your choice:
git clone git@github.com:AndreaDiotallevi/apollo-server-docker.gitOpen the folder with your favourite code editor:
code apollo-server-dockerThese are the scripts and dependencies you'll refer to:
// package.json
{
...
"scripts": {
"build": "tsc -p .",
"dev": "ts-node-dev src/index.ts",
"start": "node dist/index.js"
},
...
"dependencies": {
"apollo-server": "^3.10.0",
"graphql": "^16.5.0"
},
"devDependencies": {
"@types/node": "^18.0.0",
"ts-node-dev": "^2.0.0",
"typescript": "^4.8.3"
}
}And this is the node server index.ts, with a placeholder query hello:
import { ApolloServer } from "apollo-server"
const server = new ApolloServer({
typeDefs: `#graphql
type Query {
hello: String
}
`,
resolvers: {
Query: {
hello: () => "hello",
},
},
})
server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`)
})Install all dependencies and test you can start the server locally:
yarn install && yarn devIf everything works correctly, you should be able to access the server at http://localhost:4000 and query hello successfully:
Close the local server with Ctrl+C.
2. Test production scripts manually
Before moving straight to the Dockerfile, test the build script locally:
yarn buildThis command uses the TypeScript compiler tsc to compile TypeScript files into JavaScript files, which is what the browser ultimately reads.
Since there is only a file inside the src folder (index.ts), you should see only one file inside the dist folder (index.js).
The configuration options for tsc are set in the tsconfig.json:
// tsconfig.json
{
"compilerOptions": {
"rootDir": "./src" /* Specify the root folder within your source files. */,
"target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"types": [
"node"
] /* Specify type package names to be included without being referenced in a source file. */,
"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. */
}
}Now that the dist folder has been populated, run index.js with node:
yarn startSame as before, if everything works correctly, you should be able to query the server successfully at http://localhost:4000.
Close the local server with Ctrl+C.
3. Write the Dockerfile
Start from a base image with node and alpine:
FROM node:16-alpineDefine a working directory to avoid unintended operations in unknown directories:
WORKDIR /appCopy everything into the container working directory:
COPY . .To ignore files or folders, list them in the .dockerignore:
# .dockerignore
dist
node_modules
README.mdInstall all dependencies and compile the TypeScript files into the dist folder:
RUN yarn install
RUN yarn buildDefine the command to execute when the container is run:
CMD [ "yarn", "start" ]Here is the first version of the Dockerfile in full:
# Version 1
FROM node:16-alpine
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
CMD [ "yarn", "start" ]Problem 1 - You don't want devDependencies in the final image
Delete the node_modules after the build and install only --production dependencies:
# Version 2
FROM node:16-alpine
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
RUN rm -rf node_modules
RUN yarn install --production
CMD [ "yarn", "start" ]Problem 2 - Multiple RUN commands increase the image size
Build the image with the tag version2:
docker build -t version2 .Check the image size:
docker images | grep version2The image size is 321MB:
>> version2 latest bc7c4efc4680 37 seconds ago 321MBSince every RUN command creates a new layer, chain consecutive RUN statement to create only one layer:
# Version 3
FROM node:16-alpine
WORKDIR /app
COPY . .
RUN yarn install \
&& yarn build \
&& rm -rf node_modules \
&& yarn install --production
CMD [ "yarn", "start" ]This time, build the image with the tag version3:
docker build -t version3 .Check the image size:
docker images | grep version3The image size is 234MB:
version3 latest 004141971cd6 8 seconds ago 234MBYou saved 87MB!
Problem 3 - The Dockerfile is hard to read and prone to mistakes
Docker multi-stage builds allow you to create separate stages that have independent size and file system:
FROM node:16-alpine AS builder
WORKDIR /app
# It needs all dependencies (dev and prod)
# It's in charge of creating the dist folder
FROM node:16-alpine AS final
WORKDIR /app
# It needs only the production dependencies
# It's in charge of creating the final imageSince the builder stage doesn't create the final image, maximise for readability:
FROM node:16-alpine AS builder
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
FROM node:16-alpine AS final
WORKDIR /app
...In the final image, COPY the dist folder from the builder stage and install only production dependencies:
# Version 4
FROM node:16-alpine AS builder
WORKDIR /app
COPY . .
RUN yarn install
RUN yarn build
FROM node:16-alpine AS final
WORKDIR /app
COPY ./app/dist ./dist
COPY package.json .
COPY yarn.lock .
RUN yarn install --production
CMD [ "yarn", "start" ]This time, build the image with the tag version4:
docker build -t version4 .Check the image size:
docker images | grep version4The image size is 234MB:
version4 latest d2cc6fcb6fa3 11 seconds ago 234MBYou have achieved the same image size with a much more readable and maintainable Dockerfile.
4. Run the production image on port 4000
Since the app listens on port 4000, run the image and map the container 4000 port to your local machine 4000 port:
docker run -p 4000:4000 version4You should now be able to query the server at http://localhost:4000.
To view the containers currently running in your local machine:
docker ps>> CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5221f7343a0c version4 "docker-entrypoint.s…" 43 seconds ago Up 42 seconds 0.0.0.0:4000->4000/tcp eager_napierTo stop the container, use the container id:
docker stop 5221f7343a0cAlthough Docker multi-stage builds is not the only way to create small, readable and maintainable images, it's certainly the most elegant.
Try it out!
