From Dockerfile to Distroless - Container LabSchritt 7 von 12: Reducing the attack surface
Alle Labs

Reducing the attack surface

At the moment, we have a good starting point for our Node.js application, but there's still room for improvement. Let's investigate a bit. Simply start your docker-compose setup and open a shell to it (if it's not running):

docker compose up -d
docker exec -it lab-container-basics-learning-app-1 /bin/bash

You learned a new thing here, so far your compose setup was running in the foreground and when you hit Ctrl+C it got removed. By using the -d flag (for detached), everything gets started in the background. Therefore you can continue working in the same shell.

After you executed the docker exec command, you are inside the shell of our learning app container. The prompt should look similar to this:

root@427b81298043:/app

In fact, this prompt alone shows us two things we could avoid in production (when using Node.js):

  • The container runs as root
  • The container has a shell

In the following steps, we will try to avoid this and therefore reduce the attack surface of the container by utilizing multi-stage builds and using a distroless image.

Multi-Stage Builds

Multi-Stage builds can help us separate the steps to build the software inside a container from the container which is really running in production. In our case, we will spin up a Node.js image, which takes over all of the build steps (therefore downloading dependencies). In a second step, we will run a distroless container, copy the app directory in it and simply run our app there. Let's change our Dockerfile to reflect these changes:

FROM node:22 AS builder

COPY app /app

WORKDIR /app

RUN npm install

# Our Runtime Container
FROM gcr.io/distroless/nodejs22-debian12

COPY --from=builder /app /app

USER nonroot

CMD [ "/app/app.js" ]

After this, you can stop the docker compose setup, rebuild the containers and restart it again:

docker compose stop
docker compose build --no-cache
docker compose up

Now, the container starts again and you should be able to browse to it again. Everything should look as before, with some minor differences. Try to open a shell inside of the container:

docker exec -it lab-container-basics-learning-app-1 /bin/bash

Now you should see a message that the command bash cannot be found. This should be the same if you use /bin/sh or any other shell. Furthermore, our process is now executed with the user nonroot, which is baked into the distroless image we are using for our production container.

🎉Congratulations

With this, our attack surface is reduced, the only downside is that we cannot spawn a shell into our container which can be changed by switching to a different image for debugging purposes. This should be no problem, if your application exposes as much telemetry data (metrics and traces), to get a clear idea on what's going on