Skip to content

A simple web app that compares upstream node, python, postgres, and nginx container images to Chainguard container images.

License

Notifications You must be signed in to change notification settings

troy-chainguard-dev/three-tier-sample-app

Β 
Β 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

7 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

three-tier-sample-app

Scan to access repository

Scan to access this repository!


This is a sample web application showcasing a multi-tier architecture using Node.js, Python (Flask), PostgreSQL, and nginx.

We will walk through and build this app two different ways and then use a Python script with grype to compare the two deployments:

  • Legacy version with traditional upstream container images.
  • Chainguard version using minimal, secure-by-default, zero to near-zero CVE container images.

Architecture

Multi-tier architecture diagram

Getting Started

Prerequisites

  • Terminal access
  • Docker (container runtime)
  • Docker Compose (multi-container build and orchestration)
  • grype (for scanning container images)
  • Python 3.7+ (for vulnerability reporting)
  • Clone this directory and cd into it from your terminal:
cd three-tier-sample-app

Python Virtual Environment Setup

Create and activate a Python virtual environment for the scanning tools. Example where the Python binary is python3:

python3 -m venv venv

For bash/zsh based terminals:

source venv/bin/activate

For Windows terminals:

.\venv\Scripts\activate

Install Python dependencies:

For bash/zsh based terminals:

pip install -r scanners/requirements.txt

For Windows terminals:

pip install -r .\scanners\requirements.txt

Note: The environment will need to be activated to run the scanners in later steps so we can work within the virtual Python environment for the remainder of the steps


1. Build and Run the Legacy Version

Container Images Used

Legacy container images

πŸ”— Upstream Docker Images:

First we will use docker compose to build the app using the legacy images. The following docker compose command will recognize the docker-compose.yaml file in the root project directory and build custom images for each component based on public upstream base images from Docker Hub (node:latest, python:latest, nginx:latest, postgres:latest). Note that the --build flag forces Docker to rebuild the images, and if the base images aren't cached locally, Docker will pull them from Docker Hub, which may take a long time on a poor network connection!

docker compose up -d --build

Expected output:

[+] Running 8/8
 βœ” backend Built                           0.0s
 βœ” frontend Built                          0.0s
 βœ” nginx Built                             0.0s
 βœ” Network three-tier-sample-app_default    0.1s
 βœ” Container legacy-db Started             0.3s
 βœ” Container legacy-backend Started        0.3s
 βœ” Container legacy-frontend Started       0.3s
 βœ” Container legacy-nginx Started          0.3s

Verify It's Running

To ensure the containers are running:

docker ps

Expected output:

CONTAINER ID   IMAGE                              STATUS         PORTS                    NAMES
9da02e3b2f76   three-tier-nginx-legacy:latest      Up 3 minutes   0.0.0.0:80->80/tcp       legacy-nginx
26e1462fabb0   three-tier-frontend-legacy:latest   Up 3 minutes                            legacy-frontend
3cc427943561   three-tier-backend-legacy:latest    Up 3 minutes   0.0.0.0:5000->5000/tcp   legacy-backend
22f51e9cdff9   three-tier-db-legacy:latest         Up 3 minutes   0.0.0.0:5432->5432/tcp   legacy-db

Let's start a log view of our containers before we test our app:

docker compose logs -f

Open http://localhost:80 in your browser to view the website:

Course Registration Website

Refresh the page and click some 'Register' buttons and look at our logs to see the output. We should see all the various components receiving and passing traffic:

legacy-db        | 2025-10-10 15:38:12.260 UTC [1] LOG:  database system is ready to accept connections
legacy-frontend  | [2025-10-10T15:38:17.514Z] GET / - 172.20.0.5
legacy-nginx     | 172.20.0.1 - - [10/Oct/2025:15:38:17 +0000] "GET / HTTP/1.1" 200 279 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
legacy-db        | 2025-10-10 15:38:17.558 UTC [69] LOG:  connection received: host=172.20.0.3 port=53718
legacy-db        | 2025-10-10 15:38:17.568 UTC [69] LOG:  connection authenticated: identity="user" method=scram-sha-256 (/var/lib/postgresql/data/pg_hba.conf:128)
legacy-db        | 2025-10-10 15:38:17.568 UTC [69] LOG:  connection authorized: user=user database=chaiku
legacy-db        | 2025-10-10 15:38:17.574 UTC [69] LOG:  statement: BEGIN
legacy-db        | 2025-10-10 15:38:17.575 UTC [69] LOG:  statement: SELECT id, name, credits FROM courses
legacy-db        | 2025-10-10 15:38:17.576 UTC [69] LOG:  disconnection: session time: 0:00:00.018 user=user database=chaiku host=172.20.0.3 port=53718
legacy-backend   | 172.20.0.5 - - [10/Oct/2025 15:38:17] "GET /courses HTTP/1.0" 200 -
legacy-nginx     | 172.20.0.1 - - [10/Oct/2025:15:38:17 +0000] "GET /api/courses HTTP/1.1" 200 298 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
legacy-db        | 2025-10-10 15:38:18.867 UTC [70] LOG:  connection received: host=172.20.0.3 port=53726

This confirms that our microservice app is up and operational!

Scan Legacy Images for CVEs

Now let's scan our running containers for security vulnerabilities:

Activate your virtual environment if you haven't already (macOS/Linux):

source venv/bin/activate

Run the scanner:

python3 scanners/scan-and-report.py

This single command will:

  • βœ… Detect all running containers from docker compose
  • βœ… Use Grype to scan each container image for known CVEs
  • βœ… Generate reports in multiple formats (CSV, HTML, Text, Excel)

Output files in ./scanners/scan-results/:

  • grype-legacy-images.csv - Raw vulnerability data
  • grype-legacy-images.html - Interactive web report (searchable, color-coded)
  • grype-legacy-images.txt - Terminal-friendly text report
  • grype-legacy-images.xlsx - Detailed Excel workbook with charts and worksheets

2. Tear Down the Legacy Stack

To clean everything, including volumes:

docker compose down -v

3. Build and Run the Chainguard Version

Container Images Used

Chainguard container images

πŸ™ Chainguard Images:

βœ… Zero to near-zero CVEs β€’ Minimal attack surface

We will now use Docker Compose to create our Chainguard version of the app by pointing to a specific compose file called docker-compose-chainguard.yaml This compose file will reference the specific cgr.dev/chainguard/<images> listed above

docker compose -f docker-compose-chainguard.yaml up -d --build

Expected output:

[+] Running 8/8
 βœ” backend Built                           0.0s
 βœ” frontend Built                          0.0s
 βœ” nginx Built                             0.0s
 βœ” Network three-tier-sample-app_default    0.1s
 βœ” Container three-tier-db-cg Started       0.3s
 βœ” Container cg-backend Started            0.4s
 βœ” Container cg-frontend Started           0.4s
 βœ” Container cg-nginx Started              0.4s

Verify It's Running

To ensure the Chainguard-based containers are running (notice the cg tags on container names):

docker ps

Expected output:

CONTAINER ID   IMAGE                           STATUS         PORTS                    NAMES
476abfd23815   three-tier-nginx-cg:latest       Up 5 minutes   0.0.0.0:80->80/tcp       cg-nginx
4a12bab4e30b   three-tier-frontend-cg:latest    Up 5 minutes                            cg-frontend
5151ef168869   three-tier-backend-cg:latest     Up 5 minutes   0.0.0.0:5000->5000/tcp   cg-backend
949fdcf98c9d   three-tier-db-cg:latest          Up 5 minutes   0.0.0.0:5432->5432/tcp   three-tier-db-cg

Let's start a log view of our containers before we test our app:

docker compose logs -f

Open http://localhost:80 in your browser to view the website:

Course Registration Website

Refresh the page and click some 'Register' buttons and look at our logs to see the output. We should see all the various components receiving and passing traffic:

three-tier-db-cg  | 2025-10-10 15:29:49.178 UTC [1] LOG:  database system is ready to accept connections
cg-frontend      | [2025-10-10T15:29:53.667Z] GET / - 172.20.0.5
cg-nginx         | 172.20.0.1 - - [10/Oct/2025:15:29:53 +0000] "GET / HTTP/1.1" 200 279 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
cg-backend       | 172.20.0.5 - - [10/Oct/2025 15:29:53] "GET /courses HTTP/1.0" 200 -
cg-nginx         | 172.20.0.1 - - [10/Oct/2025:15:29:53 +0000] "GET /api/courses HTTP/1.1" 200 298 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
cg-backend       | 172.20.0.5 - - [10/Oct/2025 15:29:55] "POST /register HTTP/1.0" 201 -
cg-nginx         | 172.20.0.1 - - [10/Oct/2025:15:29:55 +0000] "POST /api/register HTTP/1.1" 201 43 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"
cg-backend       | 172.20.0.5 - - [10/Oct/2025 15:29:58] "POST /register HTTP/1.0" 201 -
cg-nginx         | 172.20.0.1 - - [10/Oct/2025:15:29:58 +0000] "POST /api/register HTTP/1.1" 201 43 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15"

This confirms that our Chainguard-based microservice app is up and operational!

Scan Chainguard Images for CVEs

Now let's scan the Chainguard images for security vulnerabilities:

python3 scanners/scan-and-report.py

This will generate the same reports as above (CSV, HTML, Text, and Excel), plus comparison reports showing side-by-side security improvements.

View the reports:

# Terminal
cat scanners/scan-results/comparison-report-latest.txt

# Browser
open scanners/scan-results/comparison-report-latest.html

Image Comparison: Legacy vs Chainguard

Here's a snapshot comparison from a recent scan on 10/6/25 (your results may vary based on scan date and host system architecture):

Component Legacy Image Size CVEs Chainguard Image Size CVEs
nginx nginx:latest ~187 MB 150+ cgr.dev/chainguard/nginx:latest ~50 MB 0-2
Frontend node:latest ~1.1 GB 200+ cgr.dev/chainguard/node:latest ~75 MB 0-2
Backend python:latest ~1.0 GB 180+ cgr.dev/chainguard/python:latest ~50 MB 0-1
Database postgres:latest ~420 MB 120+ cgr.dev/chainguard/postgres:latest ~280 MB 0-1
TOTAL ~2.7 GB 650+ ~455 MB 0-6

Key Takeaways:

  • πŸ”» 83% reduction in total image size (2.7 GB β†’ 455 MB)
  • πŸ”» 99% reduction in CVEs (650+ β†’ 0-6)

4. Tear Down the Chainguard Stack

To clean everything, including volumes:

docker compose down -v

Migrating From Upstream to Chainguard

Let's take a closer look at how Chainguard images differ from upstream images by comparing the Dockerfiles for Python and some image details.

Legacy Dockerfile (Upstream Python)

FROM python:latest

WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt

CMD ["python", "wsgi.py"]

Resulting Image:

docker images | grep three-tier-backend-legacy
three-tier-backend-legacy latest 4deda6071707 2 days ago 1.64GB

Why is this image so big?

The python:latest image is built on top of Debian Linux (specifically Debian Trixie), which includes a full operating system with hundreds of packages that aren't needed for running Python applications. Let's examine what's inside:

Check the base OS:

docker run --rm python:latest cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
NAME="Debian GNU/Linux"
VERSION_ID="13"
VERSION="13 (trixie)"
...

A Grype scan reveals the scale of unnecessary packages:

grype python:latest
   ...
   β”œβ”€β”€ βœ” Packages                        [477 packages]
   β”œβ”€β”€ βœ” Executables                     [1,403 executables]
   β”œβ”€β”€ βœ” File metadata                   [21,671 locations]
   └── βœ” File digests                    [21,671 files]

Where do all these packages come from? The base Debian OS brings most of them:

List installed debian packages in python:latest

docker run --rm python:latest dpkg -l | wc -l
472 packages

The problem: Most of these 477 packages are inherited from Debian and have nothing to do with Python. They include:

  • πŸ“¦ Package managers (apt, dpkg)
  • πŸ› οΈ Build tools (gcc, make, perl)
  • πŸ“š System libraries (systemd, pam, glibc utilities)
  • 🐚 Shells and utilities (bash, grep, sed, coreutils)
  • πŸ”§ Services and daemons you'll never use

Each unnecessary package = more CVEs, more attack surface, more storage.

Chainguard Dockerfile (Hardened Python)

FROM cgr.dev/chainguard/python:latest-dev AS builder

WORKDIR /app

COPY requirements.txt .

RUN python -m venv /app/venv && \ 
    /app/venv/bin/pip install --no-cache-dir -r requirements.txt

FROM cgr.dev/chainguard/python:latest

WORKDIR /app

ENV PYTHONUNBUFFERED=1
ENV PATH="/venv/bin:$PATH"

COPY . .
COPY --from=builder /app/venv /venv

ENTRYPOINT [ "python", "wsgi.py" ]

Resulting Image:

docker images | grep three-tier-backend-cg
three-tier-backend-cg latest 526d24399c50   23 hours ago    126MB

What makes Chainguard different?

Chainguard images are built on Wolfi, a Linux undistro designed specifically for containers - NOT a traditional Linux distribution like Debian.

Prove it's Wolfi (using -dev variant since runtime has no shell):

docker run --rm --entrypoint /bin/sh cgr.dev/chainguard/python:latest-dev -c "cat /etc/os-release"
ID=wolfi
NAME="Wolfi"
PRETTY_NAME="Wolfi"
HOME_URL="https://wolfi.dev"

The runtime image is truly distroless - it doesn't even have a shell.

Try to run a shell in the production runtime image:

docker run --rm --entrypoint /bin/sh cgr.dev/chainguard/python:latest -c "echo test"
exec: "/bin/sh": stat /bin/sh: no such file or directory

Package comparison: Debian vs Wolfi runtime

Debian-based Python runtime:

docker run --rm python:latest dpkg -l | grep "^ii" | wc -l
467 packages

Chainguard Python runtime (Wolfi-based):

grype cgr.dev/chainguard/python:latest
 βœ” Cataloged contents
   β”œβ”€β”€ βœ” Packages                        [22 packages]
   β”œβ”€β”€ βœ” Executables                     [27 executables]

The Chainguard runtime has only 22 packages - just Python and essential dependencies. The Debian runtime has 467 packages including full OS tools like shells, package managers, and build tools.

The Chainguard approach:

  • 🎯 Wolfi-based: Purpose-built minimal OS designed for containers, not bloated Debian/Ubuntu
  • πŸ—‘οΈ Distroless runtime: No shell, no package manager, no unnecessary tools
  • πŸ“¦ 95% fewer packages: 22 vs 467 (only what Python needs to run)
  • πŸ”’ Non-root by default: Runs as user 65532 (nonroot)
  • πŸ—οΈ Multi-stage build: Use -dev image to build, minimal runtime for production
  • πŸ”„ Daily updates: Automated rebuilds with latest security patches
  • πŸ“‹ Built-in SBOMs: Cryptographically signed software bill of materials for compliance

Summary: By The Numbers

Metric Upstream Images Chainguard Images
Total CVEs 650+ 0-6
Total Size ~2.7 GB ~455 MB
Packages (Python) 467 22
Attack Surface Full OS with shells, package managers, build tools Distroless - application runtime only

The bottom line: In production, every CVE means security incidents, compliance violations, and emergency patching. Chainguard Images eliminate 99% of these concerns by shipping only what your application needs to runβ€”nothing more.


Further Reading

Want to learn more about Chainguard Images and secure container practices?

  • πŸ“š Chainguard Images Directory - Explore all available Chainguard images with detailed documentation, SBOMs, and security information
  • 🐍 Python Image Comparison - Deep dive into the Python image architecture, variants, and security benefits
  • πŸŽ“ Chainguard Academy - Free courses and resources for learning about container security, supply chain security, and cloud-native best practices

About

A simple web app that compares upstream node, python, postgres, and nginx container images to Chainguard container images.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Python 96.8%
  • JavaScript 2.5%
  • Other 0.7%