Introduction
Hetzner dedicated servers are a good place to run a single-node OpenShift (SNO) lab. You get plenty of CPU, memory, and fast local NVMe storage, and the monthly cost is predictable. The awkward part is the install. On a dedicated server in Robot, you do not get the same easy ISO boot workflow you have with a VM or with Hetzner Cloud. OpenShift normally expects either a discovery ISO or PXE boot, and neither is especially convenient here.
The approach in this tutorial is to use the Hetzner Rescue System as a temporary working environment. Once the server is in rescue mode, you can build the OpenShift Agent-based Installer PXE artifacts there and then use kexec to jump straight into the Red Hat CoreOS (RHCOS) installer kernel. That avoids extra manual boot steps and works well for this kind of one-node setup.
I cover the manual path first and keep every command in the article so you can see exactly what is happening. There is also an optional shortcut later if you want to automate the preparation steps. What you get out of this:
- A running single-node OpenShift cluster on one Hetzner dedicated server.
- A
kubeconfigandkubeadminpassword on your workstation so that you can manage it remotely. - A Robot firewall in front of the node so it isn't wide open to the internet the moment it boots.
It is best suited for labs, testing, or edge use where you understand the risks. A single-node OpenShift is a single point of failure. Also, kexec does not always behave the same way as a full reboot on every hardware model. If the server does not boot cleanly into RHCOS, use a KVM console to see what is happening. If you need a supported installation path, use the Agent-based Installer with standard boot media.
Prerequisites
- A Hetzner dedicated server you can put into the Rescue System. Servers like EX44-1 and AX41-1 are good choices for OpenShift SNO because they have enough CPU, memory, and local storage. This method was developed and tested on an EX44. Red Hat documents SNO minimums of 8 vCPUs, 16 GB RAM, and 120 GB disk, but in practice you will want more memory and disk capacity. Any modern Hetzner dedicated server should clear those requirements easily. SNO on bare metal uses the
platform: noneprofile and OVN-Kubernetes by default. (Source: Red Hat — Preparing to install on a single node, 4.20.) - A Red Hat Account. You can register for free at redhat.com/wapps/ugc/register.html.
- A Red Hat pull secret from the Red Hat console. A free Red Hat Developer subscription is enough for a lab.
- An SSH key pair. The public key is baked into the install so you can later log in to the node as
core. - OpenShift 4.14.0 or newer (recommended: latest GA version of 4.21 or 4.22). This workflow relies on
openshift-install agent create pxe-files, which Red Hat documents from 4.14 onward. While any version from 4.14.0 works, using the latest GA release ensures you get recent fixes and features. - Comfortable with SSH, the Hetzner Robot panel, YAML, and
oc/kubectl. - No wildcard DNS entry for your domain (e.g.
*.example.com), otherwise the DNS wildcard validation might fail during the installation process.
Example terminology
This tutorial uses the following example values — replace them everywhere they appear:
| Placeholder | Example value | Meaning |
|---|---|---|
<server-ip> |
203.0.113.45 |
Public IPv4 of your dedicated server |
<prefix> |
26 |
Netmask prefix length of the server subnet |
<gateway> |
203.0.113.1 |
Default gateway |
<interface> |
enp41s0 |
Primary NIC name in rescue |
<mac> |
a8:a1:59:b3:c2:01 |
MAC address of that NIC |
<dns> |
192.0.2.53 |
DNS resolver (Hetzner's are fine) |
<disk-serial> |
S63CNF0X212063 |
Serial of the install disk |
<your-admin-ip> |
203.0.113.10/32 |
The IP/CIDR you administer from |
<base-domain> |
example.com |
Cluster base domain |
<cluster-name> |
sno |
Cluster name (API → api.sno.example.com) |
<ocp-version> |
4.22.1 |
OpenShift release (use latest GA 4.21 or 4.22) |
<cluster-name> and <base-domain> are separate fields that OpenShift stitches together: cluster name sno + base domain example.com gives an API endpoint at api.sno.example.com. For a lab you don't strictly need public DNS, but if you want to reach the console by name from elsewhere, point api.sno.example.com and a wildcard *.apps.sno.example.com at the server's IP.
Step 1 - Lock down the Robot firewall first
Do this before you ever boot the installer. The moment kexec hands control to RHCOS, the node is live on a public IP and OpenShift opens a fair number of ports.
The Hetzner Robot firewall is a free, stateless packet filter at the switch port. Stateless matters: it doesn't track connection state, so return traffic for connections the server initiates (image pulls from quay.io, Red Hat API calls, DNS, NTP) must be allowed explicitly. Rules are top-to-bottom, first match wins, no match means drop.
Go to https://robot.hetzner.com/server, pick your server, open the Firewall tab, and enable two global options: Filter IPv6 packets (SNO here is IPv4-only) and Hetzner Services (lets Hetzner's own infrastructure through).
For a single-admin lab, this rule set works.
Outgoing:
| # | Name | Protocol | Action |
|---|---|---|---|
| 1 | Allow all outgoing | * |
accept |
Incoming:
Run
curl -4 https://ip.hetzner.comon your local machine to get your admin IP.
| # | Name | Source IP | Dst Port | Protocol | TCP Flags | Action |
|---|---|---|---|---|---|---|
| 1 | Trusted admin | <your-admin-ip> |
* |
accept | ||
| 2 | ICMP | icmp | accept | |||
| 3 | TCP established | 32768-65535 | tcp | ack | accept | |
| 4 | UDP responses | 32768-65535 | udp | accept |
Rule 1 lets your workstation reach everything (SSH 22, API 6443, console 443). Rules 3 and 4 are the stateless workaround — they accept the ACK and ephemeral-port return packets for outbound connections. Without them, image pulls can hang and the install can stall. You get 10 incoming rules total, and each trusted IP uses one. The firewall usually takes about 20-40 seconds to apply after you save.
Step 2 - Boot the Rescue System and wipe the disks
In the Robot panel, on the server's Rescue tab, enable the Linux rescue system (64-bit), then reboot so the server comes up into rescue. Rescue is Debian 12, and DHCP is already working — if you can SSH in, the network is fine.
ssh root@<server-ip>The install disk must be clean. List your disks, then wipe the target:
Warning: This is destructive — be sure
lsblk -o NAME,SIZE,TYPE,MODEL,SERIAL
wipefs -a /dev/nvme0n1Note the serial number of your install disk. On multi-disk servers you'll pin the install by serial, because NVMe kernel names (nvme0n1, nvme1n1…) can reorder across reboots while the serial doesn't.
Note that some systems may use different names (sda, sdb,...).
From here, you have two ways to build and boot the installer. Steps 3 through 8 below show the manual process. If you prefer, you can use the optional shortcut and then continue at Step 9.
Shortcut: automate Steps 3-8 with helper scripts (optional)
If you do not want to do every preparation step manually, the community project tomazb/hetzner-sno-provision-host automates the same flow shown below. It detects the network settings, downloads and verifies oc and openshift-install, builds nmstatectl, generates the configs, creates the PXE files, and runs the padded-initrd kexec.
Same caveats apply: experimental, unsupported, lab use only. You're trusting a third-party repository to run as root in your rescue environment — read the scripts first.
On the rescue host:
Save the Red Hat pull secret in /root/pull-secret.json and store the public SSH key of your workstation in a file such as /root/.ssh/workstation.pub. Then continue with the commands below.
git clone https://github.com/tomazb/hetzner-sno-provision-host.git
cd hetzner-sno-provision-host
# Dry-run first: prints the detected interface, IP, gateway, DNS, and disk
# without installing or writing anything.
./hetzner-sno-prepare-pxe.sh --dry-run \
--disk-serial <disk-serial> \
<ocp-version> /root/pull-secret.json <base-domain> <cluster-name>
# Real run (interactive): prompts for anything it can't detect, then builds
# the PXE artifacts into /root and prints the kubeadmin password + kubeconfig.
./hetzner-sno-prepare-pxe.sh --interactive --disk-serial <disk-serial>It auto-discovers pull-secret.* files and *.pub SSH keys under $HOME, so putting them in /root first is the simplest approach. Save the kubeadmin password and kubeconfig it prints. The next command replaces the rescue environment, so those files will be lost unless you copy them out first. Then boot:
./hetzner-sno-provision-host-agentbased.sh --artifact-dir /rootYour SSH session drops on kexec — that's expected. Skip ahead to Step 9.
Optional — reserve boot-disk space for local storage: add --csi-reserve-size 500G to the prepare command to carve a raw, unformatted partition out of the boot disk for LVMS or another CSI operator. After install it appears at /dev/disk/by-partlabel/openshift-csi. This requires pinning the disk by serial. The script only creates the partition — you point your storage operator at it afterward.
Step 3 - Collect the network facts you'll need
The Agent-based Installer wants an exact description of the node's network. Grab the values from the live rescue session so you don't guess. Interface, IPv4/prefix, and gateway:
ip -brief addr show
ip route show defaultThe MAC address of that interface:
cat /sys/class/net/<interface>/addressAnd the resolvers (or just use your provider's resolvers — example values: 192.0.2.53 and 192.0.2.54):
grep nameserver /etc/resolv.confWrite these down. They go into the YAML in Step 5.
Run the following cat command to get information about your machine in list form:
cat <<EOF
---------------------------------------
Interface: $(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}')
IP/prefix: $(ip -o -4 addr show $(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}') | awk '{print $4}')
Rendezvous IP: $(curl -4 -s https://ip.hetzner.com)
Gateway: $(ip route | awk '/default/ {print $3}')
Machine network: $(python3 -c 'import ipaddress,subprocess; ip=subprocess.check_output("ip -o -4 addr show $(ip route get 1.1.1.1 | awk '\''{for(i=1;i<=NF;i++)if($i==\"dev\")print $(i+1)}'\'') | awk '\''{print $4}'\''",shell=True,text=True).strip(); print(ipaddress.ip_interface(ip).network)')
MAC: $(ip link show "$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}')" | awk '/link\/ether/ {print $2}')
DNS servers:
$(resolvectl status | grep -oE '([0-9a-fA-F]{0,4}:){2,}[0-9a-fA-F:.]+|([0-9]{1,3}\.){3}[0-9]{1,3}' | sort -u)
Disks and their serial:
$(lsblk -ndo NAME,SERIAL | awk '$2 != "" {print "/dev/"$1, $2}')
EOFStep 4 - Install the toolchain in rescue
You need three things on the rescue host: the OpenShift binaries (oc, openshift-install), nmstatectl (the installer validates the network YAML with it), and kexec-tools.
Download and verify oc and openshift-install
Pull the version-matched binaries from the Red Hat mirror and check them against the published checksums — don't skip the verification:
See latest version
cd /root
VER=<ocp-version>
BASE=https://mirror.openshift.com/pub/openshift-v4/x86_64/clients/ocp/${VER}
curl -fL -O ${BASE}/openshift-client-linux.tar.gz
curl -fL -O ${BASE}/openshift-install-linux.tar.gz
curl -fL -O ${BASE}/sha256sum.txt
sha256sum --ignore-missing --check sha256sum.txt
tar -xzf openshift-client-linux.tar.gz -C /usr/local/bin oc kubectl
tar -xzf openshift-install-linux.tar.gz -C /usr/local/bin openshift-install
oc version --client
openshift-install versionInstall nmstatectl (the tricky bit on Debian 12)
This is the one place where the Debian rescue environment does not help much: there is no nmstatectl package for it, and openshift-install needs that binary to validate the NMState networkConfig. The practical fix is to build a pinned release from crates.io with Cargo:
apt-get update
apt-get install -y build-essential pkg-config libssl-dev curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
cargo install nmstatectl --version 2.2.60 # pin a known-good release
nmstatectl --versionThe cargo build takes a few minutes and may need an extra
-devpackage. If it stops on a missing library, install the matchinglib*-devpackage and run it again. The shortcut script handles this step for you.
Install kexec-tools
The kexec-tools postinstall on Debian tries to hook itself into the boot process, which you don't want here. Preseed it to stay out of the way, then install:
echo 'kexec-tools kexec-tools/use_grub_config select false' | debconf-set-selections
echo 'kexec-tools kexec-tools/load_kexec select true' | debconf-set-selections
apt-get install -y kexec-toolsStep 5 - Write the install and agent configs
Now you can create the files. Replace the placeholders with the information you gathered in step 3.
The Agent-based Installer reads two files. Put them in a working directory — and keep copies, because openshift-install consumes (deletes) them when it generates the assets.
mkdir -p /root/ocp-installCreate /root/ocp-install/install-config.yaml. This is the standard SNO shape: one control-plane replica, zero compute, platform: none. Drop in your pull secret (the whole JSON, single-quoted on one line) and your SSH public key:
apiVersion: v1
baseDomain: <base-domain>
metadata:
name: <cluster-name>
compute:
- name: worker
replicas: 0
controlPlane:
name: master
replicas: 1
networking:
networkType: OVNKubernetes
clusterNetwork:
- cidr: 10.128.0.0/14
hostPrefix: 23
serviceNetwork:
- 172.30.0.0/16
machineNetwork:
- cidr: 203.0.113.0/<prefix> # your server's subnet, not the host IP
platform:
none: {}
pullSecret: '<paste-pull-secret-json-here>'
sshKey: |
ssh-ed25519 AAAA... your-public-keyThen /root/ocp-install/agent-config.yaml. This is where the network facts from Step 3 go, plus rootDeviceHints pinned to the disk serial so the installer targets the right drive:
apiVersion: v1beta1
kind: AgentConfig
metadata:
name: <cluster-name>
rendezvousIP: <server-ip>
hosts:
- hostname: <cluster-name>.<base-domain>
interfaces:
- name: <interface>
macAddress: <mac>
rootDeviceHints:
serialNumber: <disk-serial>
networkConfig:
interfaces:
- name: <interface>
type: ethernet
state: up
mac-address: <mac>
ipv4:
enabled: true
dhcp: false
address:
- ip: <server-ip>
prefix-length: <prefix>
dns-resolver:
config:
server:
- <dns>
routes:
config:
- destination: 0.0.0.0/0
next-hop-address: <gateway>
next-hop-interface: <interface>For a single node, rendezvousIP is just the node's own IP - like 203.0.113.45 . The machineNetwork CIDR in install-config.yaml is the subnet (e.g. 203.0.113.0/26), not the host address.
Back up both files now:
cp /root/ocp-install/install-config.yaml /root/install-config.yaml.bak cp /root/ocp-install/agent-config.yaml /root/agent-config.yaml.bakThe next command eats the originals. If you want to tweak and re-run, you'll restore from these.
Step 6 - Generate the PXE boot artifacts
Run the installer. It validates your configs (needs nmstatectl), then produces the three RHCOS boot files plus an auth/ directory with your credentials:
openshift-install agent create pxe-files --dir /root/ocp-installWhen it finishes you'll have:
/root/ocp-install/boot-artifacts/agent.x86_64-vmlinuz
/root/ocp-install/boot-artifacts/agent.x86_64-initrd.img
/root/ocp-install/boot-artifacts/agent.x86_64-rootfs.img
/root/ocp-install/auth/kubeconfig
/root/ocp-install/auth/kubeadmin-passwordCopy the boot artifacts to /root with their original names — the kexec step expects them there:
cp /root/ocp-install/boot-artifacts/agent.x86_64-* /root/
ls -lh /root/agent.x86_64-*Step 7 - Save your cluster credentials
Do not skip this step. The kexec in the next step replaces the entire rescue filesystem, so these files will be lost. Save both to your workstation before continuing:
cat /root/ocp-install/auth/kubeadmin-password # copy this string
cat /root/ocp-install/auth/kubeconfig # save to ~/.kube/config locallyThere is no password recovery flow here. If you lose the kubeconfig before the cluster is fully up, you will need to reinstall. Copy the files out before continuing.
Step 8 - kexec into the Agent installer
kexec boots a new kernel from inside the running one — no firmware round-trip. The problem: kexec takes a single initrd, but the Agent installer ships two images (the initrd and a separate rootfs). The fix is to concatenate them into one combined initrd, with a little zero-padding so the second cpio archive starts on a 4-byte boundary.
The padding is 4 + ((4 - (initrd_size % 4)) % 4) bytes — align the initrd to 4 bytes, then add 4 more. Build it:
cd /root
INITRD=agent.x86_64-initrd.img
ROOTFS=agent.x86_64-rootfs.img
COMBINED=agent.x86_64-combinedinitrd.img
INITRD_SIZE=$(wc -c < "$INITRD")
PAD=$(( 4 + ((4 - (INITRD_SIZE % 4)) % 4) ))
cat "$INITRD" > "$COMBINED"
dd if=/dev/zero bs=1 count="$PAD" status=none >> "$COMBINED"
cat "$ROOTFS" >> "$COMBINED"Now load the RHCOS kernel with that combined initrd and the kernel arguments the Agent installer expects, and fire:
kexec -l /root/agent.x86_64-vmlinuz \
--initrd=/root/agent.x86_64-combinedinitrd.img \
--append='rw ignition.firstboot ignition.platform.id=metal'
kexec -eYour SSH session will drop the instant you run kexec -e. That is normal. The new kernel is taking over, so do not reset the server.
Step 9 - Watch the install finish
The node is now booting RHCOS and running the OpenShift bootstrap on its own. Total install time is typically 30-60 minutes, depending on hardware and image-pull speed.
After ~5-10 minutes the box answers SSH again — as the core user, not root:
ssh core@<server-ip>Heads up on SSH host keys: the host key changes entering rescue, and again when RHCOS takes over, so you will see
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED. Normal. Clear the stale key and reconnect:ssh-keygen -R <server-ip>
You can run sudo journalctl -f to watch the progress.
Run the following commands to check if everything is running:
Note that it can take a while before everything is ready and running.
sudo podman ps
systemctl --no-pager status kubelet
systemctl --no-pager status crio
ss -tlnp | grep 6443After everything is running, save the kubeconfig in ~/.kube/config, then run:
export KUBECONFIG=~/.kube/config
oc get nodes
oc get clusteroperatorsThe API will not answer at first. It comes up partway through the install. Once it does, the single node should report all three roles:
$ oc get nodes
NAME STATUS ROLES AGE VERSION
sno.example.com Ready control-plane,master,worker 42m v1.35.1+a2c5e01Watch oc get clusteroperators until every operator is AVAILABLE=True and PROGRESSING=False. When they settle, you're done. Log in to the console as kubeadmin with the password from Step 7 at:
https://console-openshift-console.apps.<cluster-name>.<base-domain>You can double-check the domain by running this command:
oc whoami --show-console
curl -vk $(oc whoami --show-console)If the node is still unreachable after ~15 minutes, the most likely cause is a wrong value in agent-config.yaml, such as an incorrect gateway or prefix. At that point, use a KVM console to see what the server is doing.
Step 10 - Next Steps
Useful next steps:
| Description | |
|---|---|
| Post-installation configuration | Configure authentication, certificates, networking, and monitoring for your cluster. |
| Storage configuration | SNO ships without a default storage class. Consider LVM Storage for local persistent volumes. |
| Building applications | Learn how to deploy your first application using Source-to-Image (S2I), Dockerfiles, or Helm charts. |
| Networking Operators | Explore additional networking features like the Ingress Node Firewall Operator for host-level filtering. |
| Backup and restore | Essential for SNO since it's a single point of failure. Set up etcd backups and application-level backups with OADP. |
For comprehensive guidance, refer to the OpenShift Container Platform 4.22 documentation.
Conclusion
At this point, you should have a working single-node OpenShift cluster running on a Hetzner dedicated server. The installation was prepared from the Rescue System and booted into the RHCOS installer with kexec.
The next practical steps are to configure storage, set up DNS for the API and application routes, and review your firewall rules once the installation is complete. If you want additional host-level filtering inside the cluster, you can look at the Ingress Node Firewall Operator.
This setup is best suited for labs, testing, or edge use. Because this is a single-node cluster, the host is a single point of failure. Make sure you plan for backups accordingly.