I work for a company where getting production releases green-lit requires keeping systems clear of software vulnerabilities. This applies to both programming language packages and operating system components, even for internal-only systems. Without maintaining these standards, teams must apply for exemptions.

Like many modern systems, our current project is Kubernetes-based and deployed to a cloud service. The enterprise vulnerability scanner running across our private container image repositories detects more vulnerabilities than what is reported on https://hub.docker.com. An image appearing clean on Docker Hub might still fail to receive production approval.

Initially, we addressed OS-level vulnerabilities by switching to whichever images came up clean on the scanner. We tried official Ubuntu, Node.js, and OpenJDK images… but this proved time-consuming and problematic. For instance, when OpenJDK images were retired, we needed to quickly find alternatives.

After trying various options, we found Amazon Linux images consistently had the fewest OS vulnerabilities. For Java applications, Amazon provides images with Corretto, their supported binary distribution of OpenJDK, requiring no additional Java installation.

However, Amazon Linux does not ship with Node.js pre-installed. This means we need to install Node.js before setting up our JavaScript services. Here’s an example Dockerfile that demonstrates our approach, typically placed in the root of the Node.js application repository:

FROM amazonlinux:2023

# typescript bundling
ENV NODE_OPTIONS="--max-old-space-size=8192"

# Install certificates
COPY cert.cer /etc/pki/ca-trust/source/anchors
RUN update-ca-trust

# optional disable unsafe legacy renogotiation for dnf  
# RUN sed -i '1s/^Options = UnsafeLegacyRenegotiation\n/' /etc/crypto-policies/back-ends/opensslcnf.config

# OS Security fixes
RUN dnf update --security -y && dnf clean all

# Create lower privilege user 'node'
RUN dnf install -y shadow-utils
RUN groupadd -r node && useradd -r -g node -s /bin/bash node

# install node.js
RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
RUN dnf install -y nodejs

# install packages and copy application code
WORKDIR /usr/src/app
COPY package*.json ./
COPY . .

# change ownership of the application directory
RUN chown -R node:node /usr/src/app
USER node

# optional configure private npm registry 
# RUN npm config set @your-scope:registry https://your-private-registry.com

# install packages
RUN npm ci

# run the test suite
RUN npm run test

# build the application 
RUN npm run build

# Port the application runs on
EXPOSE 3000

# optional start the application
CMD ["node", "build/index.js"]

Let us examine some of the less obvious parts of this Dockerfile:

FROM amazonlinux:2023

This base image pulls from Docker Hub (docker.io). To pull directly from Amazon Linux, you would use public.ecr.aws/amazonlinux/amazonlinux:2023. For a private container registry, the syntax would look like FROM 123456789012.dkr.ecr.us-east-1.amazonaws.com/amazonlinux:2023. While you can pin versions (e.g., amazonlinux:2023.6.20250107.0), this creates the additional task of manually updating for security fixes.

ENV NODE_OPTIONS="--max-old-space-size=8192"

This setting configures the maximum memory size for V8’s largest and most configurable memory heap section. It is particularly useful during TypeScript compilation. For more details, see this discussion https://stackoverflow.com/q/48387040. Remember to align this value with your container’s memory limits in Kubernetes.

COPY cert.cer /etc/pki/ca-trust/source/anchors
RUN update-ca-trust

This step installs and trusts custom SSL/TLS certificates, a common requirement when containers need to trust internal or corporate certificate authorities. If Node.js has trouble connecting to internal services, you might need to set the NODE_EXTRA_CA_CERTS environment variable to these certificates (in PEM format).

# optional disable unsafe legacy renogotiation for dnf  
# RUN sed -i '1s/^Options = UnsafeLegacyRenegotiation\n/' /etc/crypto-policies/back-ends/opensslcnf.config

Some corporate proxy environments require disabling unsafe legacy SSL/TLS renegotiation for Dandified YUM (dnf) to function. Only enable this in secure environments like internal corporate networks.

RUN dnf update --security -y && dnf clean all

This command applies any security updates released since the last image publication.

# Create lower privilege user 'node'
RUN dnf install -y shadow-utils
RUN groupadd -r node && useradd -r -g node -s /bin/bash node

Following security best practices, this creates a low-privilege user for running the Node.js application. This prevents an attacker from gaining root access if the application is compromised.

# install node.js
RUN curl -fsSL https://rpm.nodesource.com/setup_22.x | bash -
RUN dnf install -y nodejs

This two-step process first adds the NodeSource repository to the system’s package manager, then installs Node.js via dnf.

RUN chown -R node:node /usr/src/app
USER node

Continuing with the principle of least privilege, this changes ownership of the application directory from root to our node user.

# optional configure private npm registry 
# RUN npm config set @your-scope:registry https://your-private-registry.com

This commented section shows how to configure access to privately hosted npm packages.

RUN npm ci

For production installations, npm ci provides advantages over npm install by ensuring consistent, reproducible builds. Learn more about its specific benefits in the npm documentation. https://docs.npmjs.com/cli/v11/commands/npm-ci.

# optional start the application
CMD ["node", "build/index.js"]

This is marked as optional because in Kubernetes environments, the application start command often resides in the deployment manifest instead:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: node-app
  labels:
    app: node-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: node-app
  template:
    metadata:
      labels:
        app: node-app
    spec:
      containers:
        - name: node-container
          image: my-node-image:1.0.0
          command: [ "node" ]
          args: [ "build/index.js" ]
          ports:
            - containerPort: 3000

Changes can be made to the Dockerfile to improve its image layers for caching. However, the main advantage of this approach is having fewer operating system vulnerabilities to manage. Building Node.js applications on Amazon Linux requires more initial setup compared to using official Node.js images, but it significantly reduces the ongoing effort of handling security compliance. For teams working in environments with strict security requirements, this trade-off has proven worthwhile in practice.