Shrinking a Node.js Docker Image from 2.5GB to 300MB: Leveraging standalone server.js
Shrinking Node.js Docker Images from 2.5GB to 300MB: Leveraging a Standalone server.js
Ever run into a situation where your Node.js application's Docker image size balloons unexpectedly, slowing down your deployment process? This often happens, especially with complex build environments. In this post, I'll share how I managed to drastically reduce image size and speed up deployments.
Trials and Pitfalls
Initially, I focused on optimizing the build environment itself. I figured increasing the number of cores on the build machine in a CI/CD environment like Cloud Build would speed things up.
# Example Cloud Build configuration (actual setup might differ)
steps:
- name: 'gcr.io/cloud-builders/docker'
args: ['build', '-t', 'gcr.io/my-project/my-app:${SHORT_SHA}', '.']
timeout: '1200s' # 20-minute timeout
machineType: 'n1-standard-8' # 8-core configuration
However, no matter how much I scaled up the build environment, the image size itself didn't shrink. While build speed saw a slight improvement, it didn't address the root problem. I noticed the size kept growing as unnecessary dependencies and development tools were included in the image.
The Cause
The core issue was trying to handle everything needed for building and running the application within the Dockerfile all at once. Specifically, the `npm install` process installed development dependencies too, and complex build scripts lingering in the image contributed to its size. Combined with the Node.js runtime itself and necessary libraries, the final image size ballooned to nearly 2.5GB.
The Solution
The solution was to create a `standalone server.js` file that included only the bare minimum required to run the application. To achieve this, I used a tool like `pkg` to package the Node.js application into a single executable file.
First, I made sure `package.json` only listed essential dependencies, and then I ran `npm install --production` to install only the packages needed for operation.
{
"name": "my-app",
"version": "1.0.0",
"main": "server.js",
"dependencies": {
"express": "^4.18.2",
"body-parser": "^1.20.2"
// ... list only production dependencies here
},
"devDependencies": {
// ... exclude dependencies only needed for development/build
}
}
Next, I used `pkg` to create a single binary from the application, including `server.js`.
npm install -g pkg
pkg server.js --targets node18-linux-x64 --out-path dist
With this single executable file (dist/my-app-linux-x64) generated, I built the Docker image. By using a lightweight OS like Alpine Linux and copying only this single executable, I minimized the image size.
FROM alpine:3.18
WORKDIR /app
COPY dist/my-app-linux-x64 /app/my-app
EXPOSE 3000
CMD ["/app/my-app"]
Using this approach, unnecessary files and development tools are excluded, and I observed a significant reduction in image size, from 2.5GB down to approximately 300MB.
The Results
- Docker image size reduced by over 8x, from 2.5GB to about 300MB.
- Deployment time drastically decreased from about 20 minutes to approximately 7 minutes.
- Faster image downloads and container startup times improved the overall deployment pipeline efficiency.
Key Takeaways — How to Avoid the Same Pitfalls
- [ ] Ensure you're using the `--production` flag during `npm install` in your Dockerfile to only install production dependencies.
- [ ] Consider using tools like `pkg` to package your application into a single executable file.
- [ ] Build your Docker images based on lightweight OS images like Alpine Linux.
- [ ] Optimize your Dockerfile to prevent unnecessary files or development tools generated during the build process from being included in the final image.