Getting PHP 8.5 running on RHEL-based systems takes a different path than Debian/Ubuntu. AppStream only carries older PHP branches as separately named packages, and RHEL 10 has no PHP module streams of its own, so the version switching in this guide comes from Remi’s modular repository. The Remi repository is your best bet here, because it’s been the go-to PHP source for RHEL/CentOS since the PHP 5.x days, and the packages are built with the same spec files Red Hat uses internally.
This guide covers installing PHP 8.5 on Rocky Linux 10, AlmaLinux 10, and RHEL 10 using the Remi repository, setting up PHP-FPM with Nginx, production tuning, the SELinux booleans that catch people off guard on RHEL systems, and how to confirm your build is patched against PHP2Shell. In October 2026 we re-ran the install, Nginx, firewalld and upgrade steps on Rocky Linux 10 with Remi’s PHP 8.5.12, and the stream switch on AlmaLinux 10. SELinux was off in that run, so we didn’t re-test the RHEL subscription or SELinux steps. The JIT warning in Step 6 comes from a separate Rocky Linux 10.2 VM with SELinux enforcing.
Prerequisites
- Rocky Linux 10, AlmaLinux 10, or RHEL 10 with sudo access
- EPEL repository (we’ll install it below)
- At least 512 MB RAM
Step 1: Install EPEL and Remi Repository
On Rocky Linux and AlmaLinux, epel-release comes from the distro’s own extras repo. EPEL provides dependencies that Remi packages need, and EPEL’s own docs recommend enabling CRB next to it, so turn that on as well:
sudo dnf install -y epel-release
sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-10.rpm
sudo dnf config-manager --set-enabled crb
RHEL 10 has no epel-release package in its own repos, so the block above fails there. On RHEL 10, run this block instead of the one above, which enables CodeReady Builder through subscription-manager and pulls EPEL straight from Fedora:
sudo subscription-manager repos --enable codeready-builder-for-rhel-10-$(arch)-rpms
sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm
sudo dnf install -y https://rpms.remirepo.net/enterprise/remi-release-10.rpm
The Remi release package adds two repositories, remi-modular (the PHP module streams) and remi-safe (extra extensions that don’t replace base packages). dnf repolist should list both next to epel and crb.
Step 2: Reset PHP Module and Enable 8.5
RHEL 10 itself ships no modular content. AppStream carries PHP as separately named packages, php and, since RHEL 10.2, php8.4, while the php:remi-8.5 stream comes from Remi’s remi-modular repo, which dnf still drives with its module commands. Reset the module and enable the 8.5 stream:
sudo dnf module reset php -y
sudo dnf module enable php:remi-8.5 -y
Verify the stream is active:
dnf module list php
Look for the remi-8.5 row:
Name Stream Profiles Summary
php remi-8.5 [e] common [d], devel, minimal PHP scripting language
The [e] flag confirms the remi-8.5 stream is enabled.
Step 3: Install PHP 8.5 with Extensions
Install PHP, PHP-FPM and the extensions most web apps (WordPress, Laravel, Nextcloud) expect:
sudo dnf install -y php php-fpm php-cli php-common \
php-mysqlnd php-pgsql php-curl php-gd php-mbstring \
php-xml php-zip php-intl php-redis \
php-bcmath php-soap php-ldap php-imagick
OPcache isn’t a separate package on PHP 8.5 any more. It’s built into PHP itself, so Remi’s php-common provides php-opcache, and older install lines that still name php-opcache just resolve to php-common. The php-mysqlnd package is the native MySQL driver, so use it instead of php-mysql.
Step 4: Verify the Installation
Check the CLI version first:
php --version
The banner names the Remi build and shows OPcache loaded:
PHP 8.5.12 (cli) (built: Oct 19 2026 17:02:46) (NTS gcc x86_64)
Copyright (c) The PHP Group
Built by Remi's RPM repository <https://rpms.remirepo.net/> #StandWithUkraine #NoAI
Zend Engine v4.5.12, Copyright (c) Zend Technologies
with Zend OPcache v8.5.12, Copyright (c), by Zend Technologies
Check loaded modules:
php -m | grep -c '^\w'
With the extension list above, both Rocky Linux 10 and AlmaLinux 10 report the same count:
58
A much lower number usually means the install line was cut short or a package failed to resolve, so scroll back through the dnf output before moving on.
Step 5: Enable and Start PHP-FPM
Start the FastCGI process manager and enable it at boot:
sudo systemctl enable --now php-fpm
Verify:
sudo systemctl status php-fpm
The unit should be enabled and active:
● php-fpm.service - The PHP FastCGI Process Manager
Loaded: loaded (/usr/lib/systemd/system/php-fpm.service; enabled; preset: disabled)
Active: active (running) since Thu 2026-10-22 15:29:42 UTC; 67ms ago
Invocation: 09647290a91d4a78aa1bc8d0c94880a9
Main PID: 650 (php-fpm)
Status: "Ready to handle connections"
Tasks: 6 (limit: 6425)
Important RHEL difference: The FPM service is called php-fpm (not php8.5-fpm like on Debian). The socket path is also different:
ls -la /run/php-fpm/www.sock
Note the + after the permissions:
srw-rw----+ 1 root root 0 Oct 22 15:29 /run/php-fpm/www.sock
That + is a POSIX ACL. The stock pool ships listen.acl_users = apache,nginx, so both the apache and nginx users can already talk to the socket even though it’s owned by root. This matters for Nginx integration, because it means you don’t have to change the pool user or the socket owner to get Nginx working.
Step 6: Configure php.ini
On RHEL, php.ini lives at /etc/php.ini (shared between CLI and FPM, unlike Debian):
sudo vim /etc/php.ini
Update these settings:
memory_limit = 256M
max_execution_time = 60
upload_max_filesize = 64M
post_max_size = 64M
date.timezone = UTC
; OPcache
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
With opcache.validate_timestamps = 0, PHP never rereads changed files, so restart PHP-FPM after every code deploy as well as now:
sudo systemctl restart php-fpm
Load order matters here. PHP reads /etc/php.ini first and every file under /etc/php.d/ after it, so a key set in both places ends up with the php.d value. The stock 10-opcache.ini only sets opcache.enable, opcache.enable_cli, the blacklist path and opcache.huge_code_pages, so nothing above gets overridden. Step 11 confirms the values through PHP-FPM itself once Nginx is serving pages.
Leave the OPcache JIT at its default, which is off. On a Rocky Linux 10.2 server with SELinux enforcing, setting opcache.jit = tracing made php-fpm workers segfault on an execmem denial, and Nginx returned 502 after a few requests. The JIT only runs there after sudo setsebool -P httpd_execmem 1, which lets every process in the web server’s SELinux domain map writable executable memory, so enable it only if you’ve measured a real gain for your workload.
If you followed an earlier version of this guide, your /etc/php.ini still carries the two opcache.jit lines. Delete them and restart PHP-FPM:
sudo sed -i '/^opcache\.jit/d' /etc/php.ini
sudo systemctl restart php-fpm
The Step 11 probe then reports opcache.jit = disable instead of tracing.
Step 7: Tune the PHP-FPM Pool for Nginx
Leave the pool running as apache. Older guides, this one included, used to switch it to user = nginx, but that breaks PHP sessions: /var/lib/php/session, /var/lib/php/opcache and /var/lib/php/wsdlcache are group apache with mode 770. In our lab a session_start() test page failed as soon as the pool ran as nginx, and a chgrp workaround was undone by the next PHP package transaction, which put the directories back to root:apache. Nginx doesn’t need the pool user changed anyway, because the socket ACL from Step 5 already lets it in. So only tune the process manager:
sudo vim /etc/php-fpm.d/www.conf
Update the pm settings and leave user, group and listen.acl_users as shipped. These values suit a 2 GB server:
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 10
pm.max_requests = 500
Restart PHP-FPM:
sudo systemctl restart php-fpm
Size pm.max_children from memory, not from this example: divide the RAM you can give PHP by the resident size of one busy worker (check with ps -o rss -C php-fpm), and leave room for the database if it shares the box.
Step 8: Install and Configure Nginx
Install Nginx from AppStream and start it:
sudo dnf install -y nginx
sudo systemctl enable --now nginx
Create a PHP-enabled server block:
sudo vim /etc/nginx/conf.d/default.conf
Add the following configuration:
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\. { deny all; }
}
Test the configuration and restart Nginx:
sudo nginx -t && sudo systemctl restart nginx
Expect two warnings, one per address family, on top of the two success lines:
nginx: [warn] conflicting server name "_" on 0.0.0.0:80, ignored
nginx: [warn] conflicting server name "_" on [::]:80, ignored
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
The warnings are harmless. The stock /etc/nginx/nginx.conf also declares a server_name _ block on port 80 for IPv4 and IPv6, but it includes conf.d/*.conf first, so your block above answers the requests. Once you set a real server_name the warnings go away. Drop the listen [::]:80; line if the server has no IPv6 address.
Step 9: Open Firewall Ports
RHEL uses firewalld by default, but minimal and cloud images often leave it out. If firewall-cmd is missing, watch your SSH port before you turn it on. The default public zone only lets in ssh on port 22, plus cockpit and dhcpv6-client, so a server with sshd on another port locks you out the moment firewalld starts. Install it and add the rules with firewall-offline-cmd while it’s still stopped, then start it. Replace 2222 with your SSH port, or drop that line if you use 22:
sudo dnf install -y firewalld
sudo firewall-offline-cmd --add-port=2222/tcp
sudo firewall-offline-cmd --add-service=http --add-service=https
sudo systemctl enable --now firewalld
Rules added offline are already permanent, so you can skip the next block. On a server where firewalld was already running, open HTTP and HTTPS:
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-services should now include http and https. On a cloud server the provider’s security group or network firewall filters traffic too, so open ports 80 and 443 there as well.
Step 10: Handle SELinux
This is where most guides leave you stranded. SELinux is enforcing by default on RHEL, Rocky, and AlmaLinux. If you skip this, PHP-FPM fails to connect to databases, send email or write outside the web root. The app usually reports a permission denied error, and the real cause sits in the audit log as an AVC denial.
PHP-FPM runs in the same SELinux domain as the web server, so the httpd_* booleans apply to it. httpd_can_network_connect lets it open outbound connections on any port, which covers MySQL, PostgreSQL, Redis and API calls. httpd_can_sendmail lets mail() hand messages to the local MTA. If the app only ever talks to a database, the narrower httpd_can_network_connect_db is enough on its own. These commands weren’t part of our October 2026 re-test, so confirm each one with getsebool after setting it:
sudo setsebool -P httpd_can_network_connect 1
sudo setsebool -P httpd_can_sendmail 1
Older guides also turn on httpd_unified so uploads work. Skip it: it lets the web server write to every file labelled as web content, including your PHP code, which removes the very separation SELinux gives you. Label only the directories the app writes to.
If an earlier version of this guide had you turn it on, switch it back off:
sudo setsebool -P httpd_unified 0
If your app writes to a custom directory outside /usr/share/nginx/html, set the SELinux context:
sudo semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/myapp/storage(/.*)?"
sudo restorecon -Rv /var/www/myapp/storage
The fcontext rule survives a relabel, unlike a plain chcon, so it’s the one to use for anything permanent.
Step 11: Test
Drop a phpinfo page into the web root and fetch it through Nginx:
echo '<?php phpinfo(); ?>' | sudo tee /usr/share/nginx/html/info.php > /dev/null
curl -s http://localhost/info.php | grep -oE 'PHP Version [0-9.]+' | head -1
The version line comes back from PHP-FPM, not the CLI:
PHP Version 8.5.12
Next, confirm PHP-FPM picked up the Step 6 values. php -i reads the same /etc/php.ini but only proves what the CLI sees, so ask the pool through Nginx instead:
echo '<?php foreach (["memory_limit","opcache.memory_consumption","opcache.jit"] as $k) echo $k, " = ", ini_get($k), PHP_EOL;' | sudo tee /usr/share/nginx/html/ini.php > /dev/null
curl -s http://localhost/ini.php
sudo rm /usr/share/nginx/html/ini.php
curl prints the values the running workers use. The first two should match what you set, and the JIT should read disable:
memory_limit = 256M
opcache.memory_consumption = 256
opcache.jit = disable
A value that still shows the default means an ini file under /etc/php.d/ sets that key too, or php-fpm wasn’t restarted after the edit. Open http://10.0.1.50/info.php in your browser (use your server’s address) to see the full page. phpinfo exposes paths and settings, so remove it after testing:
sudo rm /usr/share/nginx/html/info.php
A 502 at this point means Nginx can’t reach the socket; the Troubleshooting section below covers it.
Check the Build for PHP2Shell (CVE-2026-109618, CVE-2026-109619)
PHP2Shell is the name for CVE-2026-109618 and CVE-2026-109619, a pair of critical remote code execution bugs in PHP itself. PHP rated them CVSS 9.2 in its 20 October pre-announcement and fixed them upstream on 21 October 2026 in 8.5.12, 8.4.27, 8.3.36 and 8.2.35, per the PHP security advisory. The advisory itself told Remi users to switch to distribution packages, because Remi had no update on release day. Remi published 8.5.12 to the php:remi-8.5 stream on 22 October, so a fresh install from this guide is now patched, while a server you built before that still runs 8.5.11 or older until you upgrade it.
If you’d rather follow the distro, its AppStream builds are fixed too. Rocky Linux 10.2 ships php 8.3.36 and php8.4 8.4.27, and AlmaLinux 10.2 ships php 8.3.36 and a backported php8.4 8.4.24. Per the AlmaLinux PHP2Shell advisory, that build is fixed when its release carries .alma.1 or later, as in 8.4.24-1.el10_2.alma.1, so on Alma check the release string and not just the version.
On Remi’s supported streams (remi-8.2 to remi-8.5) the version is the check. The package changelog only says “Update to 8.5.12” and never names the CVE IDs, so grepping it for them prints nothing even on a fixed build. Ask rpm instead:
rpm -q php-common
This is a Rocky Linux 10 server still on 8.5.11. Anything below 8.5.12 is exposed, and 8.5.12 or later is fixed:
php-common-8.5.11-1.module_php.8.5.el10.remi.x86_64
Upgrade every PHP package from the stream in one transaction, then restart PHP-FPM, because a running FPM master keeps serving the old code from memory until it restarts. On our Rocky Linux 10 test, Remi’s php-fpm package did restart the service during the upgrade, through a file trigger on /etc/php.d and /etc/php-fpm.d that runs systemctl try-restart. That’s packaging behaviour you shouldn’t depend on, so restart it yourself, along with any long-running PHP CLI workers such as queue consumers under supervisor:
sudo dnf upgrade -y "php*"
sudo systemctl restart php-fpm
Confirm the running FPM master started after the upgrade finished by comparing its start time with the end of the last dnf transaction:
sudo dnf history info last | grep -E '^End time'
systemctl show -p ActiveEnterTimestamp php-fpm
The ActiveEnterTimestamp has to be at or after the End time. Ours is 16:16:01 against an End time of 16:15:59, so the workers run the new code. The “(2 seconds)” on the End line is how long the transaction took, not the gap:
End time : Thu Oct 22 16:15:59 2026 (2 seconds)
ActiveEnterTimestamp=Thu 2026-10-22 16:16:01 UTC
Then check what the web server runs, since php -v only proves the CLI. A one-line page through Nginx answers that:
echo '<?php echo PHP_VERSION, " ", PHP_SAPI, PHP_EOL;' | sudo tee /usr/share/nginx/html/v.php > /dev/null
curl -s http://localhost/v.php
sudo rm /usr/share/nginx/html/v.php
After the upgrade and restart, FPM reports the fixed release:
8.5.12 fpm-fcgi
Remi ships the new upstream release rather than a backport, so PHP_VERSION moves with the fix and this page is a direct check of what FPM runs. The other supported Remi streams are fixed too: remi-8.4 ships 8.4.27, remi-8.3 ships 8.3.36 and remi-8.2 ships 8.2.35, so switching between them as shown next doesn’t reopen the hole. The end-of-life remi-8.1, remi-8.0 and remi-7.4 streams keep their old version numbers with a backported fix, so check those with rpm -q --changelog php-common | grep CVE-2026-109618, which prints a line only on a fixed build.
Switch PHP Versions with DNF Module
To switch from PHP 8.5 to another version later:
# Reset current module
sudo dnf module reset php -y
# Switch to a different version
sudo dnf module enable php:remi-8.4 -y
# Reinstall (distro-sync ensures all packages match the new stream)
sudo dnf distro-sync -y
# Restart FPM
sudo systemctl restart php-fpm
On AlmaLinux 10 that sequence moved every PHP package down to the 8.4 stream in a single transaction, and php -v then reported 8.4.27.
RHEL vs Debian: Key PHP Differences
| Detail | RHEL/Rocky/Alma | Debian/Ubuntu |
|---|---|---|
| Package manager | dnf + Remi module streams | apt + packages.sury.org (Debian) or the Ondrej PPA (Ubuntu) |
| FPM service name | php-fpm | php8.5-fpm |
| Socket path | /run/php-fpm/www.sock | /run/php/php8.5-fpm.sock |
| Default FPM user | apache | www-data |
| php.ini location | /etc/php.ini (shared) | /etc/php/8.5/fpm/php.ini |
| FPM pool config | /etc/php-fpm.d/www.conf | /etc/php/8.5/fpm/pool.d/www.conf |
| SELinux | Enforcing (needs booleans) | Not installed |
| Firewall | firewall-cmd | ufw or nftables |
| Web doc root | /usr/share/nginx/html | /var/www/html |
Troubleshooting
DNF module conflict when installing:
If you see “Nothing to do” or “module stream conflict”, reset the module first:
sudo dnf module reset php -y
sudo dnf module enable php:remi-8.5 -y
sudo dnf distro-sync -y
PHP-FPM starts but Nginx returns 502:
Nginx runs as nginx and reaches the socket only through the pool’s ACL. Check that the line is still there and still names nginx:
grep acl_users /etc/php-fpm.d/www.conf
The stock pool prints this:
listen.acl_users = apache,nginx
If the line is commented out or missing nginx, add it back and restart php-fpm. While the ACL is set, php-fpm ignores listen.owner and listen.group and logs a warning saying so, which is why changing those two settings never fixes a 502 here.
PHP can’t connect to MySQL/PostgreSQL:
Check the audit log first. If ausearch shows an AVC denial for php-fpm, SELinux is the cause and the boolean fixes it; if it shows nothing, look at credentials and the database’s own access rules instead:
sudo ausearch -m avc -ts recent | grep php
sudo setsebool -P httpd_can_network_connect_db 1
OPcache not enabled:
On PHP 8.5 there’s no package to install, because OPcache is compiled in. Confirm it’s loaded:
php -m | grep -i opcache
It prints Zend OPcache twice (once as a PHP module, once as a Zend extension). If it’s loaded but still off for web requests, look for opcache.enable = 0 in /etc/php.ini or /etc/php.d/10-opcache.ini and restart php-fpm.
Conclusion
PHP 8.5 on RHEL-based systems works well once you handle the three things that differ from Debian: Remi’s DNF module streams for version management, the FPM socket ACL for Nginx, and SELinux booleans for database and network access. Skip any of those and you’ll spend an hour debugging silent failures. Get them right upfront, keep rpm -q php-common at or above the latest fixed release, and everything runs smoothly.
Related guides: