Andrew Mercer

Since you already have a Rocky 9 kickstart workflow, here's the RHEL equivalent adjusted for RHEL-specific details.

1. Create the kickstart file

Save this as rhel9-do.ks:

cat > rhel9-do.ks << 'EOF'
#version=RHEL9

# Use CDROM
cdrom

# Text mode
text

# Keyboard/Language
keyboard --vckeymap=us --xlayouts='us'
lang en_US.UTF-8

# Network — cloud-init will handle networking on DO, we just need it up for install
network --bootproto=dhcp --device=eth0 --activate
network --hostname=rhel9-base

# Root password (locked — DO injects SSH keys via cloud-init)
rootpw --lock

# Create cloud-user with sudo (cloud-init will manage users on DO)
user --name=cloud-user --groups=wheel --shell=/bin/bash

# SELinux
selinux --enforcing

# Firewall
firewall --enabled --service=ssh

# Timezone
timezone UTC --utc

# Bootloader — must be on vda for KVM
bootloader --location=mbr --boot-drive=vda

# Partition — single root partition, no swap (DO handles swap via swapfile if needed)
clearpart --all --initlabel --drives=vda
part /boot/efi --fstype=efi  --size=600  --ondisk=vda
part /boot     --fstype=xfs  --size=1024 --ondisk=vda
part /         --fstype=xfs  --size=1    --grow --ondisk=vda

# Do not run Setup Agent
firstboot --disable

# Reboot after install
reboot

%packages
@^minimal-environment
cloud-init
cloud-utils-growpart
gdisk
dracut-config-generic
dracut-norescue
rsync
tar
curl
wget
vim
git
bash-completion
-plymouth
-iprutils
-fprintd-pam
-iwl*firmware
%end

%post --log=/root/ks-post.log
#!/bin/bash

echo "=== Starting post-install ==="

# Subscribe to Red Hat (replace with your credentials or activation key)
subscription-manager register \
  --username=YOUR_RH_USERNAME \
  --password=YOUR_RH_PASSWORD \
  --auto-attach

# Update all packages
dnf update -y

# Install EPEL (requires active subscription)
dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm

# cloud-init configuration for DigitalOcean
cat > /etc/cloud/cloud.cfg.d/99-digitalocean.cfg << 'CLOUDCFG'
datasource_list: [ConfigDrive, DigitalOcean, NoCloud, None]
datasource:
  DigitalOcean:
    retries: 3
    timeout: 10
CLOUDCFG

# Enable cloud-init services
systemctl enable cloud-init-local
systemctl enable cloud-init
systemctl enable cloud-config
systemctl enable cloud-final

# Configure cloud-init to manage SSH keys and users
sed -i 's/^disable_root:.*/disable_root: true/'      /etc/cloud/cloud.cfg
sed -i 's/^ssh_pwauth:.*/ssh_pwauth: false/'          /etc/cloud/cloud.cfg

# SSH hardening
sed -i 's/^#PermitRootLogin.*/PermitRootLogin no/'        /etc/ssh/sshd_config
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/^#PubkeyAuthentication.*/PubkeyAuthentication yes/'    /etc/ssh/sshd_config

# Configure sudo for wheel group
echo "%wheel ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/wheel
chmod 0440 /etc/sudoers.d/wheel

# Remove machine-id so DO generates a unique one per droplet
truncate -s 0 /etc/machine-id

# Remove SSH host keys — regenerated on first boot
rm -f /etc/ssh/ssh_host_*

# Clean up subscription (image shouldn't be registered — DO doesn't use RHSM)
subscription-manager unregister || true
subscription-manager clean

# Clean up
dnf clean all
rm -rf /var/cache/dnf
rm -f /root/anaconda-ks.cfg
rm -f /root/ks-post.log

echo "=== Post-install complete ==="
%end
EOF

2. Serve the kickstart over HTTP

cd ~/kickstart
python3 -m http.server 8080

3. Run virt-install

sudo virt-install \
  --name rhel9-digitalocean \
  --memory 2048 \
  --vcpus 2 \
  --disk path=/var/lib/libvirt/images/rhel9-do.qcow2,size=20,format=qcow2,bus=virtio \
  --location /var/lib/libvirt/images/rhel-9.4-x86_64-dvd.iso \
  --os-variant rhel9.4 \
  --network network=default \
  --graphics none \
  --console pty,target_type=serial \
  --extra-args "console=ttyS0,115200n8 inst.ks=http://192.168.122.1:8080/rhel9-do.ks inst.text" \
  --noautoconsole

Watch the install:

virsh console rhel9-digitalocean
# Ctrl+] to detach

4. Wait for the VM to shut down, then verify it's off

virsh list --all
# State should show "shut off" when install is complete

5. Sparsify and compress the image

# Sparsify — reclaims empty blocks, reduces upload size significantly
sudo virt-sparsify \
  --compress \
  /var/lib/libvirt/images/rhel9-do.qcow2 \
  /tmp/rhel9-do-sparse.qcow2

# Check the final size
ls -lh /tmp/rhel9-do-sparse.qcow2

If you don't have virt-sparsify:

sudo dnf install libguestfs-tools   # RHEL/Rocky
sudo apt install libguestfs-tools   # Debian/Ubuntu

6. Convert to raw format (required by DigitalOcean)

qemu-img convert \
  -f qcow2 \
  -O raw \
  /tmp/rhel9-do-sparse.qcow2 \
  /tmp/rhel9-do.raw

# Compress the raw image for faster upload
bzip2 --best /tmp/rhel9-do.raw
# Produces: /tmp/rhel9-do.raw.bz2

ls -lh /tmp/rhel9-do.raw.bz2

DigitalOcean also accepts qcow2 directly but raw is the most reliable. The bzip2 compressed raw is what most people use.

7. Upload to Spaces and import

# Configure s3cmd with your Spaces credentials first if not done
s3cmd --configure

# Upload
s3cmd put /tmp/rhel9-do.raw.bz2 \
  s3://your-space/rhel9-do.raw.bz2 \
  --acl-public \
  --region nyc3

# Import as custom image
doctl compute image create "RHEL 9.4" \
  --image-url "https://your-space.nyc3.digitaloceanspaces.com/rhel9-do.raw.bz2" \
  --region nyc3 \
  --image-distribution "Unknown" \
  --image-description "RHEL 9.4 minimal cloud-init"

# Watch import progress
watch -n 10 "doctl compute image list-user"

8. Create a Droplet once the image shows available

# Get image ID
doctl compute image list-user

# Get your SSH key ID
doctl compute ssh-key list

# Create the droplet
doctl compute droplet create rhel9-prod \
  --image <image-id> \
  --size s-2vcpu-4gb \
  --region nyc3 \
  --ssh-keys <ssh-key-id> \
  --wait

Key things the kickstart does differently from your Rocky one:

  • Single root partition with --grow instead of LVM — simpler for cloud images and DO's resize-on-first-boot to work correctly via cloud-utils-growpart
  • cloud-init installed and configured with the DigitalOcean datasource so it picks up SSH keys, hostname, and networking from the DO metadata service on first boot
  • machine-id truncated and SSH host keys removed so each Droplet gets unique identifiers when cloned from the image
  • Subscription registered during install to pull updates, then unregistered before export — the image itself shouldn't carry a subscription since it's not a registered system