Migrate CentOS Stream to Rocky Linux
Move a CentOS Stream 8 or 9 server to Rocky Linux with migrate2rocky: the README warnings, snapshot first, what distro-sync keeps, and the checks after.
Why migrate CentOS Stream to Rocky Linux
To migrate CentOS Stream to Rocky Linux you run one script from Rocky's rocky-tools repository: migrate2rocky9.sh on Stream 9, or migrate2rocky.sh on Stream 8. The script swaps the packages that define the distribution, runs a dnf distro-sync, and asks for a reboot. The server keeps its major version, its data, its users and its configuration. Most of the work is in the checks before you run it and the checks after, and that is where most of this guide goes.
The reason to move is the support window. CentOS Stream is the development branch that runs ahead of the next Red Hat Enterprise Linux (RHEL) minor release, so it never has a stable point release to sit on, and each major version stops receiving updates long before the matching RHEL does. Checked on centos.org today, 2026-09-18: CentOS Stream 8 reached end of life on 2024-05-31, so a Stream 8 server has had no security updates for more than two years. CentOS Stream 9 reaches end of life on 2027-05-31. Rocky Linux tracks the RHEL lifecycle instead, so as of September 2026 Rocky 8 is supported until 2029-05-31 and Rocky 9 until 2032-05-31. How CentOS ended up here is the subject of the history of Red Hat, CentOS, Rocky and Alma. This guide is the practical half of that story.
Two limits set the shape of everything below. The script converts within one major version only: Stream 8 becomes Rocky 8 and Stream 9 becomes Rocky 9. The README says it plainly: "Upgrades are not supported." And the script refuses anything that is not Enterprise Linux (EL). Run it on the wrong major version, or on Fedora, and it exits with This script must be run on an EL9 distribution. Migration from other distributions is not supported.
Which script: migrate2rocky9 or migrate2rocky
Both scripts live in the migrate2rocky directory of the rocky-tools repository on its main branch. Check your major version first. The wrong script refuses to run, and the check costs nothing.
cat /etc/os-release
rpm -q centos-stream-releasePLATFORM_ID="platform:el9" means Stream 9 and migrate2rocky9.sh. PLATFORM_ID="platform:el8" means Stream 8 and migrate2rocky.sh. The second command is the same test the script uses to tell Stream from CentOS Linux: an installed centos-stream-release package means Stream.
On Stream 8 there is one more thing to know. The Stream 8 repositories were taken off the CentOS mirrors after end of life, so dnf update on an untouched Stream 8 box fails with Failed to download metadata for repo 'appstream': Cannot prepare internal mirrorlist: No URLs in mirrorlist. migrate2rocky.sh carries its own map of the Stream 8 repositories, pointing at the copy Rocky hosts under https://dl.rockylinux.org/vault/centos/8-stream/, and uses that map in place of the dead mirrors during the conversion. You do not need to repair the repositories yourself first. You do need to leave the original repository files in place, for the reason in the next section.
What breaks the migration, from the README
The README is short, and every warning in it describes a system the script cannot rescue. Read these before you start.
Altered core repositories. The script "expects the original repository configuration being present, as well as enabled." The known-issues section goes further: "Any distribution that has had its core repositories altered, removed, duplicated or overridden may cause migrate2rocky to break or corrupt the system when run." A fresh install migrates cleanly. A server where someone replaced centos.repo with a local mirror, or copied a repository under a second name, is the case that corrupts. List what you have and compare it to a stock install:
ls -l /etc/yum.repos.d/
dnf repolistOn a stock Stream 9 server dnf repolist shows baseos and appstream, plus extras-common, plus crb and epel if you enabled them. A repository id you do not recognise, or a baseos that appears twice, means stop and restore the stock files before you go further.
Katello, Satellite, Uyuni and SUSE Manager. A server registered to Red Hat Satellite 6 (Katello) or to Uyuni or SUSE Manager has its repositories managed from outside, and the script refuses to touch it. The check is a file test. If /etc/rhsm/ca/katello-server-ca.pem exists the script exits with Migration from Katello-modified systems is not supported by migrate2rocky9., and /etc/salt/minion.d/susemanager.conf produces the matching Uyuni message. The Rocky guide adds that "known clashes between the script and Katello's repositories exist." A VPS installed from a provider image has neither file.
A broken rpm database. The script runs dnf -y check before anything else and exits with Errors found in dnf/rpm database. Please correct before running migrate2rocky9. if that fails. Run sudo dnf check yourself beforehand so you see the errors on your own terms, with time to fix them.
Not enough disk. The README lists the minimums: 250M free in /usr, 1.5G in /var and 50M in /boot. df -h /usr /var /boot shows whether you have them.
A dropped SSH session. SSH (secure shell) is how you reach a VPS, and a script killed halfway through the package swap leaves a system that is neither Stream nor Rocky. The README recommends a screen or tmux session "especially during remote connections". This is the warning most people skip, and it is the cheapest one to follow.
Snapshot first
dnf history undo cannot roll back a migration. The transaction replaces centos-stream-release and centos-stream-repos with rocky-release and rocky-repos, and afterwards the old repositories are disabled, so there is nowhere for a dnf history rollback to pull the old packages from. The only rollback is a snapshot of the whole disk, taken before you start.
Take that snapshot in your VPS provider's panel, from a clean state: stop your application services and run sync, then snapshot while nothing is writing. The Rocky guide puts it in bold: "make a data backup and system snapshot, or do it in a staging environment first." A staging copy of the server is the better option when you can afford one, because the migration is the same command either way and you get to read the log before anything touches production.
Then bring the system fully up to date and reboot into the current kernel, so the migration starts from a known state:
sudo dnf -y update
sudo rebootOn Stream 9 this is routine. On Stream 8 the update fails with the mirrorlist error above, and since no Stream 8 update has been published since 2024-05-31 there is nothing to fetch; skip it and let the script's vault map do the work. The one exception is a Stream 8 box that was never updated past 8.0, which the README says must be updated before the migration, using the repository sed from its known-issues section.
Run migrate2rocky9
Download the script from the main branch, make it executable, and read the help before you run it. These are the commands from the Rocky migration guide, with the script name changed to the 9 version.
sudo dnf install -y tmux
tmux new -s migrate
curl -O https://raw.githubusercontent.com/rocky-linux/rocky-tools/main/migrate2rocky/migrate2rocky9.sh
chmod u+x migrate2rocky9.sh
sudo ./migrate2rocky9.sh -hThree flags exist. -h prints the help. -r converts to Rocky. -V adds a verification pass that records the rpm state before and after the conversion for comparison; it is optional, and it makes the run slower. On Stream 8, replace migrate2rocky9.sh with migrate2rocky.sh in every command above and below.
The script needs root, and it needs to reach dl.rockylinux.org, because that is where it fetches the Rocky GPG (GNU Privacy Guard) signing key and the BaseOS and AppStream repositories it pulls rocky-release from. Start the conversion inside the tmux session:
sudo ./migrate2rocky9.sh -rEverything the script prints to stdout and stderr is also written to /var/log/migrate2rocky.log. If the session drops, reattach with tmux attach -t migrate. If the script has already finished or failed by then, the log is the record. The last line of a successful run is Done, please reboot your system.
Do not reboot yet. Read the CentOS Stream notes the script prints just above that line, because they explain what it did with the packages Stream had at a newer version than Rocky.
What distro-sync does when Stream is ahead of Rocky
Rocky Linux 9 is a rebuild of the current RHEL 9 point release. CentOS Stream 9 is the branch the next RHEL 9 point release is built from, so it is always ahead. On the day you migrate, many packages on the Stream box carry a higher version than the same package in Rocky's repositories, and some are a whole upstream version newer. The script's own notes describe the problem and the choice it makes:
Because CentOS Stream leads RockyLinux by the next point release many packages in Stream will have higher version numbers than those in RockyLinux, some will even be rebased to a new upstream version. Downgrading these packages to the versions in RockyLinux carries the risk that the older version may not recognize config files, data or other files generated by the newer version in Stream.
To avoid issues with this the newer package versions from CentOS Stream have been retained. Also the CentOS Stream repositories have been retained but renamed with a prefix of "stream-" to avoid clashing with RockyLinux repositories, but these same repos have also been disabled so that future package installs will come from the stock RockyLinux repositories.
So the sync step is not a mass downgrade. The script installs rocky-release and rocky-repos (plus rocky-gpg-keys) in place of the CentOS ones, then runs dnf -y --allowerasing distro-sync while the Rocky repositories and the renamed stream- repositories are both enabled. distro-sync moves every installed package to the best version available in the enabled repositories, and with both sets enabled the best version is usually the newer Stream one, which is how those packages stay. Only after that does the script disable the stream- repositories, so anything you install later comes from Rocky.
One group is replaced with Rocky's build no matter what version Stream had. The script keeps the list in an array named stream_always_replace: kernel and kernel-*, grub2-*, shim-* and fwupdate*. That is the boot chain, and the script does not leave a Stream kernel or bootloader in place. Which other packages change version on your server depends on what you have installed, and the honest way to find out is to read the transaction rather than to guess. The conversion appears as the newest entries in dnf's history, and the large one is the distro-sync:
sudo dnf history list | head -n 8
sudo dnf history info IDhistory info labels every package with its action, so the Downgrade lines are exactly the packages that moved to Rocky's older build, and the packages absent from the list are the ones retained from Stream. The same transaction is in /var/log/migrate2rocky.log if you prefer to read it there.
The notes end with the plan for the retained packages: "If you do nothing except update to the next point release of RockyLinux when it becomes available then the packages retained from Stream should be replaced at that time." Until that point release ships, a retained package receives no updates from Rocky, because Rocky's version is lower and a normal dnf update never installs a lower version. If one of them needs a security fix in the meantime, the notes say to enable the appropriate repository for that one update:
sudo dnf --enablerepo='stream-*' upgrade PACKAGEPost-migration checks on Rocky
Reboot into the Rocky kernel, then confirm the identity of the system from more than one source, because a migration that half-worked can pass one check and fail the others.
sudo rebootAfter the reboot:
cat /etc/rocky-release
hostnamectl | grep 'Operating System'
rpm -q rocky-release centos-stream-release/etc/rocky-release should read Rocky Linux release 9.x (Blue Onyx) with the current point release in place of x. hostnamectl should say Rocky Linux. The rpm -q line should print the installed rocky-release version and then package centos-stream-release is not installed. A centos-stream-release that is still installed means the package swap did not complete, and the log shows where it stopped.
Then check the repositories:
dnf repolist
dnf repolist --disabledThe enabled list should be Rocky's baseos and appstream, plus extras, plus crb and epel if you had them. The disabled list should hold the renamed Stream repositories, each id starting with stream-. Leave them disabled. They exist for the one-package update case above and for nothing else.
Check what the kernel and the packages say about themselves. Every rpm carries a VENDOR tag, so a count by vendor shows how much of the system is now Rocky's build and how much is retained from Stream:
rpm -q kernel --queryformat '%{VERSION}-%{RELEASE} %{VENDOR}\n'
uname -r
rpm -qa --queryformat '%{VENDOR}\n' | sort | uniq -cThe kernel from uname -r should match a line whose vendor is Rocky Enterprise Software Foundation. The count of CentOS packages is your retained set. It shrinks at the next Rocky point release, and the same command shows it shrinking. The README also notes that GRUB, the bootloader, still lists the old CentOS kernel entries: "This is normal. The running kernel cannot be safely removed when migrate2rocky is run." Now that the Rocky kernel is the one running, sudo dnf remove --oldinstallonly removes the kernels beyond your installonly_limit and keeps the running one.
Re-check EPEL (Extra Packages for Enterprise Linux). On Stream, EPEL users often also had EPEL Next, the variant built against Stream's newer packages, and the script treats epel-next-release as a Stream repository package: it is dropped from the rpm database and its repository is renamed and disabled with the others, because EPEL Next has no meaning on Rocky. Plain epel-release is left alone. If dnf repolist shows no epel line and you need it, sudo dnf install -y epel-release brings it back from Rocky's extras repository, and enabling EPEL and CRB on Rocky and Alma covers the CRB (CodeReady Builder) half, which some EPEL packages depend on.
Finally, confirm that nothing is still running old code. The reboot handled the kernel and everything that starts at boot, so this is a confirmation, but it is a cheap one:
sudo dnf install -y yum-utils
sudo needs-restarting -r
sudo needs-restarting -sneeds-restarting -r prints Reboot should not be necessary. when the running kernel matches the newest installed one. If it still asks for a reboot straight after one, GRUB booted an older entry, and the kernel query above shows which. -s lists services holding replaced libraries, and after a reboot it should print nothing. Reading needs-restarting after updates on Rocky and Alma covers what to do when either answer is not clean.
What to do with a server that is now Rocky
The migration keeps your firewall rules, your SELinux (Security-Enhanced Linux) mode, your users and your services as they were, so nothing in securing a new Rocky Linux server is undone. It is still the right checklist to re-run, because a server that has been on Stream for a while has usually collected some drift. The setting that matters most from this point is unattended patching. Stream delivered updates in a constant flow; Rocky ships them as point releases plus security errata between them, and dnf-automatic on Rocky and Alma is how you apply those without logging in.
If you chose AlmaLinux instead, its almalinux-deploy script does the same job from the same starting point, within the same major version and with the same snapshot rule. The choice between the two is not about the migration, which is equivalent either way, and Rocky Linux versus AlmaLinux makes the case for each so you do not have to re-argue it here.
FAQ
Can migrate2rocky move CentOS Stream 8 to Rocky Linux 9?
No. Both scripts convert within one major version, Stream 8 to Rocky 8 and Stream 9 to Rocky 9, and the README states "Upgrades are not supported." A Stream 8 server that needs to end up on 9 is either reinstalled on Rocky 9 and restored from backup, or migrated to Rocky 8 first and moved to 9 later with a separate major-version tool such as AlmaLinux's ELevate, which is a separate project with its own risks. Rocky 8 is supported until 2029-05-31, so the second step is not urgent once the migration is done.
Why does the migration fail with "This script must be run on an EL9 distribution"?
The script reads PLATFORM_ID from /etc/os-release and exits if it is not platform:el9. Either you are on Stream 8 and ran the 9 script, or the reverse, or you are on a distribution that is not Enterprise Linux at all, such as Fedora, which the README lists as unsupported. cat /etc/os-release shows which case you are in, and the fix is the other script.
Does migrate2rocky downgrade my packages to Rocky's versions?
Mostly no. The script's Stream notes explain that Stream leads Rocky by a point release and that it keeps the newer Stream versions rather than downgrade them, because an older build may not read the config or data files a newer one wrote. It always replaces the kernel, GRUB, shim and fwupdate with Rocky's builds. The rest of the retained packages are replaced when you update to the next Rocky point release. sudo dnf history info on the migration transaction, or /var/log/migrate2rocky.log, shows the exact list on your server.
The script exited because of Katello or a modified repository. What now?
The script tests for /etc/rhsm/ca/katello-server-ca.pem and /etc/salt/minion.d/susemanager.conf and refuses to run if either exists, and the README warns that altered, removed or duplicated core repositories can corrupt the system. Neither case has a workaround inside the script. Restore the stock repository files and unregister from the management server on a staging copy first, or install Rocky fresh and restore your data. Both are safer than forcing the migration through.
How do I roll back if the migration goes wrong?
Restore the snapshot you took before running the script. dnf history undo cannot do it, because the transaction removed the CentOS release and repository packages and disabled the Stream repositories that reinstalling them would need. Without a snapshot, /var/log/migrate2rocky.log shows how far the script got, and a partly migrated system is usually easier to finish, by fixing the reported error and running migrate2rocky9.sh -r again, than to take back.