Containers have become the de-facto standard for deploying applications. No matter where you deploy them, they offer a lot of benefits, including repeatable builds and easy deployment workflows. However, building containers that are small, secure, and follow all industry best practices is still challenging.
We’ll walk you through creating a small Node.js application and containerizing it with Docker. The application will be a “Hello World” server that uses the express web application framework.
First, create a Node.js app locally using npm init. Then, fill in the name of the package and description for your application. You can use the default settings for everything else. Once done, you’ll see the structure of the project created for you with package.json.
Next, add some dependencies to your package.json file. Install the production dependency of express using npm install express. Then add eslint as a dev dependency using npm install -D eslint.
Finally, initialize the eslint config file using npx eslint --init and follow the prompts. You will be prompted to add more dev dependencies pertaining to eslint and to create a .eslintrc.json file.
Your package.json should now look like this:
{
"name": "node_js_docker_demo",
"version": "1.0.0",
"description": "A sample express app to demonstrate containerization of a Node app",
"main": "index.js",
"scripts": {
"start": "node index.js",
"lint": "eslint index.js",
"test": "echo \\"Error: no test specified\\" && exit 1"
},
"author": "Coder Society",
"license": "ISC",
"dependencies": {
"express": "^4.17.2"
},
"devDependencies": {
"eslint": "^7.32.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.2.0"
}
}
You’ve now modified the script section of your package.json to add a start and lint script for your server.
Next, let’s write the code for the server in index.js.
'use strict'
const express = require('express')
const PORT = 8080
const HOST = '0.0.0.0'
const app = express()
app.get('/', (req, res) => {
res.send('Hello World')
})
app.listen(PORT, HOST)
console.log(`Running on http://${HOST}:${PORT}`)
Lint the server code using npm run lint. You should not see any violations while linting.
Now, test to see if everything is working by starting the server using npm start.
If set up was done correctly, you should see your server listening on port 8080. You can verify it by browsing to http://localhost:8080. You’ll see Hello World being returned in response.
That’s it for setup! We’ll now start containerizing the application, and discuss some best practices along the way.
There are many best practices you should follow when containerizing an application into a Docker image. In this section, we’ll demonstrate some of them by actually building different versions of images.
Let’s create your first Docker image. Although it will work, we’ll discuss how to improve it in subsequent sections.
First, create the Dockerfile.01 file.
FROM node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run lint
CMD ["npm", "run", "start"]
Next, build the Docker image using the following command:
docker build -f Dockerfile.01 -t node-docker-demo:v0.1 .
Once the image is built, check its size using the docker images command. The final size of the image is 1.02 GB.
Verify that the image is built correctly by starting the container and browsing http://localhost:8080.
docker run -d -p 8080:8080 node-docker-demo:v0.1
In v0.1, you used the latest node image, which can change depending on when you build it. This may introduce breaking changes, leading to failures while creating or running containers. In order to avoid such issues, always build your images from standard images and a fixed version. Since you are already using the standard node image, all you need to do is peg the version of the base image to a certain tag.
Create a Dockerfile.02 file to ensure that the builds for this Docker image are repeatable.
FROM node:16.13.1-stretch
WORKDIR /app
COPY . .
RUN npm install
RUN npm run lint
CMD ["npm", "run", "start"]
Due to the way the current Docker image is built, it will always have to rebuild the dependencies when there is a change in your server code. You can restructure your Dockerfile to take advantage of Docker cache while building your image.
Create your Dockerfile.03 file.
FROM node:16.13.1-stretch
COPY package*.json /tmp/
RUN cd /tmp && npm install
RUN cp -a /tmp/node_modules /app/
WORKDIR /app
COPY . .
RUN npm run lint
CMD ["npm", "run", "start"]
Now, build the image.
docker build -f Dockerfile.03 -t node-docker-demo:v0.3 .
You should see node_modules being downloaded while you’re creating the image.
However, if you change anything in the index.js file and rebuild the image now, the node modules won’t be downloaded again (given that nothing has changed in package.json).
You should also consider using a .dockerignore file to avoid accidentally copying credentials and unused files to your Docker image.
Here is a sample .dockerignore file you can use to avoid copying the local node_modules folder inside the Docker image:
**/node_modules
So far, you have optimized the build time using Docker cache. However, the size of the image is still huge—1.02 GB. This is partly because you are using a larger base image. Luckily, you can use Alpine-based images, which are significantly smaller.
To do this, create a Dockerfile.04 file.
FROM node:16.13.1-stretch-slim
COPY package*.json /tmp/
RUN cd /tmp && npm install
RUN cp -a /tmp/node_modules /app/
WORKDIR /app
COPY . .
RUN npm run lint
CMD ["npm", "run", "start"]
You are using the slim version of your base image. The resultant Docker image is only 280 MB, which is four times smaller than previous versions.
In the previous version of the image, you linted the server code in the image itself. Because of this, you’ll have to keep linting dependencies in your final image as well. In this version, you’ll use multi-stage builds to ensure that you don’t have dev dependencies in your final build, leading to an even smaller image size. Another advantage of a multi-stage build is that you can avoid adding credential files (e.g., npmrc) to the production image.
Imagine you installed some node packages from your private repository (Artifactory, Nexus, etc.). You would need those credentials in the Docker image to be able to download the packages. However, baking credentials into your Docker images is a security risk, as anyone who uses your image can access them. By using a multi-stage build, you can avoid putting your credentials in the production image by using them in the build image and copying the packages directly to the production image.
Create your .npmrc file.
//<YOUR-PRIVATE-REPOSITORY>/USERNAME/:_authToken=SECRET-TOKEN
Create your Dockerfile.05 file.
FROM node:16.13.1-stretch as build
WORKDIR /app
COPY package*.json /app/
COPY .npmrc /app/
# Install production dependencies
RUN npm install --only=production
RUN cp -a node_modules /tmp/prod_node_modules
# Now install all dependencies
RUN npm install
COPY . .
RUN npm run lint
FROM node:16.13.1-stretch-slim
COPY --from=build /tmp/prod_node_modules/ /app/node_modules/
WORKDIR /app
COPY . .
CMD ["npm", "run", "start"]
The resulting production image is slightly smaller, at 238 MB, with only production dependencies. In this demo application, the difference is insignificant, but in a production setting, when there are dozens of dev dependencies for linting, build tools, transpilers, etc., the size difference is huge. In addition, removing unused code reduces the attack surface and makes the image more secure.
Also, note that for the build image, you can use the bigger base image with more development tools for testing, linting, and other activities. However, the final production image can be based on an Alpine/slim image.
In all of the previous versions of the container, you used root users to run your processes. The root user in the container is the same as the root user on the host. A malicious user can break out of running as root in a container and may gain access to the host machine. To avoid this, run the container as a non-root user.
First, create a Dockerfile.06 file.
FROM node:16.13.1-stretch as build
WORKDIR /app
COPY package*.json /app/
COPY .npmrc /app/
# Install production dependencies
RUN npm install --only=production
RUN cp -a node_modules /tmp/prod_node_modules
# Now install all dependencies
RUN npm install
COPY . .
RUN npm run lint
FROM node:16.13.1-stretch-slim
RUN groupadd -r node-group && useradd -r -g node-group -u 1001 node-user
COPY --from=build /tmp/prod_node_modules/ /app/node_modules/
WORKDIR /app
COPY . .
USER node-user
CMD ["npm", "run", "start"]
In the production image, you can see that you’re creating a new group (node-group) and a user (node-user) with explicit guid. Later, you’ll use USER instruction to tell Docker which user to run the container with.
Restricting your container image to just your application code and its dependencies is the best way to reduce the attack surface and size of your containers. Google’s distroless project helps restrict container runtime to the bare minimum of required dependencies. The distroless images do not have package managers, shells, or any other Linux tool that you might expect.
Create a Dockerfile.07 file.
FROM node:16.13.1-stretch as build
WORKDIR /app
COPY package*.json /app/
COPY .npmrc /app/
# Install production dependencies
RUN npm install --only=production
RUN cp -a node_modules /tmp/prod_node_modules
# Now install all dependencies
RUN npm install
COPY . .
RUN npm run lint
FROM gcr.io/distroless/nodejs:16
COPY --from=build --chown=nonroot:nonroot /tmp/prod_node_modules/ /app/node_modules/
WORKDIR /app
COPY . .
USER nonroot
CMD ["index.js"]
Now the production build is based on gcr.io/distroless/nodejs:16. Note that you don’t need to explicitly add a non-root user, as a nonroot user already exists in the image that you can use. The resulting image is 138 MB, which is even smaller than the Alpine/slim-based image.
When you pull public images from Docker repositories (Docker hub, Google Container Repository, etc.), you run the risk of man-in-the-middle attacks. The image being downloaded can be tampered with over the wire between Docker client and Docker registry. Avoid this by verifying that the image you are downloading is signed by the owner and isn’t tampered with.
You can use Docker Content Trust to enable verification of images while downloading them. Do this by setting the environment variable DOCKER_CONTENT_TRUST to 1.
Note: Enabling verification will restrict downloading images that are not signed, so be careful. Enabling this system wide might tank your production environment.
When you enable trust verification, docker build signs your image automatically and puts the key in the ~/.docker/trust folder. You can read more about signing in the official documentation.
It’s important to use a container vulnerability scanning tool. By default, docker scan uses Snyk, which provides comprehensive image scanning. Use the following command to scan your Docker image:
docker scan node-docker-demo:v0.7
Note: You’ll need to create an account with Snyk and do a docker scan —-login before you can use the scanning functionality.
Here are a few more best practices to follow when containerizing your applications:
LABEL
instruction. Labels are inherited from the parent image and can be overridden by child images.
LABEL “author”=”Coder Society”
These labels can be used to filter out the objects by passing in --filter predicate.
docker ps --filter "label=author=Coder Society"
The OCI Image Format Specification outlines labels for some common use cases.
In this article, we covered some of the best practices for making your containers small, secure, and easy to consume. As we discussed, containers are a great way to deploy your applications, and it's really easy to create and distribute them. However, making sure your images are maintainable and secure is not as straightforward. You need to diligently include the best practices we covered in your image-building workflow to ensure that the images are secure, verifiable, and concise.