CentOS 7 to AlmaLinux 9: two honest migration routes
CentOS 7 reached end of life on 30 June 2024. Nothing dramatic happened that morning, the machine kept serving traffic, and that is exactly the problem. A CentOS 7 box today boots a 3.10 kernel, links against glibc 2.17 and OpenSSL 1.0.2, and gets no fixes for any of it. Below are the two routes off it that actually work: a new AlmaLinux 9 server with a data migration, which is what we recommend for anything public facing, and an in place conversion with ELevate for machines that genuinely cannot be rebuilt.
What actually stops working
The obvious answer is security updates, and that is true, but the practical failures usually arrive from a different direction.
- Packages become hard to install. mirror.centos.org is retired. Only vault.centos.org still carries CentOS 7, frozen at 7.9.2009. Anything you install from it is as old as the day support ended.
- Modern software refuses to build or run. Current Node.js, container runtimes, monitoring agents and many Composer or pip packages require a newer glibc, OpenSSL 3 or a Python that CentOS 7 does not have.
- Other people's servers start refusing you. This is the one that bites unannounced. Payment gateways, banks and partner APIs drop TLS 1.0 and 1.1 and old cipher suites, and your outbound cron job suddenly cannot connect. OpenSSL 1.0.2 has no TLS 1.3 at all.
- Certificate renewal quietly dies. ACME clients have dropped Python 2, so an untouched CentOS 7 box eventually stops being able to renew its own certificates.
- Audits and questionnaires. Any customer security review, PCI scope or insurance form now flags the OS by name.
Take stock before you touch anything
Most failed migrations fail because something nobody remembered was running on the old box. Spend an hour on inventory. On the CentOS 7 machine:
rpm -qa --qf '%{NAME} %{VERSION} %{VENDOR}\n' | sort -k3 > /root/inventory-packages.txt
systemctl list-unit-files --state=enabled > /root/inventory-services.txt
ss -tulpn > /root/inventory-ports.txt
iptables-save > /root/inventory-iptables.rules
crontab -l; ls -l /etc/cron.d /etc/cron.daily
for u in $(getent passwd | awk -F: '$3>=1000 {print $1}'); do crontab -l -u $u; done
httpd -M; php -v; php -m
mysql -e "SELECT VERSION(); SHOW DATABASES;"
Sorting packages by vendor is the useful trick: it exposes everything that came from EPEL, Remi, IUS or Webtatic, which is exactly the software that has no automatic equivalent on AlmaLinux 9. Then find the files that no package owns and the configs that were hand edited:
rpm -Va --nomtime --nosize | grep '^..5' | grep ' c '
find /opt /usr/local /srv -maxdepth 3 -type f | head -100
If you still need to install a tool on the old box to do the migration, point yum at the vault. It is frozen, so use it for the migration and nothing else:
cp -a /etc/yum.repos.d/CentOS-Base.repo /root/CentOS-Base.repo.bak
sed -i 's|^mirrorlist=|#mirrorlist=|' /etc/yum.repos.d/CentOS-Base.repo
sed -i 's|^#\?baseurl=http://mirror.centos.org/centos/$releasever|baseurl=http://vault.centos.org/7.9.2009|' /etc/yum.repos.d/CentOS-Base.repo
grep -E '^(mirrorlist|baseurl)' /etc/yum.repos.d/CentOS-Base.repo
yum clean all && yum makecache
The grep is not decoration. If the pattern misses, the substitution fails silently and yum keeps pointing at a host that no longer answers, so confirm it before you go on: every baseurl should now start with http://vault.centos.org/7.9.2009 and every mirrorlist should be commented out. One pass covers base, updates, extras and centosplus, since they live in the same file. Any other repository you still have, EPEL or SCLo, you point at its own archive or disable for the duration.
Route one: a fresh AlmaLinux 9 server
This is the route we recommend for websites, shops, APIs and anything else with users in front of it. You build a clean machine, you migrate data onto it, you test it while the old server is still serving, and the cutover is a DNS change you can reverse. A conversion gives you none of that. Provision the new machine as a VPS if the workload fits, or a dedicated server if you are CPU or disk bound. Both run in our own data centre in Riga on our own network, and if your current server is already with us, the old and the new machine stay in the same data centre while you work.
Do the base setup first: user accounts, SSH keys, time zone, monitoring, backup agent. Then reproduce the stack, not the machine. The goal is a supported AlmaLinux 9 configuration that runs your application, not a byte for byte copy of a ten year old server.
The version jumps that hurt
| Component | CentOS 7 | AlmaLinux 9 | What breaks |
|---|---|---|---|
| PHP | 5.4 in base | 8.0 in AppStream, 8.1 and newer as module streams | mod_php no longer exists, removed functions, stricter comparisons |
| Database | MySQL 5.7 or MariaDB 5.5 | MariaDB 10.11 as a module stream, MySQL 8.0 as a plain package | sql_mode strictness, zero dates, auth plugins, collations |
| Python | 2.7 as /usr/bin/python | 3.9 system Python, no Python 2 at all | every script with an old shebang |
| TLS | OpenSSL 1.0.2 | OpenSSL 3 plus system crypto policy | TLS 1.0 and 1.1 off, SHA-1 signatures and RSA under 2048 bits rejected |
| Firewall | iptables | nftables with firewalld 1.x | saved rulesets do not load, ipset and iptables-nft are deprecated |
| Network config | ifcfg network-scripts | NetworkManager keyfiles | the network-scripts package is gone |
| SELinux | usually disabled | enforcing, targeted policy | permission denials that look like application bugs |
PHP
Check which streams your minor release offers before you pick one, because streams were added over the lifetime of AlmaLinux 9 and a plain dnf install php gives you 8.0:
dnf module list php
dnf module enable php:8.2 -y
dnf install -y php php-fpm php-mysqlnd php-gd php-mbstring php-xml php-opcache
systemctl enable --now php-fpm
The structural change nobody expects: mod_php does not exist on EL8 or EL9. PHP runs under php-fpm and Apache talks to it over a socket. That part needs nothing from you: the PHP packages install /etc/httpd/conf.d/php.conf with a global FilesMatch and SetHandler block, so PHP works in every vhost with no per vhost change. You write the handler yourself only when a vhost has to use its own php-fpm pool:
SetHandler "proxy:unix:/run/php-fpm/app.sock|fcgi://localhost"
What actually breaks is the opposite direction, the leftovers of the old config, and they fail in two different ways. A bare php_value or php_flag line is an unknown directive once mod_php is gone: in a vhost or in httpd.conf Apache refuses to start with Invalid command 'php_value', and in an .htaccess file it returns 500 on that path while the rest of the server keeps serving. The quiet failure is the one you have to go looking for: everything inside an <IfModule mod_php5.c> block is skipped without a word, because the module is not there, so those settings are simply gone and nothing in the log mentions them. AddHandler php5-script is a normal mod_mime directive and never stops Apache, and the global SetHandler from php.conf is merged after .htaccess and wins, so PHP still runs and the line is only dead weight. Wherever they came from, the settings themselves move into the pool file under /etc/php-fpm.d/ as php_admin_value[...] entries, or into /etc/php.d/.
Anything written for 5.4 will have mysql_* calls, each(), create_function() and curly brace string offsets, all removed. PHP 8 also changed string to number comparison, so "abc" == 0 is now false, which silently changes the behaviour of old validation code rather than throwing an error. Start with a syntax sweep, then read the code:
find /var/www -name '*.php' -print0 | xargs -0 -n1 -P4 php -l | grep -v 'No syntax errors'
Databases
Dump with the character set spelled out, and use --lock-all-tables instead of --single-transaction if any table is still MyISAM:
mysqldump --single-transaction --routines --triggers --events --default-character-set=utf8mb4 --databases app > /root/app.sql
On the new box, enable the stream, install, then import:
dnf module list mariadb
dnf module enable mariadb:10.11 -y
dnf install -y mariadb-server
systemctl enable --now mariadb
mysql < /root/app.sql
mariadb-upgrade
If you are staying on MySQL rather than MariaDB, skip the module step entirely: on AlmaLinux 9 MySQL 8.0 is an ordinary AppStream package, there is no mysql stream to enable, and dnf install -y mysql-server is the whole command. Note the name: since MariaDB 10.5 the tools are mariadb, mariadb-dump and mariadb-upgrade, with the old mysql_upgrade kept only as a symlink. Two things reliably go wrong on import. Definer clauses on views and routines reference users that do not exist yet, so create the users first or strip the clauses. And dates like 0000-00-00 are rejected under the stricter default sql_mode, which turns a working legacy insert into an error at runtime rather than at import. Set character_set_server and sql_mode explicitly in /etc/my.cnf.d/ instead of trusting defaults, because they differ between versions.
Python 2
There is no Python 2 on AlmaLinux 9, not even as a module, and /usr/bin/python does not exist by default. Every internal script with #!/usr/bin/python fails immediately. Small scripts are usually a short rewrite. EL8 still shipped a python2 package, so a stop on AlmaLinux 8 buys you an interpreter you can install, though that stream itself went end of life in June 2024 and gets no fixes either, and on 9 there is nothing left to install at all. Code that genuinely cannot be rewritten has to run in a container built on an older base image, or stay on the old host until someone rewrites it. Do not build a Python 2 from source on a production host. A separate case, and the one python3.11 answers, is an application that needs a newer Python 3 than the system 3.9: install the parallel python3.11 package and give the application its own virtualenv.
Ciphers and the crypto policy
AlmaLinux 9 enforces a system wide crypto policy. Under DEFAULT, TLS 1.0 and 1.1 are off, SHA-1 signatures are rejected and RSA keys under 2048 bits are refused. This affects outbound connections too, so a script that talks to an old partner endpoint can fail on the new box while it worked on the old one. Check first, then loosen only as much as you must:
update-crypto-policies --show
update-crypto-policies --set DEFAULT:SHA1
Restart the affected services afterwards, since long running daemons read the policy at start. Two related traps: OpenSSL 3 will not open old PKCS#12 files that used RC2, so use openssl pkcs12 -legacy -in old.pfx, and SSH connections authenticating with an old ssh-rsa SHA-1 key are refused. That one has to be fixed in the right place. /etc/ssh/sshd_config starts with Include /etc/ssh/sshd_config.d/*.conf, the shipped 50-redhat.conf pulls in the crypto policy back end, which already sets PubkeyAcceptedAlgorithms, and sshd keeps the first value it obtains for a keyword. A line appended to the bottom of sshd_config is therefore ignored without a word, which is the worst possible outcome for someone already locked out. Use a drop-in that sorts earlier instead:
echo 'PubkeyAcceptedAlgorithms +ssh-rsa' > /etc/ssh/sshd_config.d/40-legacy-rsa.conf
sshd -t && systemctl reload sshd
Treat all of these as temporary bridges with a date on them.
iptables to nftables and firewalld
Your saved iptables ruleset will not simply load. The honest options are to rewrite the policy in firewalld, which is what most servers should do, or to translate the ruleset if it encodes real logic. For translation, install the compatibility tools on the new box, convert, then read the result before applying it. Do it with console access open, because a translated ruleset can shut your own SSH session out as easily as a hand written one, and pick one owner for the firewall: if you load the ruleset with nftables.service, disable firewalld, or the two will overwrite each other.
dnf install -y iptables-nft nftables
iptables-restore-translate -f /root/inventory-iptables.rules > /root/ruleset.nft
nft -c -f /root/ruleset.nft
nft -f /root/ruleset.nft
For most workloads firewalld is less work and easier to hand over:
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.10/32" port port="3306" protocol="tcp" accept'
firewall-cmd --reload
firewall-cmd --list-all
Two things to know. firewalld 1.x removed zone drifting and changed intra zone forwarding defaults, so a config copied from an EL7 era guide can behave differently. And ipset and iptables-nft are deprecated in this release, so do not build anything new on them. If you run fail2ban, set the ban action to an nftables or firewalld action explicitly and then confirm a ban actually lands with nft list ruleset, because a silently ineffective fail2ban is worse than none.
SELinux is enforcing again
Almost every CentOS 7 box in the wild has SELINUX=disabled. A fresh AlmaLinux 9 install is enforcing, and the symptoms look like application bugs: a 403 from Apache on a docroot outside /var/www, php-fpm unable to write sessions, a service unable to bind a custom port, an outbound connection refused for no visible reason. Read the denials rather than guessing:
dnf install -y policycoreutils-python-utils setroubleshoot-server
ausearch -m AVC -ts recent
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/app/storage(/.*)?"
restorecon -Rv /var/www/app
setsebool -P httpd_can_network_connect on
semanage port -m -t http_port_t -p tcp 8080
That block is a menu, not a script. Run the install and ausearch, then only the fix that matches the denial you actually saw. setsebool -P httpd_can_network_connect on in particular gives every httpd process permanent outbound network access, so leave it alone unless a denial tells you to set it. Note the -m on the port line. With -a the command stops at ValueError: Port tcp/8080 already defined, because the targeted policy already types 8080 as http_cache_port_t, so the type has to be modified rather than added. Ports 8008, 8009, 8443 and 9000 are already http_port_t and need no command at all. The semanage tool moved package between releases: it was policycoreutils-python on EL7 and is policycoreutils-python-utils on EL8 and EL9. After a large rsync, relabel the copied data with restorecon -R, or force a full relabel with touch /.autorelabel and a reboot. If you are stuck mid migration, setenforce 0 puts SELinux in permissive mode so you can collect every denial in one pass, then fix them and switch back. Do not reach for a permanent disable: on AlmaLinux 9, setting SELINUX=disabled in /etc/selinux/config no longer disables it properly, the kernel boots with SELinux on and no policy loaded, and the supported way is a kernel argument via grubby --update-kernel ALL --args selinux=0. That is a decision you should have to justify.
Testing before anyone else sees it
Point your own workstation at the new IP with a hosts file entry and use the application properly: log in, upload a file, run a checkout, trigger the emails, run each cron job by hand as the user that owns it. Watch the logs while you do it:
journalctl -u php-fpm -u httpd -p warning --since "15 min ago"
tail -f /var/log/php-fpm/www-error.log
Two things get forgotten every time. Outbound mail depends on the new IP: check the PTR record, SPF and DKIM before cutover, not after the first bounce. And any partner that allowlists your IP, a payment provider, a bank, a supplier API, needs the new address added days in advance. Also enable the slow query log for the first days, since the optimiser in MariaDB 10.11 does not always choose the same plan as MySQL 5.7 and a query that was fine can turn slow under real traffic.
The cutover
Lower the DNS TTL first, and do it early. If the record currently has a 24 hour TTL, changing it today does not help tomorrow, because resolvers hold the old value for up to the old TTL. Drop it to 300 seconds, wait out the previous TTL, then schedule the switch.
On the day: put the application into maintenance mode, run the final rsync with rsync -aHAX --numeric-ids --delete, take the final database dump, import, relabel, start services, verify on the new box through your hosts entry, then change the DNS record. Keep the old server running and watch both access logs until the old one goes quiet, which usually takes as long as the TTL plus a tail of badly behaved clients. Raise the TTL back after a day or two.
Keep the old server for the rollback window
Do not cancel the old machine the same week. Keep it for a week or two, but stop its application and set its database read only, otherwise a client stuck on a cached DNS answer writes an order into a database nobody will ever look at again. That split brain is the real risk after a cutover, not the migration itself. Be honest about the rollback window: it is clean for the first hours, and after real writes have landed on the new server, going back means merging data by hand.
Route two: in place conversion with ELevate
Sometimes rebuilding is not on the table: a licence tied to hardware, an appliance, a machine in someone else's care with a decade of undocumented local scripts. ELevate, AlmaLinux's leapp based tooling, can convert in place. Two facts up front. There is no direct CentOS 7 to AlmaLinux 9 path. You go CentOS 7 to AlmaLinux 8, then AlmaLinux 8 to AlmaLinux 9, which is two upgrades, two reboot windows and a period on an intermediate release. And ELevate upgrades the operating system, not your application: PHP, the database and the crypto work above still have to be done, only now on a live server.
Start by pointing yum at the vault, as shown above. On this route it is not optional: on an untouched CentOS 7 machine mirrorlist.centos.org answers 404, yum update stops with Cannot find a valid baseurl for repo: base/7/x86_64, and leapp inhibits the upgrade on a system that is not fully updated. Take a snapshot or a full backup before the first command and have out of band console access open, because the very first line updates a machine nobody has updated in years and then reboots it. With the vault repository in place:
yum update -y && reboot
yum install -y https://repo.almalinux.org/elevate/elevate-release-latest-el$(rpm --eval %rhel).noarch.rpm
yum install -y leapp-upgrade leapp-data-almalinux
leapp preupgrade
Read /var/log/leapp/leapp-report.txt in full and clear every inhibitor. Typical ones are third party repositories with no mapping, a modified sshd_config that blocks root login during the upgrade phase, and unsupported kernel modules. The report also ends with at least one question you have to answer explicitly, on CentOS 7 almost always the pam_pkcs11 one, and leapp upgrade refuses to start until you do. Answer it, do not just paste it: confirm=True tells leapp you accept that the pam_pkcs11 module goes away, which is right on an ordinary server and wrong on one where people log in with smart cards. After the report is clean:
leapp answer --section remove_pam_pkcs11_module_check.confirm=True
leapp upgrade
reboot
The reboot boots a special upgrade initramfs and takes a long time. This is where the console access earns its keep: if the boot stops there, SSH is not coming back, and the snapshot you took is the only undo you have. Then repeat the sequence on AlmaLinux 8 to reach 9, starting from the elevate-release install and with dnf instead of yum: the vault switch is a CentOS 7 step and does not apply there. Afterwards, clean up what was left behind:
rpm -qa | grep -E 'el7|el8'
find /etc -name '*.rpmnew' -o -name '*.rpmsave'
systemctl list-units --state=failed
Where ELevate genuinely does not fit: control panel servers, which have their own vendor specific migration tooling, boxes with exotic storage, and anything where you cannot get a console. For a machine in colocation that you cannot duplicate, this route is reasonable. For a public web server, building fresh is faster and safer almost every time.
The week after
Check that backups actually ran on the new server and that a restore works, not just that the job exited zero. Confirm logrotate is handling the new log paths, that the monitoring agent reports, and that unattended security updates are configured with dnf-automatic. Reboots matter again: a kernel or glibc update needs one, and dnf needs-restarting -r tells you when. If something behaves oddly after the move, our engineers are available around the clock, and the old box is still there for another week to compare against.