Breadcrumb

  1. Red Hat Interactive Learning Portal
  2. Red Hat Enterprise Linux learning
  3. Build a hardened Flask stack and deploy it in image mode for Red Hat Enterprise Linux
  4. Create and run a Flask application locally

Build a hardened Flask stack and deploy it in image mode for Red Hat Enterprise Linux

Build a Python Flask application on Red Hat Enterprise Linux 10 using hardened images and bootc to turn containers into a bootable, verified virtual machine (VM) with a trusted software supply chain.

In this lesson, you will launch a Python Flask stack using Red Hat® Hardened Images. You will use standard Podman command-line interface (CLI) commands to network and run FlaskNGINX, and PostgreSQL containers locally.

Prerequisites:

In this lesson, you will:

  • Review the core application components.
  • Develop the application.
  • Launch and test the application.

Core application components

This application uses three Linux containers. NGINX and PostgreSQL run directly from Red Hat's Hardened Image catalog, configured through files rather than custom code, so no custom image is required. For Flask, your application code requires a custom runtime image built on top of the Python Hardened Image:

  • Flask (via Gunicorn): Processes the code in app.py. A multi-stage Containerfile.app uses hi/python:latest-builder to install Flask, Gunicorn, and the PostgreSQL driver, then copies only those packages into the minimal hi/python:latest runtime image. The resulting image is pushed to your registry and pulled by Podman on first boot, exactly like the other Red Hat Hardened Images.
  • PostgreSQL: A full-featured, open source SQL database that stores the timestamp of every visitor to your site. As your application evolves beyond the scope of this example, you can use this database to maintain data related to your application and even be configured to support read-scale replicas for scaling.
  • NGINX: Acts as a reverse proxy. It handles incoming web traffic on port 8080 and passes requests to the Flask application server. It can also be configured to provide load balancing across PostgreSQL servers in more advanced configurations.

Step 1: Prepare the development workspace

Open your Linux terminal and create a directory for the project. Inside, create an app folder for your Python code and an nginx folder for the server configuration.

mkdir -p flaskdev-hardened/{app,nginx}
cd flaskdev-hardened

Step 2: Build the Flask runtime image

Red Hat Hardened Images are distroless, meaning the runtime image has no shell and no build tools. The builder variant (hi/python:latest-builder) exists specifically to give you a safe place to install dependencies. A multi-stage build uses the builder to install your packages, then copies only the results into the minimal runtime image, and build tools never remain in the final production image.

Create the file flaskdev-hardened/Containerfile.app with the following contents:

# Stage 1: builder — includes pip and a shell so you can install packages normally.
FROM registry.access.redhat.com/hi/python:latest-builder AS builder
USER root
RUN python3 -m venv /venv
RUN /venv/bin/pip install --no-cache-dir flask gunicorn psycopg2-binary
  
# Stage 2: runtime — minimal and distroless. 
# Copy only the installed packages.
FROM registry.access.redhat.com/hi/python:latest
COPY --from=builder /venv /venv
WORKDIR /app
EXPOSE 8000
CMD ["/venv/bin/gunicorn", "--workers", "2", "--bind", "0.0.0.0:8000",
  "app:app"]

The builder stage installs Flask, Gunicorn, and the PostgreSQL driver into a virtual environment at /venv which provides a self-contained Python application environment. The runtime stage copies that directory into the final minimal production image. The CMD calls Gunicorn by its full path inside the virtual environment, so no shell or PATH resolution is needed.

Quay.io is a registry for storing, building, and distributing container images and other OCI artifacts. It offers both free and paid tiers to cater to various user needs.  If you haven’t done so already, you’ll need to log in to Quay.io using your Red Hat account and set up your account by accepting terms and conditions. 

Once you’ve accepted terms on the Quay.io site:

  1. Select your_account_name in the upper right corner of the page.
  2. Select Account Settings.
  3. Select CLI Password: Generate Encrypted Password

Once that step is complete, you can log in with credentials using the command:

 podman login quay.io

Your Quay.io username will be the same as your Red Hat account username.

Build the image with two tags, a local tag for use in this lesson and a registry tag for the virtual machine (VM) to pull from later. Replace `YOUR\_USERNAME` with your Quay.io username, then push the image to the registry. 

Now build the image from the flaskdev-hardened directory:

podman build \
    -f Containerfile.app \
    -t localhost/flask-runtime:latest \
    -t quay.io/YOUR_USERNAME/flask-runtime:latest .

podman push quay.io/YOUR_USERNAME/flask-runtime:latest

For this learning path, you’ll want to visit Quay.io and make sure that your flask-runtime repo is Public so that it can be accessed without a login by your runtime containers. You can

do by going to: quay.io/repository/YOUR_USERNAME/flask-runtime and selecting SettingsRepositoryVisibilityMake Public.

Note on keeping your Flask image up to date

Red Hat rebuilds and republishes the hi/python images when vulnerabilities are found. When the base image is patched, you rebuild your flask-runtime image on top of it, as you would with any containerized application. In production, connect your Containerfile.app to a Quay.io build trigger pointed at the git repository where you store it. Quay.io then rebuilds and republishes your image automatically, and the AutoUpdate=registry setting in your Quadlet picks up the new digest with no manual intervention.

Step 3: Create the application logic

The Flask application logic will display "Hello World — Flask on Red Hat Hardened Images." The example below will also connect with the rest of the stack and perform the following tasks:

  • Environment discovery: The script uses os.getenv() to find the database credentials. This keeps secrets out of your code and lets the container platform manage them.
  • Database handshake: It uses the psycopg2 library to connect to the PostgreSQL container. If the connection fails, it catches the error so your whole page doesn't crash.
  • Schema automation: On the first run, the script automatically creates a visits table. This demonstrates how your app can set itself up in a new environment.
  • Persistence check: Every time you refresh, it inserts a new row with a timestamp and then counts the total rows. This confirms that your data volume is working and your data won't disappear if the container restarts.

Save the following code as flaskdev-hardened/app/app.py:

import os 
import platform 
from flask import Flask 
import psycopg2

app = Flask(__name__)

@app.route('/') 
def index(): 
    db_host = os.getenv('DB_HOST', 'localhost') 
    db_user = os.getenv('DB_USER', 'appuser') 
    db_pass = os.getenv('DB_PASS', 'apppass')
    db_name = os.getenv('DB_NAME', 'hellodb') 
    status = 'Disconnected' 
    error  = '' 
    count  = 0 
    try: 
        conn = psycopg2.connect( 
            host=db_host, 
            user=db_user, 
            password=db_pass, 
            dbname=db_name 
        ) 
        conn.autocommit = False
        cursor = conn.cursor() 
        cursor.execute( 
            "CREATE TABLE IF NOT EXISTS visits " 
            "(id SERIAL PRIMARY KEY, " 
             "visited_at TIMESTAMP DEFAULT NOW())" 
        ) 
        conn.commit() 
        cursor.execute("INSERT INTO visits (visited_at) VALUES (NOW())") 
        conn.commit() 
        cursor.execute("SELECT COUNT(*) FROM visits") 
        count  = cursor.fetchone()[0] 
        status = 'Connected' 
        cursor.close() 
        conn.close() 
    except psycopg2.Error as e: 
        error = str(e) 
    python_ver  = platform.python_version() 
    status_cls  = 'ok' if status == 'Connected' else 'fail' 
    visits_html = f'<li>Total visits: {count}</li>' if status == 'Connected' else '' 
    error_html  = f'<li class="fail">Error: {error}</li>' if error else '' 
    return f""" 
<html lang="en"> 
<head> 
    <meta charset="UTF-8"> 
    <title>Hello Flask on Red Hat Hardened Images</title> 
    <style> 
            body {{ font-family: sans-serif; max-width: 600px; margin: 4em auto; }} 
                .ok   {{ color: green; }} 
                .fail {{ color: red; }} 
    </style> 
</head> 
<body> 
    <h1>Hello World — Flask on Red Hat Hardened Images</h1> 
    <ul> 
        <li>Python version: {python_ver}</li> 
        <li>PostgreSQL status: 
            <span class="{status_cls}"> 
                {status} 
            </span> 
        </li> 
        {visits_html} 
        {error_html} 
    </ul> 
</body> 
</html>"""

Step 4: Configure the NGINX reverse proxy

The NGINX server needs to know where to forward incoming requests. NGINX communicates with Gunicorn over standard HTTP. Because all three containers share the same network namespace inside a pod, they reach each other on localhost. Create flaskdev-hardened/nginx/flask.conf with the following contents to tell NGINX to proxy traffic to the Flask container:

upstream flask_backend { 
    server localhost:8000; 
}

server { 
    listen 8080; 
    server_name _; 
    location / { 
        proxy_pass         http://flask_backend; 
        proxy_set_header   Host              $host; 
        proxy_set_header   X-Real-IP         $remote_addr; 
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for; 
        proxy_set_header   X-Forwarded-Proto $scheme; 
    } 
} 

Step 5: Define the stack in a bash startup script

A  bash script launches the stack using native Podman commands. A Podman pod groups the three containers into a single logical unit and gives them a shared network namespace, where they communicate on localhost rather than by container hostname, and port 8080 is published once at the pod level.

Create the file flaskdev-hardened/start-flask.sh with the following contents:

#!/bin/bash

podman pod create --name flask-pod -p 8080:8080 || true 
podman volume create db-data || true

podman run -d --pod flask-pod --name postgresql \ 
    -e POSTGRES_USER=appuser \ 
     -e POSTGRES_PASSWORD=apppass \ 
    -e POSTGRES_DB=hellodb \ 
    -v db-data:/var/lib/postgresql/data:Z \ 
    registry.access.redhat.com/hi/postgresql:latest

podman run -d --pod flask-pod --name flask-app \ 
    -e DB_HOST=localhost \ 
    -e DB_USER=appuser \ 
    -e DB_PASS=apppass \ 
    -e DB_NAME=hellodb \ -v ./app:/app:z \ 
    localhost/flask-runtime:latest

podman run -d --pod flask-pod --name nginx \ 
    -v ./nginx/flask.conf:/etc/nginx/conf.d/default.conf:Z,ro \
     registry.access.redhat.com/hi/nginx:latest

echo "Stack is running at http://localhost:8080" 
echo "To stop:   podman pod stop flask-pod" 
echo "To remove: podman pod rm -f flask-pod" 

Make the script executable:

chmod +x start-flask.sh 

Step 6: Launch and test results

To run the application in containers using your code and Red Hat Hardened Images, execute the bash script from the flaskdev-hardened/ directory:

./start-flask.sh 

To test, point your web browser to http://localhost:8080. You should see results similar to Figure 1 below:

Browser pointing to http://localhost:8080
Figure 1: Browser test page.

Here is what to look for:

  • Python version: Confirms the Flask application container is processing code.
  • PostgreSQL status: "Connected" means your application successfully authenticated with the hardened database.
  • Total visits: Refresh the page to see the count increase, confirming persistent storage is working.

Success! You've developed, launched, and tested the application and confirmed the basic functionality of the Flask containers.

Next, you will use Podman Quadlets to tell the operating system how to manage those containers.

Previous resource
Overview: Build a hardened Flask stack and deploy it in image mode for Red Hat Enterprise Linux
Next resource
Use Podman Quadlets to manage Flask stack containers as system services