Sub-Module 1.1: Introduction & Basic Images
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.
Your RHEL Virtual Machine
You will execute the first module in a Red Hat Enterprise Linux Virtual Machine running on Red Hat OpenShift Container Platform.
-
Make sure you are in the Terminal tab on your right.
-
Switch to your OpenShift project:
oc project {user}-rhel -
Connect to your RHEL Virtual Machine:
virtctl ssh rhel@vm/rhel -
Log into the VM using the
rheluser’s password (typeyesat the promptAre you sure you want to continue connecting (yes/no/[fingerprint])?):Password:{password}
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
<!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 \
registry.access.redhat.com/hi/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 pattern will work for very simple testing, but to use this image in a production environment, it’s better to create a custom Containerfile to bundle the assets. This avoids relying on local volume mounts and ensures portability.
In a production environment, you’ll also want to use verified images from the catalog of hardened images. You can see the OCI spec here matches what you saw on the web, but we’ll explore cryptographic verification a little later on.
cat > ~/webserver/Containerfile << 'EOF'
FROM registry.access.redhat.com/hi/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 occurrence 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 registry.access.redhat.com/hi/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 ahead 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 and the associated maintenance.
We can be sure we’ve got the right images by verifying 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
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 dependencies, mimicking the use of a secure enterprise index like Red Hat Trusted Libraries.
|
Throughout this lab, you’ll see files marked with circled numbers and a numbered list following the file. These are to help draw your attention to particular parts of the file. These are part of the source file and also visible in the terminal via |
First, lets look at a typical UBI based approach.
cat ~/flask/Containerfile.ubi
# 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"]
UBI characteristics
| 1 | Package Installation: Traditional dnf install approach for dependencies |
| 2 | User Management: Manual user/group creation required (groupadd, useradd) |
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
We’ve verified the UBI Containerfile results in a working Flask application, now we can start modifying it to use the hardened python image. 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’ll demonstrate this by installing the application dependencies 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 (1)
FROM registry.access.redhat.com/hi/python:3.14
# Set the working directory in the container
WORKDIR /app
# Copy the application files to the target directory
# COPY always executes as root (2)
COPY --chown=65532 app.py .
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 dependencies
# 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 runtim #<.>e
USER ${CONTAINER_DEFAULT_USER}
# 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 provided ENV variable |
Let’s go ahead and build our app on the default 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
|
This failure is deliberate to pivot to our next topic. However if you know a binary or command is available in the image, you can use the "exec form" instead of the "shell form" for RUN ["/usr/bin/python", "-m", "pip", "install", " flask" ] |
What happened here? One of the changes to 'distroless' images like our new default is the removal of things like shells.
The RUN step executes provided commands (here pip install) by passing them to a shell where the previous COPY instructions do not.
Without a shell, what can we do? Switch to the builder variant.
Edit the Containerfile and change the FROM to include the appropriate tag.
|
The |
vi ~/flask/Containerfile.hi
# Stage 1: Builder base image
FROM registry.access.redhat.com/hi/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, as in this case. 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 available. 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
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 production.
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.
✅ 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
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.