Sub-Module 1.1: Introduction & Basic Images

Sub-Module Overview

Duration: 10-12 minutes
Learning Objectives:

  • Understand available types of hardened images

  • Use service images and containerized tools

  • Create simple runtime applications with Python

  • Compare distroless vs distribution base images

This workshop environment has been optimized for learning efficiency. Required tools, directories, sample applications, and configurations have been pre-installed to focus on core hardened image concepts rather than repetitive setup tasks.

Introduction

In this sub-module, you’ll get hands-on with Red Hat Hardened Images. You’ll explore the three main categories of images (services, tools, and runtimes), understand the difference between distroless and traditional base images, and build your first containerized applications using these minimal, secure foundations.

Available types of hardened images

There are three main categories of images provided in the collection of hardened images: runtimes, services, and tools. Each of these are built in the same fashion but provide different capabilities. Runtimes include various languages like java, python, and dotnet and also have -builder variants to allow for complex application tasks. Services include network based applications like caddy, memcached, and haproxy for deploying minimal versions of common services. Tools include things like git, curl, and jq for running a known containerized version in an isolated manner like within a CI pipeline.

Services and tools

Step 1: Examine Pre-Created Web Content

Let’s start by examining a basic HTML landing page that has been created for use in our examples.

cat ~/webserver/index.html
Expected output:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Project Hummingbird</title>
    <!-- Load Tailwind CSS from CDN for instant styling -->
    <script src="https://cdn.tailwindcss.com"></script>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;800&display=swap" rel="stylesheet">
    <style>
        body {
            font-family: 'Inter', sans-serif;
        }
    </style>
</head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen p-4">
    <div class="text-center">
        <h1 class="text-6xl md:text-8xl font-extrabold text-indigo-700
                   hover:scale-105 transition duration-300 ease-in-out">
            Welcome to Red Hat Hardened Images
        </h1>
        <p class="mt-4 text-xl text-gray-500">
            Your simple Caddy server is running!
        </p>
        <p class="mt-5 text-xl text-gray-400">
	    ....everybody loves hummingbirds
        </p>
    </div>
</body>

</html>

This is a straightforward page, with a little CSS and font prettification, nothing complex.

With our index.html ready to serve, we need a web server. There are several available, and caddy image can be used for this sort of simple testing by running the default container image and passing the webserver directory as a volume mount.

podman run -d --rm --name caddy-server \
  -p 8080:8080 \
  -v ~/webserver:/usr/share/caddy:ro,Z \
  quay.io/hummingbird/caddy:latest

Check to see if caddy is serving the file we just looked at.

curl http://localhost:8080

For now, stop the caddy server.

podman stop caddy-server

This will work for very simple testing, but to use this image in a production environment, it’s better to create a custom Containerfile for your final webserver.

cat > ~/webserver/Containerfile << 'EOF'
FROM quay.io/hummingbird/caddy:latest

COPY index.html /usr/share/caddy/
EOF

This Containerfile simply adds the content to be served to the default Caddy location to create the final container. If we needed more complex Caddy configurations, we can also create and add a custom Caddyfile to override the defaults in the image. We’ll revisit that in a later exercise.

Build and run the new image

podman build -t my-website -f ~/webserver/Containerfile ~/webserver
podman run -d --rm --name webserver -p 8080:8080 my-website

Check the new webserver still serves the file we expect

curl http://localhost:8080

Network service images are fairly straightforward, but what about tools? Why would we want a containerized version of curl available?

On the current system, we have curl installed as an RPM. But, what if you were building on a system without curl and without the privileges to install new packages? This can be a common occurence in CI/CD runners, systems used to build software in a pipeline often with minimal packages installed.

In that case we could run the same test as we did with the local package using a known good and specific version of curl via podman

podman run --rm --net=host quay.io/hummingbird/curl:latest http://localhost:8080

Note the use of --net=host here to let an user container connect to the exposed port on the host. There’s a wide range of possibilities here, like testing an API call with a POST request, downloading a file for local inspection, etc.

We should see the same results as the local binary. But these can be useful to reduce the dependency on host customization.

Go head and stop the webserver.

podman stop webserver

Containerized versions of common tools let’s us provide a known good, common set of capabilities to any host while ensuring the same security profiles to the tools as any other hardened image. This makes keeping fleets of systems, like build or test farms, in sync since they can all use the exact same tool image instead of requiring local tools. We can be sure we’ve got the right images by verifing the signature with a tool like cosign. We’ll cover more on that later.

Simple runtime examples

While services and tools can be useful, the most commonly used types of hardened images are runtimes. These provide the platform for various different languages and are typically available in different variants: eg default, builder, and fips. The details will vary from image to image, but the default variant is purpose built as a deployment target with only critical dependencies and security metadata. The builder variant adds to the default both rpm and language specific package managers to allow for installation of additional dependencies, debug and troubleshooting, and for multi-stage-containerfiles. The fips variants include specially validated OpenSSL modules for the enforcement of specific crypto algorithms required for certain regulatory regimes. We’ll look at this variant in a later module.

Create a simple Python Flask application

We’ll look at how using the UBI Python images you have today differ from the new default variant. To do that, we need a Python application to containerize.

A sample Flask application should be available on the system, let’s take a look.

cat ~/flask/app.py
Expected output:
from flask import Flask

app = Flask(__name__,)

@app.route("/")
def index():
    return app.send_static_file("index.html")

if __name__ == "__main__":
    # Listen on all interfaces (0.0.0.0) on port 8080
    app.run(host="0.0.0.0", port=8080)

The application uses the development server to serve an HTML file as a static file, which means it will be added to the static directory in our Containerfile later.

Copy the index.html from the Caddy example. No need to reinvent the wheel here.

cp ~/webserver/index.html ~/flask/

We’ll ship this application in two ways to explore some differences between the typical container build and one with a hardened image.

Distroless vs distribution base images

Hardened images are what’s called 'distroless' images. These styles of images are targeted builds that skip certain shells and tools typically found in standard base images. Since package managers are usually part of that missing toolset, that’s how the term 'distroless' was coined.

Let’s build a comparison image using UBI to see the difference. Then we can see what changes we need to make to move this to a hardened python image. In both cases, we’ll use a local mirror for python dependcies, mimicing the use of a secure enterprise index like Red Hat Trusted LIbraries.

First, lets look at a typical UBI based approach.

cat ~/flask/Containerfile.ubi
Expected output:
# Stage 1: Base Image from Red Hat UBI
FROM registry.access.redhat.com/ubi9/ubi

# Install pip to manage application dependencies (1)
RUN dnf -y install python3-pip && dnf clean all

# Create a non-root user and group for the application (2)
# Using a different UID/GID to avoid conflict with existing users in the base image.
RUN groupadd -r -g 1005 appgroup && \
    useradd -r -u 1005 -g 1005 -d /app -s /sbin/nologin -c "Application User" appuser

# Set the working directory in the container
WORKDIR /app

# Ensure ownership is set to the new non-root user
# COPY always executes as root
COPY --chown=appuser:appgroup app.py .
COPY --chown=appuser:appgroup index.html static/

# Set environment variables for Python
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Install application dependencies
USER root
RUN python3 -m pip install --extra-index-url http://localhost:8000 flask

# Switch to the non-root user for runtime
USER appuser

# Expose the port Flask will listen on
EXPOSE 8080

# Appropriately set the stop signal for the python interpreter executed as PID 1
STOPSIGNAL SIGINT
ENTRYPOINT ["python3", "./app.py"]
1 Package Installation: Traditional dnf install approach for dependencies
2 User Management: Manual user/group creation required (groupadd, useradd)

UBI characteristics

A local PyPI server is running on port 8000 to provide Flask dependencies reliably. This mimics enterprise artifact repositories and ensures consistent builds. Since we’re using a local PyPi mirror, we need to make sure podman uses the host’s networking to access mirror via localhost.

podman build --net=host -t my-flasksite:ubi -f ~/flask/Containerfile.ubi ~/flask

Let’s do a quick check on our UBI based application and see that we’re getting the results we expect.

podman run -d --rm --name flask-demo -p 8080:8080 my-flasksite:ubi

Checking with curl should show us the same index.html as before.

curl http://localhost:8080
podman stop flask-demo

Step 2: Examine the changes

The most obvious change is the base image, but there are a few other minor adjustments we need to make.

Our UBI example creates a non-root appuser to run the application. The hardened image already provides a default non-root user, so we can skip that step.

This means we do need to make sure we’re running the commands as the correct user for each step however. We can also skip installing pip as that’s already included in the default variant for python.

We’ll demonstrate this by installing the application dependecies globally as root instead of working in a virtual environment. This workflow is fine since we’re only ever installing one application in this image, but your choice should make the most sense for your applications and environment.

In practice those changes give us the following Containerfile

cat > ~/flask/Containerfile.hi << 'EOF'
# Stage 1: Builder base image
FROM quay.io/hummingbird/python:3.14 (1)

# Set the working directory in the container
WORKDIR /app

# Copy the application files to the target directory
# COPY always executes as root
COPY --chown=65532 app.py . (2)
COPY --chown=65532 index.html static/

# Set environment variables for Python
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

# Switch to root from the non-root user to install dependcies
# By default, these install in /tmp/.local for the non-root user
USER root
RUN pip install --index-url http://localhost:8000/simple flask

# Switch to the default non-root user for runtime
USER ${CONTAINER_DEFAULT_USER} (3)

# Expose the port Flask will listen on
EXPOSE 8080

# Appropriately set the stop signal for the python interpreter executed as PID 1
STOPSIGNAL SIGINT
ENTRYPOINT ["python", "./app.py"]

EOF

Distroless characteristics:

1 Change our base image to the hardened default image
2 Change the user in COPY to the provided default UID instead of adding appuser
3 Reset the USER with the provded ENV variable

Let’s go ahead and build our app on the dafault variant.

podman build --net=host -t my-flasksite:hi -f ~/flask/Containerfile.hi ~/flask
STEP 7/11: RUN pip install --index-url http://localhost:8000/simple flask
error running container: from /usr/bin/crun creating container for [/bin/sh -c pip install --index-url http://localhost:8000/simple flask]: executable file `/bin/sh` not found: No such file or directory

What happened here? One of the changes to 'distroless' images like our new default is the removal of things like shells. Here pip needs a shell to install our application dependencies.

Without a shell, what can we do? Switch to the builder variant.

Edit the Containerfile and change the FROM to include the appropriate tag.

vi ~/flask/Containerfile.hi
# Stage 1: Builder base image
FROM quay.io/hummingbird/python:3.14-builder (1)
1 The tagging format for python images is <version>-builder

Let’s retry the build using the new variant.

podman build --net=host -t my-flasksite:hi -f ~/flask/Containerfile.hi ~/flask

That Successfully built, so let’s run it and see if we get our index.html

podman run -d --rm --name flask-demo -p 8080:8080 my-flasksite:hi
curl http://localhost:8080

Changing existing Containerfiles to use hardened images can be fairly straightforward. To help understand the default settings for hardened images, we provide tables that compare them to upstream versions. This not only includes the properties, but also the reasoning behind any changes.

Step 3: Examine our two images

There are some other differences in the packages installed and the tools avaialble. List the images just built. Note that the hardened image is just over half the size of the UBI based image.

podman images my-flasksite
Expected output:
REPOSITORY              TAG         IMAGE ID      CREATED            SIZE
localhost/my-flasksite  hi          7355f3daefdc  About a minute ago  182 MB
localhost/my-flasksite  ubi         472a6a9ebee8  9 minutes ago       254 MB

The new image is smaller, but we’ve included everything in the builder image in our application, including a shell that we won’t want in prodcution.

podman exec -it flask-demo /bin/sh -c 'ls -al /app'
podman stop flask-demo

How do we remove all of the unnecessary parts? Multi-stage builds provide the answer, and we’ll explore that pattern in the next sub-module.

Summary

Congratulations! You’ve completed Sub-Module 1.1 and explored the foundations of Red Hat Hardened Images.

What You’ve Accomplished

✅ Explored three types of hardened images (services, tools, runtimes)
✅ Used containerized Caddy web server and curl tool
✅ Created a Python Flask application
✅ Compared UBI and Hardened Image approaches
✅ Understood distroless image characteristics
✅ Learned about builder vs default variants

Key Takeaways

Image Categories:

  • Services: Web servers (Caddy), caching (Memcached), load balancers (HAProxy)

  • Tools: Containerized versions of common utilities (curl, git, jq)

  • Runtimes: Language platforms (Python, Java, Node.js, Go, .NET)

Image Variants:

  • default: Minimal runtime image with no package managers or shells

  • builder: Adds package managers and tools for building applications

  • fips: FIPS 140-3 validated crypto modules for compliance

Distroless Benefits:

  • Smaller image sizes (50%+ reduction)

  • Reduced attack surface (no unnecessary tools)

  • Faster downloads and deployments

Next Steps

In Sub-Module 1.2: Multi-Stage Builds, you’ll learn how to combine builder and runtime variants to create production-ready images that are both flexible during development and minimal in deployment.