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
--growinstead of LVM — simpler for cloud images and DO's resize-on-first-boot to work correctly viacloud-utils-growpart cloud-initinstalled and configured with the DigitalOcean datasource so it picks up SSH keys, hostname, and networking from the DO metadata service on first bootmachine-idtruncated 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