Skip to main content

Project Lab 01: Multi-Stage Builds & Secure Image Optimization

This lab focuses on the best practice for creating minimal, production-ready container images. We will take a simple application, separate the build environment from the runtime environment, and optimize the final image size and security.

Topics Covered: Dockerfile, Multi-Stage Builds, .dockerignore, Least Privilege (Non-Root User).


1. THE PROBLEM: BLOATED, INSECURE IMAGES

A typical, non-optimized Docker build includes all compiler tools, source code, and development dependencies in the final image.

Image TypeSizeSecurity
Monolithic BuildLarge (e.g., 800MB)High Risk (Includes GCC, NPM, etc.)
Multi-Stage BuildSmall (e.g., 20MB)Low Risk (Only the final binary/app)

2. PROJECT SETUP: Node.js API

We will use a simple Node.js application that needs compilation (npm install) to run.

2.1 Application Code (server.js)

// server.js
const express = require('express');
const app = express();
const port = 8080;

app.get('/', (req, res) => {
res.send('Hello from the Multi-Stage Optimized Container!');
});

app.listen(port, () => {
console.log(`App listening at http://localhost:${port}`);
});

2.2 Dependencies (package.json)

{
"name": "node-api",
"version": "1.0.0",
"description": "Simple API for multi-stage build demo",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.17.1"
}
}

2.3 Exclusion File (.dockerignore)

This is critical for efficiency. It prevents node modules, Git files, and log files from being sent to the Docker build context.

# .dockerignore
node_modules
npm-debug.log
.git
.gitignore
coverage
temp/
*.log

3. SOLUTION: THE MULTI-STAGE DOCKERFILE

The multi-stage pattern uses an initial, heavy "builder" image to compile the application and a second, minimal "runtime" image to host only the final output.

3.1 Dockerfile (Dockerfile)

# Stage 1: The Build Environment (Heavy)
# FROM node:20-alpine AS builder
FROM node:20-slim AS builder

WORKDIR /app

# Copy package files first to leverage Docker cache layers
COPY package*.json ./
RUN npm install

# Copy application source code
COPY . .

# Build step placeholder (e.g., RUN npm run build for React/Angular)
# For this simple app, npm install is sufficient

# Stage 2: The Final Production Environment (Minimal & Secure)
# Use a minimal, non-root base image (node:20-slim, or 'scratch' for Go/Rust)
FROM node:20-slim

# 1. SECURITY: Create a non-root user (Best Practice)
RUN addgroup --system appgroup && adduser --system --uid 1001 appuser --ingroup appgroup
USER appuser
WORKDIR /home/appuser/app

# 2. COPY: Only copy the production-ready node_modules and code from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server.js .
COPY --from=builder /app/package.json .

# 3. EXPOSE and CMD
EXPOSE 8080
CMD ["npm", "start"]

Reference Topic: For more on least privilege, refer to 10-security/1-pod-security-standards.md.


4. EXECUTION AND VERIFICATION

4.1 Build the Image

The --target flag is unnecessary for a final build, as Docker automatically uses the last FROM stage. The -t tags the final small image.

docker build -t node-optimized-app:v1 .

4.2 Verify Image Size (The Success Check)

Compare the final image size against a hypothetical single-stage build (which would be the size of the node:20-slim base image plus its build tools).

docker images | grep node-optimized-app

Expected Observation: The final image is significantly smaller than the initial Node.js build environment (which often includes development tools).

4.3 Run and Audit Security

Run the image and use docker exec to audit the process identity.

docker run -d --name optimized-api -p 8080:8080 node-optimized-app:v1

# Audit the process identity
docker exec optimized-api sh -c "whoami && id"

Expected Output:

appuser
uid=1001(appuser) gid=1001(appuser) groups=1001(appuser)

Success: The application is running as the non-root user appuser (UID 1001), satisfying the least privilege principle.


5. TROUBLESHOOTING AND NINJA COMMANDS

5.1 Inspecting Stage Layers

Use docker history to see the layer IDs and commands for the final image. Note that only commands in Stage 2 (and copied assets) appear.

docker history node-optimized-app:v1

5.2 Cleaning the System

Remove all intermediary build stages (dangling images) to reclaim disk space.

docker system prune -f
docker rmi $(docker images -f "dangling=true" -q)