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
devDependencies
in the final image - Problem 2 - Multiple
RUN
commands increase the image size - Problem 3 - The
Dockerfile
is 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.git
Open the folder with your favourite code editor:
code apollo-server-docker
These 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 dev
If 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 build
This 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 start
Same 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-alpine
Define a working directory to avoid unintended operations in unknown directories:
WORKDIR /app
Copy everything into the container working directory:
COPY . .
To ignore files or folders, list them in the .dockerignore
:
# .dockerignore
dist
node_modules
README.md
Install all dependencies and compile the TypeScript files into the dist
folder:
RUN yarn install
RUN yarn build
Define 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 version2
The image size is 321MB
:
>> version2 latest bc7c4efc4680 37 seconds ago 321MB
Since 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 version3
The image size is 234MB
:
version3 latest 004141971cd6 8 seconds ago 234MB
You 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 image
Since 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 version4
The image size is 234MB
:
version4 latest d2cc6fcb6fa3 11 seconds ago 234MB
You 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 version4
You 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_napier
To stop the container, use the container id:
docker stop 5221f7343a0c
Although 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!