Securely Installing Private Packages in Node.js Docker Builds
When building Docker images for Node.js applications, specifically for projects using Typescript, we have to make use of Docker’s multi-stage build features. This ensures that we get a production ready image with only the build outputs that are required to run the application in the final stage.
During the build stage, we would usually have to install all the dependencies for the project so that the build process is successful. When it comes to packages, majority of are available on the public Internet. But it is common for organizations to use private packages on either NPM or GitHub. This is done to provide some internal functionality or tooling to be used by the application.
Leaking secrets and tokens
My usual approach to providing build time values (eg: application version) was to provide --build-arg <varname>=<value>
flags in the docker build
command. But this is no longer recommended for senstive values, as anyone can inspect the layers of a Docker image. Even inspect the contents of individual layers with tools like Dive.
Docker’s documentation does not recommend using build arguments for this use-case, as the values are also visible in the docker history
command.
Using Docker Secrets
With the new build secrets feature, we can solve the above problem and ensure that such values will not be accessible in the final image.
Below is a Dockerfile
for a simple Typescript application.
FROM node:20-alpine as base
# stage for building the app
FROM base as builder
WORKDIR /app
COPY package*json tsconfig.json src ./
# install all the dependencies
RUN npm ci
# build the application
RUN npm run build
# remove the extra dev related packages
RUN npm prune --production
# stage for final image
FROM base as runner
WORKDIR /app
ENV NODE_ENV="production"
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono
# copy the production dependencies and build outputs from the builder stage
COPY --from=builder --chown=hono:nodejs /app/node_modules /app/node_modules
COPY --from=builder --chown=hono:nodejs /app/dist /app/dist
COPY --from=builder --chown=hono:nodejs /app/package.json /app/package.json
USER hono
EXPOSE 3000
CMD ["node", "/app/dist/index.js"]
If we had a private package in the project, we usually get an error like this when we run docker build
.
3.577 npm notice
3.578 npm ERR! code E401
3.578 npm ERR! 401 Unauthorized - GET https://npm.pkg.github.com/download/@org-name/tsconfig/3.0.0/afc9686569ec81268d8d51c3d3137d4d715a6094 - unauthenticated: User cannot be authenticated with the token provided.
3.579
3.579 npm ERR! A complete log of this run can be found in: /root/.npm/_logs/2024-04-27T10_29_37_953Z-debug-0.log
------
Dockerfile:16
--------------------
15 |
16 | >>> RUN >>> npm ci
--------------------
ERROR: failed to solve: process "npm ci" did not complete successfully: exit code: 1
Using the documentation from NPM and Docker, we can update the builder
stage in the Dockerfile
to create a .npmrc
file and use secrets to securely pass the registry authentication token.
FROM node:20-alpine as base
# stage for building the app
FROM base as builder
# create an .npmrc file to set the registry configuration
RUN cat <<EOF > $HOME/.npmrc
@org-name:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=\${GITHUB_TOKEN}
EOF
WORKDIR /app
COPY package*json tsconfig.json src ./
# use mount type secrets to securely pass in the token
# during image build and run the dependency install
RUN --mount=type=secret,id=GITHUB_TOKEN,required=true \
GITHUB_TOKEN=$(cat /run/secrets/GITHUB_TOKEN) \
npm ci
# build the application
RUN npm run build
# remove the extra dev related packages
RUN npm prune --production
# stage for final image
FROM base as runner
# ...continues
Now we can build the image, first by setting the required environment variable for the secret and passing that to the docker build
command.
export GITHUB_TOKEN=<personal-access-token>
docker build -t secure-app-image --secret id=GITHUB_TOKEN .
The important parts to get this working are:
To use a project specific .npmrc
file.
This is mentioned in the npm Docs, where npm cli will replace the value with the contents of the GITHUB_TOKEN
environment variable.
In the Dockerfile
, we have set that in the user’s $HOME
directory. So the npm cli will detect the configuration from there.
Making the token available for dependency installation
Making the GITHUB_TOKEN
available in the environment variables as the image is build and npm ci
is run.
This is done using the secret mount type in the RUN
command.
--mount=type=secret,
allows build container to access sensitive values without baking them into the image.id=GITHUB_TOKEN,
sets the name of the variable.required=true
will show an error if this variable is not provided.GITHUB_TOKEN=$(cat /run/secrets/GITHUB_TOKEN)
set an envionment variable by reading the value from/run/secrets/<id>
which is the default mount path inside the build container.npm ci
the install command will now have access to the token and parse.npmrc
file to successfully install the dependencies.
Conclusion
Using Docker’s build secrets feature, we now have secure way of passing in sensitive tokens and values to the Docker build process. With this we can be sure that they are not baked into the image.
If you want to checkout the full code for this guide, I’ve put them up in the GitHub repository here
Thanks for reading.