DEV Community

Building an OpenShift 4.18 Cluster from Scratch: Part 1 – The Network Foundation & Utilities Server

Building an OpenShift 4.18 Cluster from Scratch: Part 1 – The Network Foundation & Utilities Server

Note: This is Part 1 of a multi-part series.

  • [Part 2: Generating Ignition Configs & VM Prep] (Coming Soon)
  • [Part 3: The Deployment Lifecycle & Troubleshooting] (Coming Soon)
  • [Part 4: Post-Install Hardening & User Management] (Coming Soon)

In this series, I walk through a full OpenShift Container Platform 4.18 deployment from scratch. While most tutorials target cloud providers or pre-provisioned infrastructure, this guide replicates a true platform-agnostic (bare metal) installation-executed within a lab of Virtual Machines hosted on RHEL 10. This approach allows us to master the critical underlying infrastructure-DNS, DHCP, PXE booting, and load balancing-without the cost of physical hardware.

By the end of Part 1, you will have a fully functional Utilities Server that acts as the command center for the entire cluster, handling:

  • DNS: Internal resolution for all cluster nodes.
  • DHCP: IP allocation and iPXE boot redirection via Kea.
  • TFTP & HTTP: Serving bootloader images, RHCOS ISOs, and Ignition configs.
  • HAProxy: Load balancing for the Kubernetes API and Ingress traffic.

We won't run openshift-install just yet; first, we must build the foundation upon which the cluster relies.

📚 References & Background

This lab strictly follows the Platform-Agnostic installation methodology defined by Red Hat. The setup was heavily inspired by the DO322: Red Hat OpenShift Installation Lab training course, ensuring we cover the same critical edge cases and network requirements found in production environments.

Why use VMs for a "Bare Metal" guide? While running on virtual machines provides speed and portability for this lab, the network stack (DNS/DHCP/PXE) and the Ignition configuration flow are identical to what you would encounter on physical hardware. Following the DO322 methodology ensures this tutorial remains relevant for real-world bare-metal deployments.

Let's get the foundation laid.

🛠 Lab Environment Overview

Before diving in, here is the topology I'm working with. All these VMs are hosted on a RHEL 10 hypervisor.

Role VM Name IP Address Specs
Utilities Server utilities 192.168.110.20 4 vCPU, 8GB RAM, 250GB Disk
Bootstrap Node bootstos 192.168.110.100 4 vCPU, 16GB RAM, 100GB NVMe
Control Plane masteros01-03 .101 to .103 4 vCPU, 24GB RAM, 100GB NVMe
Compute Nodes workeros01-03 .111 to .113 2 vCPU, 8GB RAM, 100GB SSD

1. Setting Up the Utilities Server

We start with a fresh RHEL 10 installation. Once installed, we register the system and ensure all packages are up to date.

$ sudo subscription-manager register --username <YOUR_USER> --password <PASSWORD>
$ sudo dnf update redhat-release
$ sudo dnf upgrade -y
$ sudo reboot

After reboot, we verify connectivity and prepare for the services.

Network Configuration

The utilities server has two interfaces. I configured them via nmtui to assign static IPs:

  • enp1s0: 192.168.1.20 (Management/Internet Gateway)
  • enp7s0: 192.168.110.20 (Internal Cluster Network)

Then, add two extra IP addresses to interface enp7s0, 192.168.110.21 and 192.168.110.22, to be used by OpenShift services.

$ nmcli
enp1s0: connected to enp1s0
        "Red Hat Virtio 1.0"
        ethernet (virtio_net), 52:54:00:C5:C1:9E, hw, mtu 1500
        ip4 default
        inet4 192.168.1.20/24
        route4 default via 192.168.1.1 metric 100
        route4 192.168.1.0/24 metric 100
        inet6 fe80::5054:ff:fec5:c19e/64
        route6 fe80::/64 metric 1024

enp7s0: connected to enp7s0
        "Red Hat Virtio 1.0"
        ethernet (virtio_net), 52:54:00:E6:D1:A4, hw, mtu 1500
        inet4 192.168.110.22/24
        inet4 192.168.110.21/24
        inet4 192.168.110.20/24
        route4 192.168.110.0/24 metric 101
        route4 192.168.110.0/24 metric 101
        route4 192.168.110.0/24 metric 101
        inet6 fe80::5054:ff:fee6:d1a4/64
        route6 fe80::/64 metric 1024

2. Installing & Configuring DNS (BIND)

OpenShift relies heavily on proper DNS. We need records for the API VIPs, ingress VIP, and every node.

Installation

$ sudo dnf install bind bind-utils -y

Configuration

I modified the default config (/etc/named.conf) to listen on both interfaces and allow recursion only for our internal subnets. Crucially, we define our zones here.

# /etc/named.conf snippet
options {
    listen-on port 53 { 127.0.0.1; 192.168.1.20; 192.168.110.20; };
    allow-query { localhost; 192.168.1.0/24; 192.168.110.0/24; };
    allow-recursion { localhost; 192.168.1.0/24; 192.168.110.0/24; };
    forwarders { 8.8.8.8; 8.8.4.4; }; # Or your ISP DNS
    recursion yes;
};

zone "internal.local" {
    type master;
    file "internal.local.zone";
    allow-query { any; };
};

zone "110.168.192.in-addr.arpa" {
    type master;
    file "110.168.192.in-addr.arpa.zone";
    allow-query { any; };
};

Zone Files

We create the forward and reverse lookup zones. Notice the VIPs for the API (192.168.110.21) and Ingress (192.168.110.22). These point to our HAProxy server (which runs on the utilities node).

/var/named/internal.local.zone:

$TTL 8h
@ IN SOA ns1.internal.local. hostmaster.internal.local. (
    2026042601 ; serial
    1d         ; refresh
    3h         ; retry
    3d         ; expire
    3h )       ; minimum
    IN NS ns1.internal.local.

ntp             IN A    192.168.110.20
dns             IN A    192.168.110.20

; Static infrastructure
;hostname       IN A    192.168.110.x
utilities.internal.local.   IN A    192.168.110.20

; OpenShift VIPs
api.internal.local.         IN A    192.168.110.21
api-int.internal.local.     IN A    192.168.110.21
*.apps.internal.local.      IN A    192.168.110.22

; Bootstrap Node
bootstos.internal.local.    IN A    192.168.110.100

; Master Nodes
masteros01.internal.local.  IN A    192.168.110.101
masteros02.internal.local.  IN A    192.168.110.102
masteros03.internal.local.  IN A    192.168.110.103

; Worker Nodes
workeros01.internal.local.  IN A    192.168.110.111
workeros02.internal.local.  IN A    192.168.110.112
workeros03.internal.local.  IN A    192.168.110.113

/var/named/110.168.192.in-addr.arpa.zone:

$TTL 8h
@ IN SOA ns1.internal.local. hostmaster.internal.local. (
    2026041901 ; serial number
    1d         ; refresh period
    3h         ; retry period
    3d         ; expire time
    3h )       ; minimum TTL
    IN NS ns1.internal.local.

20  IN PTR    ns1.internal.local.
20  IN PTR    utilities.internal.local.
21  IN PTR    api.internal.local.
21  IN PTR    api-int.internal.local.
100 IN PTR    bootstos.internal.local.
101 IN PTR    masteros01.internal.local.
102 IN PTR    masteros02.internal.local.
103 IN PTR    masteros03.internal.local.
111 IN PTR    workeros01.internal.local.
112 IN PTR    workeros02.internal.local.
113 IN PTR    workeros03.internal.local.

Check configuration and start the service

Change ownership and permissions, and check the configuration:

$ sudo named-checkconf
$ sudo chown root:named /var/named/internal.local.zone
$ sudo chown root:named /var/named/110.168.192.in-addr.arpa.zone
$ sudo chmod 640 /var/named/internal.local.zone
$ sudo chmod 640 /var/named/110.168.192.in-addr.arpa.zone
$ sudo named-checkzone internal.local /var/named/internal.local.zone
zone internal.local/IN: loaded serial 2026041901 OK
$ sudo named-checkzone 110.168.192.in-addr.arpa /var/named/110.168.192.in-addr.arpa.zone
zone 110.168.192.in-addr.arpa/IN: loaded serial 2026041901 OK

Enable and start the service:

$ sudo systemctl enable --now named

And allow the service through the firewall:

$ sudo firewall-cmd --permanent --add-service=dns
$ sudo firewall-cmd --reload

3. Time Synchronization (Chrony)

Kubernetes clusters are extremely sensitive to time skew. If the clocks drift, certificates and authentication will fail immediately. We install chrony (it's already present in RHEL, but we configure it):

# /etc/chrony.conf snippet
pool 2.rhel.pool.ntp.org iburst
allow 192.168.1.0/24
allow 192.168.110.0/24
local stratum 10

This allows the utility server to act as a local NTP source for all nodes while syncing itself with external sources.

Enable and start the service:

$ sudo systemctl enable --now chronyd
Created symlink '/etc/systemd/system/multi-user.target.wants/chronyd.service' → '/usr/lib/systemd/system/chronyd.service'.

And allow the service through the firewall:

$ sudo firewall-cmd --add-service=ntp --permanent
$ sudo firewall-cmd --reload

4. DHCP with Kea

Instead of the legacy ISC DHCP server, I used Kea, which is the recommended choice for RHEL 10.

Installation & Config

sudo dnf install kea -y

The configuration /etc/kea/kea-dhcp4.conf is where the magic happens for PXE booting. We define client classes to distinguish between iPXE clients and standard UEFI/BIOS PXE clients.

{
    "Dhcp4": {
        "interfaces-config": {
            "interfaces": [ "enp7s0/192.168.110.20" ]
        },
        "client-classes": [
            {
                "name": "iPXE Clients",
                "test": "option[175].exists",
                "option-data": [
                    {
                        "name": "tftp-server-name",
                        "data": "192.168.110.20"
                    },
                    {
                        "name": "boot-file-name",
                        "data": "http://192.168.110.20/boot.ipxe"
                    }
                ]
            },
            {
                "name": "UEFI PXE Clients",
                "test": "option[93].hex == 0x0007 and not option[175].exists",
                "next-server": "192.168.110.20",
                "boot-file-name": "ipxe-snponly-x86_64.efi"
            },
            {
                "name": "BIOS PXE Clients",
                "test": "option[93].hex == 0x0000 and not option[175].exists",
                "next-server": "192.168.110.20",
                "boot-file-name": "undionly.kpxe"
            }
        ],
        "subnet4": [
            {
                "id": 1,
                "subnet": "192.168.110.0/24",
                "pools": [
                    {
                        "pool": "192.168.110.200 - 192.168.110.250"
                    }
                ],
                "reservations": [
                    {
                        "hw-address": "AA:BB:CC:DD:EE:01",
                        "ip-address": "192.168.110.100",
                        "hostname": "bootstos.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:02",
                        "ip-address": "192.168.110.101",
                        "hostname": "masteros01.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:03",
                        "ip-address": "192.168.110.102",
                        "hostname": "masteros02.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:04",
                        "ip-address": "192.168.110.103",
                        "hostname": "masteros03.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:05",
                        "ip-address": "192.168.110.111",
                        "hostname": "workeros01.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:06",
                        "ip-address": "192.168.110.112",
                        "hostname": "workeros02.oc41827.internal.local"
                    },
                    {
                        "hw-address": "AA:BB:CC:DD:EE:07",
                        "ip-address": "192.168.110.113",
                        "hostname": "workeros03.oc41827.internal.local"
                    }
                ],
                "option-data": [
                    {
                        "name": "routers",
                        "code": 3,
                        "data": "192.168.110.1"
                    },
                    {
                        "name": "domain-name-servers",
                        "code": 6,
                        "space": "dhcp4",
                        "data": "192.168.110.20"
                    },
                    {
                        "name": "domain-name",
                        "data": "internal.local"
                    },
                    {
                        "name": "domain-search",
                        "data": "oc41827.internal.local internal.local"
                    }
                ]
            }
        ]
    }
}

Note: Ensure you replace the MAC addresses with the actual ones assigned to your VMs.

Check the configuration and enable Kea

$ sudo kea-dhcp4 -t /etc/kea/kea-dhcp4.conf

You might see some warnings, you might ignore them (but do not ignore any error), see this as an example:

2026-04-21 20:52:38.159 WARN[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_MT_DISABLED_QUEUE_CONTROL disabling dhcp queue control when multi-threading is enabled.
2026-04-21 20:52:38.159 WARN[kea-dhcp4.dhcp4/6313.140336065439872] DHCP4_RESERVATIONS_LOOKUP_FIRST_ENABLED Multi-threading is enabled and host reservations lookup is always performed first.
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_CFGMGR_NEW_SUBNET4 a new subnet has been added to configuration: 192.168.110.0/24 with params: valid-lifetime=7200
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_CFGMGR_SOCKET_TYPE_SELECT using socket type raw
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_CFGMGR_USE_ADDRESS listening on address 192.168.110.20, on interface enp7s0
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_CFGMGR_SOCKET_TYPE_DEFAULT "dhcp-socket-type" not specified, using default socket type raw
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_LEASE_MGR_BACKENDS_REGISTERED the following lease backend types are available: memfile
2026-04-21 20:52:38.160 INFO[kea-dhcp4.hosts/6313.140336065439872] HOSTS_BACKENDS_REGISTERED the following host backend types are available:
2026-04-21 20:52:38.160 INFO[kea-dhcp4.dhcpsrv/6313.140336065439872] DHCPSRV_FORENSIC_BACKENDS_REGISTERED the following forensic backend types are available:
2026-04-21 20:52:38.160 INFO[kea-dhcp4.database/6313.140336065439872] CONFIG_BACKENDS_REGISTERED the following config backend types are available:

Enable and start the service:

$ sudo systemctl enable --now kea-dhcp4

And allow the service through the firewall:

$ sudo firewall-cmd --add-service=dhcp --permanent
$ sudo firewall-cmd --reload

5. Web Services & iPXE Boot

OpenShift uses a live-boot mechanism. We need an HTTP server to serve the RHCOS kernel, rootfs, and ignition files, plus TFTP for the initial bootloader.

Apache Setup

Install Apache:

$ sudo dnf install httpd -y

To prevent interferences with haproxy, change the listen port to include the IP address:

$ sudo nano /etc/httpd/conf/httpd.conf

From Listen 80 to Listen 192.168.110.20:80.

Allow the service through the firewall:

$ sudo firewall-cmd --add-service=http --permanent
$ sudo firewall-cmd --reload

And enable and start the service:

$ sudo systemctl enable --now httpd.service

TFTP Setup

Install TFTP:

$ sudo dnf install tftp-server -y

Enable and start the service:

$ sudo systemctl enable --now tftp.socket

And allow the service through the firewall:

$ sudo firewall-cmd --add-service=tftp --permanent
$ sudo firewall-cmd --reload

iPXE Setup

We are going to deploy the OpenShift nodes using ipxe and tftp-server. The key is the boot.ipxe script, which we will discuss in the next blog. For now, we prepare iPXE.

Install iPXE:

$ sudo dnf install ipxe-bootimgs

And prepare the basic structure:

$ sudo cp /usr/share/ipxe/undionly.kpxe /var/lib/tftpboot/
$ sudo cp /usr/share/ipxe/ipxe-snponly-x86_64.efi /var/lib/tftpboot/
$ sudo restorecon -Rv /var/lib/tftpboot

It can be later used to install other OS, like RHEL or Ubuntu.

6. Load Balancing with HAProxy

This is critical for High Availability. HAProxy listens on the API VIP (192.168.110.21) and Ingress VIP (192.168.110.22) and forwards traffic to the backend nodes.

Install HAProxy

$ sudo dnf install haproxy -y

HAProxy Configuration for OpenShift

Use nano or vim to update the configuration file /etc/haproxy/haproxy.cfg.

Comments

No comments yet. Start the discussion.