Cloud Connections

Cloud Connections

cloud engineering, automation, devops, systems architecture and more…

28 Apr 2024

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.

Categories

Tags