The Quest for Secure Docker Images: Bobs Journey

Bob thought he had Docker figured out, but a security audit revealed hidden vulnerabilities. With Josies help, he learns the key practices to build secure Docker images—pinning versions, running as non-root, and using tools like Trivy. Join Bob on his security-first journey!

The Quest for Secure Docker Images: Bobs Journey

Meet Bob, a software developer who just started exploring Docker. After successfully containerising his first app, Bob thought he had it all figured out. But a routine security audit revealed vulnerabilities in his image. This is the story of how Bob learned the importance of building secure Docker images and took steps to safeguard his containerised apps.

📂 Code Repository: Explore the complete code and configurations for this article on GitHub.

View Repository on GitHub

The Risk of latest – Bobs First Lesson

Bob was on a roll. His Dockerfile was simple, his app was working, and he felt like a Docker pro. His Dockerfile started with:

FROM node:latest

Bob thought he was being smart by using the latest version. After all, staying up-to-date meant fewer bugs, right?

Fast forward a few months. Bobs app was being redeployed to production, but something was wrong. The deployment pipeline was failing, and when the app did start, it was crashing in unexpected ways.

After hours of troubleshooting, Bob traced the issue back to the latest tag in his Dockerfile. Without his knowledge, the latest Node.js image had been updated. The new version introduced breaking changes and, even worse, security vulnerabilities. Bob quickly realised that using latest made his builds unpredictable and vulnerable.

Key Takeaways:

  • Pinning base images ensures consistency and stability in builds. You control the version and can test updates before they impact production
  • Bob learned that by explicitly stating the image version, he could avoid surprises like these

The Solution: Pinning Versions

Bob realised that pinning a specific version was the safer route. He changed his Dockerfile:

FROM node:22.9.0

By pinning the version to 22.9.0, Bob ensured that his image would always use the same version of Node.js and wouldn't unexpectedly change during builds.

But this was just the beginning. Bob knew he'd have to keep track of this version and update it when security patches were released, but at least now, he was in control.

Key takeaways:

  • Control your dependencies: pinning base images reduces risk and ensures consistency
  • Stay informed: regularly check for security updates on your pinned base image, but test before upgrading
  • Leverage tools: tools like Dependabot can help monitor for new releases and alert you to potential vulnerabilities

Bob Meets the Non-Root Rule

Feeling good about solving the latest issue, Bob moved on. But then came the security audit. The auditor flagged something new: “Why is your app running as root in the container?” they asked.

Bob was puzzled. In his Dockerfile, there was no mention of a user at all. His app ran, so he didn't think twice about it. But that was a problem:

# The default configuration runs the container as root
FROM node:22.9.0
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

The default user for most base images is root, which meant Bobs app was running with full privileges inside the container. If an attacker ever managed to exploit his app, they would have root access—not only inside the container but potentially to the host system.

The auditor explained to Bob that running a container as root opens up a huge attack surface. If Bobs app had a vulnerability and an attacker gained access, they could execute commands with root privileges, potentially compromising the entire environment.

The Fix: Using a Non-Root User

Bob knew he had to change his Dockerfile. He added a non-root user inside the container:

FROM node:22.9.0

# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set the working directory and copy the app files
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm install

# Switch to the non-root user
USER appuser

# Run the application
CMD ["npm", "start"]

Now, even if someone exploited Bobs app, the attacker would only have access as appuser, a user with restricted privileges.

Key takeaways:

  • Never run containers as root unless absolutely necessary: Running as root can lead to privilege escalation
  • Use the USER directive: Switch to a non-root user to limit the potential damage if your app is compromised
  • Least Privilege Principle: Always provide the minimal level of access required

The Power of Minimal Base Images – Bobs Next Level

Feeling more confident, Bob went a step further. His image was secure now, but he noticed something strange. His Docker image was large—much larger than expected. Curious, Bob decided to investigate.

He ran the following command to check the size of his image:

docker images

It turned out his image, which was based on node:16-buster, was over 700MB. That's when Bob learned about image size and why it matters...

Large images come with several problems:

  • Slower build times: The bigger the image, the longer it takes to build and deploy.
  • More attack surface: Full images like buster or ubuntu come with many unnecessary packages. Each package is a potential vulnerability.
  • More dependencies to monitor: The more packages, the more you need to keep track of for updates and security patches.

Bob realised that his app didn't need all the extra packages that came with a full buster image. That's when he discovered Alpine.

Alpine is a lightweight Linux distribution designed for simplicity and security. Switching to an Alpine-based image could reduce the number of installed packages, lowering the potential attack surface.

Bob updated his Dockerfile to use the Alpine version of Node.js:

FROM node:22.9.0-alpine

Using these minimal images means there are fewer packages which reduces the overall attack surface of the containers being run. In most cases, it also means they build quicker, especially if you are extending them.

Key takeaways:

  • Choose minimal images like Alpine or mini-deb to reduce the size and scope of your image
  • Smaller images are faster and more secure: By using only the packages your app needs, you reduce the attack surface
  • Test compatibility: Alpine may not be suitable for all apps, so test thoroughly to ensure your app works with the minimal base image

Bob Discovers the Power of Vulnerability Scanners with Josies Help

Bob was feeling good about the improvements he'd made to his Docker images—pinning versions, running as a non-root user, and switching to a minimal base image like Alpine. He felt like his image was secure and ready for production. Then one day, his colleague Josie stopped by and asked:

“Hey Bob, have you scanned your images for vulnerabilities?”

Bob had heard of vulnerability scanners, but he hadn't used one yet. Josie explained that while tools like Docker Hubs vulnerability scanner focus on system-level packages in the image (like OpenSSL or glibc), they don't scan the application dependencies, such as those installed by npm.

That got Bob thinking. His app had several npm packages, and if one of them had a vulnerability, his app could still be at risk—even though his base image was secure.

Josie suggested Bob try Trivy, a tool that can scan both the base image and the application dependencies, catching issues in packages installed via npm and packages installed via composer for those PHP apps.

There are multiple ways that Bob can leverage Trivvy, Josie recommends he installs it from using the latest release tgz as follows:

$ mkdir trivy
$ cd trivvy
$ wget https://github.com/aquasecurity/trivy/releases/download/v0.56.1/trivy_0.56.1_Linux-64bit.tar.gz
$ tar zxvf trivy_0.56.1_Linux-64bit.tar.gz
$ sudo mv trivy /usr/local/bin/
$ trivy --version
Version: 0.56.1

With Trivy now installed, Bob was ready to scan his Docker images for vulnerabilities in both system-level packages and application dependencies, like those installed by npm by running something like:

$ trivy image my-node-webapp:1.0.0

+------------------+------------------+----------+-------------------+---------------+------------------------------------------+
|   PACKAGE NAME   |    INSTALLED     | SEVERITY |    DESCRIPTION    | FIX AVAILABLE |                 REFERENCE                |
+------------------+------------------+----------+-------------------+---------------+------------------------------------------+
| libssl1.1        | 1.1.1l-r0        | HIGH     | OpenSSL Security  | 1.1.1k-r0     | CVE-2021-3450                            |
| express          | 4.17.1           | MEDIUM   | Directory Traversal | 4.17.2      | CVE-2020-7729                            |
| lodash           | 4.17.20          | HIGH     | Prototype Pollution| 4.17.21      | CVE-2021-23337                           |
+------------------+------------------+----------+-------------------+---------------+------------------------------------------+

Trivy ran through his image, scanning both the base image and the application dependencies from his package.json and npm install process. The report listed vulnerabilities not only in the base image but also in the npm packages Bob had installed.

The report showed vulnerabilities in:

  1. libssl1.1 (from the base image),
  2. express (from the npm packages),
  3. lodash (another npm package).

To Bobs surprise, while his base image had a known OpenSSL vulnerability, some of his npm packages were also vulnerable, including a high-severity issue with lodash.

Josie explained that while Docker Hubs vulnerability scanner would catch the OpenSSL issue, it likely wouldn't detect the vulnerabilities in Bobs npm packages. Trivy, on the other hand, could scan both system-level packages and application dependencies, giving Bob a full picture of his images security status.

With the vulnerabilities identified, Bob took action. He updated his package.json file to patch the vulnerable npm packages. For instance, he updated lodash to a safer version:

{
  "dependencies": {
    "express": "^4.17.2",
    "lodash": "^4.17.21"
  }
}

Bob ran npm install to update his dependencies and then updated his dockerfile to include the OpenSSL upgrade:

FROM node:22.9.0-alpine

# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Set the working directory and copy the app files
WORKDIR /app
COPY --chown=appuser:appgroup . .
RUN npm install

# Switch to the non-root user
USER appuser

# Run the application
CMD ["npm", "start"]

When Bob rebuilds and rescans his image Trivy returns no results showing that he has successfully patched both his npm installation and the version off OpenSSL that was previously listed

$ docker build -t my-node-webapp:1.1.1 .
$ trivy image my-node-webapp:1.1.1

...

Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

This time, the scan showed no vulnerabilities in his npm packages or the base image. Josie gave Bob a thumbs up ... his app was now much more secure.

Using Docker Hubs Vulnerability Scanner

In addition to Trivy, Bob decided to enable Docker Hubs vulnerability scanner as a backup to catch issues related to the base image. While Docker Hubs scanner doesn't check the application dependencies (like npm packages), it's still a great way to monitor the system-level packages installed in the image.

  1. Bob logged into Docker Hub and enabled vulnerability scanning for his repository by navigating to the repository settings and turning on the security scanning feature
  2. After rebuilding and securing his image, Bob pushed the updated version to Docker Hub using docker push bob/my-node-webapp:1.1.1
  3. Docker Hubs scanner automatically ran a scan on Bobs image, flagging any vulnerabilities related to the system-level packages. While it didn't catch the npm issues (since it focuses on the base image), it confirmed that Bob had successfully patched the OpenSSL vulnerability in his Alpine image

Key takeaways:

  • Scan your entire image: Use tools like Trivy to scan both the base image and your application dependencies (like npm packages)
  • Regularly scan for vulnerabilities: Even after securing your Dockerfile, vulnerabilities can appear in both the base image and app libraries
  • Complementary tools: While Docker Hubs scanner checks system packages, Trivy goes deeper by also looking at your applications code and dependencies
  • Patch vulnerabilities quickly: When vulnerabilities are detected, update the affected packages (whether it's system packages like OpenSSL or npm packages like lodash) to the latest safe version

Bobs Secure Dockerfile

After learning these lessons, Bob sat back to review his journey. He had gone from a beginner with an insecure and bloated Docker image to a developer who could build lightweight, secure containers.

Here's Bobs final, secure Dockerfile:

# Pin the Node.js version to avoid unexpected updates
FROM node:22.9.0-alpine

# Create a non-root user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Update system packages and fix the OpenSSL vulnerability
RUN apk --no-cache upgrade openssl

# Set the working directory and copy the app files with the correct ownership
WORKDIR /app
COPY --chown=appuser:appgroup . .

# Install only necessary packages
RUN apk add --no-cache curl

# Switch to the non-root user
USER appuser

# Expose the port and run the application
EXPOSE 3000
CMD ["npm", "start"]

From this we can see that Bob is now pinning his image to a particular version which is known in terms of vulnerabilities, it runs as a non-root user which helps minimise the risk of privilege escalation and uses alpine to keep it as small as possible ensuring only the required packages are available and reducing the overall attack surface.

Bobs Secure Workflow with Josies Help

With Josies guidance, Bob now had a rock-solid workflow for building and securing Docker images. Here's the new process he followed:

  1. Pin Base Images: Avoid latest to ensure predictable builds
  2. Run as Non-Root: Use a non-root user inside the container to limit privileges
  3. Use Minimal Images: Choose Alpine or mini-deb to reduce the attack surface
  4. Scan with Trivy: Run scans to detect vulnerabilities in both system-level packages and application dependencies like npm packages
  5. Monitor with Docker Hub: Use Docker Hubs built-in vulnerability scanner as an additional layer to catch system-level vulnerabilities (optional)
  6. Update Regularly: Keep both the base image and application dependencies up to date with security patches

Bob had come a long way. With the right tools and processes in place, he was now confident his Docker images were secure, fast, and ready for production.


Bob's journey shows that building secure Docker images is a continuous process. By applying these best practices, you can safeguard your containers from vulnerabilities and reduce the attack surface. As Docker evolves, staying proactive with regular scans, updates, and secure configurations is key. But there's more to learn—multi-stage builds are an advanced method that Bob explored next to further streamline and secure his Docker workflow. Ready to dive deeper? Read Part 2: Mastering Multi-Stage Builds and join Bob as he tackles new challenges and gains even greater control over container security.