diff --git a/bindep.txt b/bindep.txt
index 13f7a9dcae..6e1e3e68e9 100644
--- a/bindep.txt
+++ b/bindep.txt
@@ -99,4 +99,5 @@ zstd [devstack]
 
 # For graphical console support
 podman [devstack]
-systemd-container [devstack]
\ No newline at end of file
+systemd-container [devstack]
+buildah [devstack]
diff --git a/devstack/lib/ironic b/devstack/lib/ironic
index 1356226f32..bec1cf6606 100644
--- a/devstack/lib/ironic
+++ b/devstack/lib/ironic
@@ -1279,7 +1279,7 @@ function install_ironic {
     fi
 
     if is_service_enabled ir-novnc; then
-        # a websockets/html5 or flash powered VNC console for vm instances
+        # a websockets/html5 VNC console for bare metal hosts
         NOVNC_FROM_PACKAGE=$(trueorfalse False NOVNC_FROM_PACKAGE)
         if [ "$NOVNC_FROM_PACKAGE" = "True" ]; then
             # Installing novnc on Debian bullseye breaks the global pip
@@ -1304,7 +1304,11 @@ function install_ironic {
             git_clone $NOVNC_REPO $NOVNC_WEB_DIR $NOVNC_BRANCH
         fi
         # podman, systemd-container required by the systemd container provider
-        install_package podman systemd-container
+        # buildah required below to build the VNC container
+        install_package podman systemd-container buildah
+        pushd $IRONIC_DIR/tools/vnc-container
+        buildah bud -f ./Containerfile -t localhost/ironic-vnc-container
+        popd
     fi
 }
 
@@ -2057,8 +2061,7 @@ function configure_ironic_novnc {
     iniset $IRONIC_CONF_FILE vnc port $service_port
     iniset $IRONIC_CONF_FILE vnc novnc_web $NOVNC_WEB_DIR
     iniset $IRONIC_CONF_FILE vnc container_provider systemd
-    # TODO(stevebaker) build this locally during the devstack run
-    # iniset $IRONIC_CONF_FILE vnc console_image localhost/ironic-vnc-container
+    iniset $IRONIC_CONF_FILE vnc console_image localhost/ironic-vnc-container
 
 }
 
diff --git a/setup.cfg b/setup.cfg
index e04776d171..6e8a192367 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -27,6 +27,7 @@ data_files =
     etc/ironic =
         etc/ironic/rootwrap.conf
     etc/ironic/rootwrap.d = etc/ironic/rootwrap.d/*
+    share/ironic/vnc-container = tools/vnc-container/*
 packages =
     ironic
 
diff --git a/tools/vnc-container/Containerfile b/tools/vnc-container/Containerfile
new file mode 100644
index 0000000000..38c01b7fb9
--- /dev/null
+++ b/tools/vnc-container/Containerfile
@@ -0,0 +1,25 @@
+FROM quay.io/centos/centos:stream9
+
+RUN dnf -y install \
+    epel-release && \
+    dnf -y install \
+    chromium \
+    chromedriver \
+    dumb-init \
+    procps \
+    psmisc \
+    python3-requests \
+    python3-selenium \
+    x11vnc \
+    xorg-x11-server-Xvfb
+
+ENV DISPLAY_WIDTH=1280
+ENV DISPLAY_HEIGHT=960
+
+ENV APP='fake'
+
+ADD bin/* /usr/local/bin
+ADD drivers /drivers
+
+ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+CMD ["/usr/local/bin/start-xvfb.sh"]
\ No newline at end of file
diff --git a/tools/vnc-container/README.rst b/tools/vnc-container/README.rst
new file mode 100644
index 0000000000..57bca6722d
--- /dev/null
+++ b/tools/vnc-container/README.rst
@@ -0,0 +1,74 @@
+=============
+VNC Container
+=============
+
+Overview
+--------
+
+This allows a container image to be built which supports Ironic's graphical
+console functionality.
+
+For each node with an enabled graphical console, the service ironic-novncproxy
+(or nova-novncproxy) will connect to a VNC server exposed by a container
+running this image.
+
+Building and using
+------------------
+
+To build the container image for local use, install ``buildah`` and run the
+following as the user which runs ironic-conductor::
+
+    buildah bud -f ./Containerfile -t localhost/ironic-vnc-container
+
+The ``systemd`` container provider (or an external provider) can then be configured
+to use this image in ``ironic.conf``:
+
+.. code-block:: ini
+
+      [vnc]
+      container_provider=systemd
+      console_image=localhost/ironic-vnc-container
+
+
+Implementation
+--------------
+
+When the container is started the following occurs:
+
+1. Xvfb is run, which starts a virtual X11 session
+2. x11vnc is run, which exposes a VNC server port
+
+When a VNC connection is established a Selenium python script is started
+which:
+
+1. Starts a Chromium browser
+2. For the ``fake`` app displays drivers/fake/index.html
+3. For the ``redfish`` app detects the vendor by looking at the ``Oem``
+   value in a ``/redfish/v1`` response
+4. Runs vendor specific code to display an HTML5 based console
+
+When the VNC connection is terminated, the Selenium script and Chromium is
+also terminated.
+
+Vendor specific implementations are as follows.
+
+Dell iDRAC
+~~~~~~~~~~
+
+One-time console credentials are created with a call to
+``/Managers/<manager>/Oem/Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession``
+and the browser loads a console URL using those credentials.
+
+HPE iLO
+~~~~~~~
+
+The ``/irc.html`` URL is loaded. For iLO 6 the inline login form is populated
+with credentials and submitted, showing the console. For iLO 5 the main login
+page is loaded, and when the login is submitted ``irc.html`` is loaded again.
+
+Supermicro (Experimental)
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A simulated user logs in, waits for the console preview image to load, then
+clicks on it.
+
diff --git a/tools/vnc-container/bin/start-browser-x11vnc.sh b/tools/vnc-container/bin/start-browser-x11vnc.sh
new file mode 100755
index 0000000000..822695a5b7
--- /dev/null
+++ b/tools/vnc-container/bin/start-browser-x11vnc.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -eux
+
+x11vnc -nevershared -forever -afteraccept 'start-selenium-browser.py &' -gone 'killall -s SIGTERM python3'
\ No newline at end of file
diff --git a/tools/vnc-container/bin/start-selenium-browser.py b/tools/vnc-container/bin/start-selenium-browser.py
new file mode 100755
index 0000000000..bf60c0f1e8
--- /dev/null
+++ b/tools/vnc-container/bin/start-selenium-browser.py
@@ -0,0 +1,337 @@
+#!/usr/bin/env python3
+
+import json
+import os
+import requests
+from requests import auth
+import signal
+import sys
+import time
+from urllib import parse as urlparse
+
+from selenium import webdriver
+from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.common.by import By
+from selenium.common import exceptions
+
+
+class BaseApp:
+
+    def __init__(self, app_info):
+        self.app_info = app_info
+
+    @property
+    def url(self):
+        pass
+
+    def handle_exit(self, signum, frame):
+        print("got SIGTERM, quitting")
+        self.driver.quit()
+        sys.exit(0)
+
+    def start(self, driver):
+        self.driver = driver
+        signal.signal(signal.SIGTERM, self.handle_exit)
+
+
+class FakeApp(BaseApp):
+
+    @property
+    def url(self):
+        return "file:///drivers/fake/index.html"
+
+
+class RedfishApp(BaseApp):
+
+    @property
+    def base_url(self):
+        return self.app_info["address"]
+
+    @property
+    def redfish_url(self):
+        return self.base_url + self.app_info.get("root_prefix", "/redfish/v1")
+
+    def disable_right_click(self, driver):
+        # disable right-click menu
+        driver.execute_script(
+            'window.addEventListener("contextmenu", function(e) '
+            "{ e.preventDefault(); })"
+        )
+
+
+class IdracApp(RedfishApp):
+
+    @property
+    def url(self):
+        username = self.app_info["username"]
+        password = self.app_info["password"]
+        verify = self.app_info.get("verify_ca", True)
+        kvm_session_url = (f"{self.redfish_url}/Managers/iDRAC.Embedded.1/Oem/"
+                           "Dell/DelliDRACCardService/Actions/DelliDRACCardService.GetKVMSession")
+        netloc = urlparse.urlparse(self.base_url).netloc
+
+        r = requests.post(
+            kvm_session_url,
+            verify=verify,
+            timeout=60,
+            auth=auth.HTTPBasicAuth(username, password),
+            json={"SessionTypeName": "idrac-graphical"},
+        ).json()
+        temp_username = r["TempUsername"]
+        temp_password = r["TempPassword"]
+        url = (f"{self.base_url}/restgui/vconsole/index.html?ip={netloc}&"
+               f"kvmport=443&title=idrac-graphical&VCSID={temp_username}&VCSID2={temp_password}")
+        return url
+
+    def start(self, driver):
+        super(IdracApp, self).start(driver)
+        # wait for the full screen button
+        wait = WebDriverWait(
+            driver,
+            timeout=10,
+            poll_frequency=0.2,
+            ignored_exceptions=[exceptions.NoSuchElementException],
+        )
+        wait.until(
+            lambda d: driver.find_element(By.TAG_NAME, value="full-screen")
+            or True
+        )
+        fs_tag = driver.find_element(By.TAG_NAME, value="full-screen")
+        fs_tag.find_element(By.TAG_NAME, "button").click()
+
+
+class IloApp(RedfishApp):
+
+    @property
+    def url(self):
+        return self.base_url + "/irc.html"
+
+    def login(self, driver):
+
+        username = self.app_info["username"]
+        password = self.app_info["password"]
+        # wait for the username field to be enabled then perform login
+        wait = WebDriverWait(
+            driver,
+            timeout=10,
+            poll_frequency=0.2,
+            ignored_exceptions=[exceptions.NoSuchElementException],
+        )
+        wait.until(
+            lambda d: driver.find_element(By.ID, value="username") or True
+        )
+
+        username_field = driver.find_element(By.ID, value="username")
+        wait = WebDriverWait(
+            driver,
+            timeout=5,
+            poll_frequency=0.2,
+            ignored_exceptions=[exceptions.ElementNotInteractableException],
+        )
+        wait.until(lambda d: username_field.send_keys(username) or True)
+
+        driver.find_element(By.ID, value="password").send_keys(password)
+        driver.find_element(By.ID, value="login-form__submit").click()
+
+    def start(self, driver):
+        super(IloApp, self).start(driver)
+
+        # Detect iLO 6 vs 5 based on whether a message box or a login form
+        # is presented
+        try:
+            driver.find_element(By.CLASS_NAME, value="loginBoxRestrictWidth")
+            is_ilo6 = True
+        except exceptions.NoSuchElementException:
+            is_ilo6 = False
+
+        if is_ilo6:
+            # iLO 6 has an inline login which matches the main login
+            self.login(driver)
+            self.disable_right_click(driver)
+            self.full_screen(driver)
+            return
+
+        # load the main login page
+        driver.get(self.base_url)
+
+        # full screen content is shown in an embedded iframe
+        iframe = driver.find_element(By.ID, "appFrame")
+        driver.switch_to.frame(iframe)
+
+        self.login(driver)
+
+        # wait for <body id="app-container"> to exist, which indicates
+        # the login form has submitted and session cookies are now set
+        wait = WebDriverWait(
+            driver,
+            timeout=10,
+            poll_frequency=0.2,
+            ignored_exceptions=[exceptions.NoSuchElementException],
+        )
+        wait.until(
+            lambda d: driver.find_element(By.ID, value="app-container")
+            or True
+        )
+
+        # load the actual console
+        driver.get(self.url)
+        self.disable_right_click(driver)
+        self.full_screen(driver)
+
+    def full_screen(self, driver):
+        # make console full screen to hide menu
+        fs_button = driver.find_element(
+            By.CLASS_NAME, value="btnVideoFullScreen"
+        )
+        wait = WebDriverWait(
+            driver,
+            timeout=20,
+            poll_frequency=0.2,
+            ignored_exceptions=[
+                exceptions.ElementNotInteractableException,
+                exceptions.ElementClickInterceptedException,
+            ],
+        )
+        wait.until(lambda d: fs_button.click() or True)
+
+
+class SupermicroApp(RedfishApp):
+
+    @property
+    def url(self):
+        return self.base_url
+
+    def start(self, driver):
+        super(SupermicroApp, self).start(driver)
+        username = self.app_info["username"]
+        password = self.app_info["password"]
+
+        # populate login and submit
+        driver.find_element(By.NAME, value="name").send_keys(username)
+        driver.find_element(By.ID, value="pwd").send_keys(password)
+        driver.find_element(By.ID, value="login_word").click()
+
+        # navigate down some iframes
+        iframe = driver.find_element(By.ID, "TOPMENU")
+        driver.switch_to.frame(iframe)
+
+        iframe = driver.find_element(By.ID, "frame_main")
+        driver.switch_to.frame(iframe)
+
+        wait = WebDriverWait(
+            driver,
+            timeout=30,
+            poll_frequency=0.2,
+            ignored_exceptions=[
+                exceptions.NoSuchElementException,
+                exceptions.ElementNotInteractableException,
+            ],
+        )
+        wait.until(lambda d: driver.find_element(By.ID, value="img1") or True)
+
+        # launch the console by waiting for the console preview image to be
+        # loaded and clickable
+        def snapshot_wait(d):
+            try:
+                img1 = driver.find_element(By.ID, value="img1")
+            except exceptions.NoSuchElementException:
+                print("img1 doesn't exist yet")
+                return False
+
+            if "Snapshot" not in img1.get_attribute("src"):
+                print("img1 src not a console snapshot yet")
+                return False
+            if not img1.get_attribute("complete") == "true":
+                print("img1 console snapshot not loaded yet")
+                return False
+            try:
+                img1.click()
+            except exceptions.ElementNotInteractableException:
+                print("img1 not clickable yet")
+                return False
+            return True
+
+        wait = WebDriverWait(driver, timeout=30, poll_frequency=1)
+        wait.until(snapshot_wait)
+
+        # self.disable_right_click(driver)
+
+
+def start_driver(url, app_info):
+    print(f"starting app with url {url}")
+    opts = webdriver.ChromeOptions()
+    opts.binary_location = "/usr/bin/chromium-browser"
+    # opts.enable_bidi = True
+    if url:
+        opts.add_argument(f"--app={url}")
+
+    verify = app_info.get("verify_ca", True)
+    if not verify:
+        opts.add_argument("--ignore-certificate-errors")
+        opts.add_argument("--ignore-ssl-errors")
+
+    opts.add_argument("--disable-extensions")
+    opts.add_argument("--disable-gpu")
+    opts.add_argument("--disable-plugins-discovery")
+
+    opts.add_argument("--disable-context-menu")
+    opts.add_argument("--no-sandbox")
+    opts.add_argument("--disable-dev-shm-usage")
+
+    opts.add_argument("--window-position=0,0")
+    opts.add_experimental_option("excludeSwitches", ["enable-automation"])
+    if "DISPLAY_WIDTH" in os.environ and "DISPLAY_HEIGHT" in os.environ:
+        width = int(os.environ["DISPLAY_WIDTH"])
+        height = int(os.environ["DISPLAY_HEIGHT"])
+        opts.add_argument(f"--window-size={width},{height}")
+    if "CHROME_ARGS" in os.environ:
+        for arg in os.environ["CHROME_ARGS"].split(" "):
+            opts.add_argument(arg)
+
+    driver = webdriver.Chrome(options=opts)
+    driver.delete_all_cookies()
+    driver.set_window_position(0, 0)
+
+    return driver
+
+
+def discover_app(app_name, app_info):
+    if app_name == "fake":
+        return FakeApp
+    if app_name == "redfish-graphical":
+        # Make an unauthenticated redfish request
+        # to discover which console class to use
+        url = app_info["address"] + app_info.get("root_prefix", "/redfish/v1")
+        verify = app_info.get("verify_ca", True)
+        r = requests.get(url, verify=verify, timeout=60).json()
+        oem = ",".join(r["Oem"].keys())
+        if "Hpe" in oem:
+            return IloApp
+        if "Dell" in oem:
+            return IdracApp
+        if "Supermicro" in oem:
+            return SupermicroApp
+        raise Exception(f"Unsupported {app_name} vendor {oem}")
+
+    raise Exception(f"Unknown app name {app_name}")
+
+
+def main():
+    app_name = os.environ.get("APP")
+    print("got app info " + os.environ.get("APP_INFO"))
+    app_info = json.loads(os.environ.get("APP_INFO"))
+    app_class = discover_app(app_name, app_info)
+
+    app = app_class(app_info)
+
+    driver = start_driver(url=app.url, app_info=app_info)
+    print(f"got driver {driver}")
+
+    print(f"Running app {app_name}")
+    app.start(driver)
+    while True:
+        time.sleep(10)
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/vnc-container/bin/start-xvfb.sh b/tools/vnc-container/bin/start-xvfb.sh
new file mode 100755
index 0000000000..2584ffe074
--- /dev/null
+++ b/tools/vnc-container/bin/start-xvfb.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -eux
+
+xvfb-run -s "-screen 0 ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT}x24" start-browser-x11vnc.sh
\ No newline at end of file
diff --git a/tools/vnc-container/drivers/fake/index.html b/tools/vnc-container/drivers/fake/index.html
new file mode 100644
index 0000000000..19d94c57b2
--- /dev/null
+++ b/tools/vnc-container/drivers/fake/index.html
@@ -0,0 +1,80 @@
+<html>
+    <head>
+        <title>Bouncing Pixie</title>
+        <style>
+            * {margin:0; padding: 0; color:red;}
+        </style>
+    </head>
+
+    <body>
+        <canvas id="tv-screen"></canvas>
+        <script>
+let speed = 50;
+let scale = 0.4; // Image scale (I work on 1080p monitor)
+let canvas;
+let ctx;
+let logoColor;
+
+let dvd = {
+    x: 200,
+    y: 300,
+    xspeed: 10,
+    yspeed: 10,
+    img: new Image()
+};
+
+(function main(){
+    canvas = document.getElementById("tv-screen");
+    ctx = canvas.getContext("2d");
+    dvd.img.src = 'ironic_mascot_color.png';
+
+    //Draw the "tv screen"
+    canvas.width  = window.innerWidth;
+    canvas.height = window.innerHeight;
+
+    pickColor();
+    update();
+})();
+
+function update() {
+    setTimeout(() => {
+        //Draw the canvas background
+        ctx.fillStyle = '#000';
+        ctx.fillRect(0, 0, canvas.width, canvas.height);
+        //Draw DVD Logo and his background
+        ctx.fillStyle = logoColor;
+        ctx.fillRect(dvd.x, dvd.y, dvd.img.width*scale, dvd.img.height*scale);
+        ctx.drawImage(dvd.img, dvd.x, dvd.y, dvd.img.width*scale, dvd.img.height*scale);
+        //Move the logo
+        dvd.x+=dvd.xspeed;
+        dvd.y+=dvd.yspeed;
+        //Check for collision
+        checkHitBox();
+        update();
+    }, speed)
+}
+
+//Check for border collision
+function checkHitBox(){
+    if(dvd.x+dvd.img.width*scale >= canvas.width || dvd.x <= 0){
+        dvd.xspeed *= -1;
+        pickColor();
+    }
+
+    if(dvd.y+dvd.img.height*scale >= canvas.height || dvd.y <= 0){
+        dvd.yspeed *= -1;
+        pickColor();
+    }
+}
+
+//Pick a random color in RGB format
+function pickColor(){
+    r = Math.random() * (254 - 0) + 0;
+    g = Math.random() * (254 - 0) + 0;
+    b = Math.random() * (254 - 0) + 0;
+
+    logoColor = 'rgb('+r+','+g+', '+b+')';
+}
+        </script>
+    </body>
+</html>
diff --git a/tools/vnc-container/drivers/fake/ironic_mascot_color.png b/tools/vnc-container/drivers/fake/ironic_mascot_color.png
new file mode 100644
index 0000000000..7bf1424770
Binary files /dev/null and b/tools/vnc-container/drivers/fake/ironic_mascot_color.png differ