All systems operational Your IP: 216.73.216.190 info@cloudhosting.lv +371 66 66 29 69 Client area

← All questions

LAMP on AlmaLinux 9: Apache, PHP 8.3 and MariaDB

The old habit of typing yum install httpd php mysql and getting a usable web server died with CentOS. On AlmaLinux 9 the packages are still there, but PHP sits behind module streams, MariaDB replaced MySQL, mod_php is out of the picture and SELinux is enforcing from the first boot. This guide walks through a working Apache, PHP 8.3 and MariaDB stack on a clean AlmaLinux 9 machine, including the parts that usually cost an afternoon. Everything below is identical on Rocky Linux 9 and RHEL 9, because all three are the same package set with different branding.

Why AlmaLinux 9 and not CentOS

CentOS 8 was discontinued at the end of 2021, years before the lifecycle people had planned around, and CentOS 7 reached end of life on 30 June 2024. Neither receives security errata now. CentOS Stream still exists, but it sits ahead of RHEL rather than behind it, which is useful for testing and wrong for a machine you want to leave quiet for five years.

AlmaLinux 9 is a binary compatible rebuild of RHEL 9 with a lifecycle running to 2032. Package names, file paths, systemd unit names and the SELinux policy match RHEL 9 exactly, so any vendor document written for RHEL 9 applies without translation. There is no supported in place jump from 7 to 9. ELevate can take a machine from 7 to 8 and then 8 to 9, and it does work, but on virtual hardware it is usually faster and far safer to build a clean 9, move the data across and cut over DNS. If the box is a rented VPS, keep the old one running while you test the new one and you have a free rollback.

Where PHP 8.3 comes from

A plain dnf install php on AlmaLinux 9 does not go through a module at all: PHP 8.0 ships as ordinary non modular packages in AppStream. AlmaLinux keeps backporting security fixes into them, so that 8.0 is not unpatched, but it stays 8.0 for the life of the release, while upstream stopped issuing 8.0 security releases in November 2023 and the libraries you want to run have moved on. Newer versions arrive as AppStream module streams added over the 9.x minor releases, and none of them is marked as a default, so nothing switches on by itself. Check what your machine actually offers rather than assuming:

dnf module list php
dnf module list mariadb

Depending on the minor release you will see 8.1, 8.2 and 8.3 in that list, with no [d] marker on any of them. If 8.3 is there and you would rather stay on distribution packages only, skip the EPEL and Remi step below and enable php:8.3 where the next block enables php:remi-8.3; nothing else in this guide changes. This guide takes the Remi route, the long running third party build for Enterprise Linux, because point releases land there within days and 8.4 is already sitting in the same repository for when you move on. Remi needs EPEL, and EPEL on AlmaLinux 9 needs the CRB repository enabled:

dnf -y upgrade
dnf -y install dnf-plugins-core
dnf config-manager --set-enabled crb
dnf -y install epel-release
dnf -y install https://rpms.remirepo.net/enterprise/remi-release-9.rpm

Now reset the stream before installing anything. On a machine where no stream was ever enabled the reset does nothing, and dnf will not silently swap a stream out from under installed packages, which is exactly why people get stuck here:

dnf -y module reset php
dnf -y module enable php:remi-8.3
dnf -y install php php-fpm php-cli php-mysqlnd php-opcache php-gd php-mbstring php-xml php-intl php-zip php-process
php -v

Two things to know. If the distro PHP was already installed, run dnf distro-sync after enabling the Remi stream so the whole set moves together, otherwise you end up with a base php-common and a Remi php-mysqlnd arguing about dependencies. And Remi also publishes parallel php83-php-* packages that install under /opt/remi and coexist with other versions. Those are for build servers that need several PHP versions at once. For a single site machine the module stream is simpler and puts the binary at /usr/bin/php where everything expects it.

Two package names trip people coming from CentOS 7. JSON has been compiled into php-common since PHP 8.0, so there is no php-json package to chase. And php-mysql has not existed for years, the driver is php-mysqlnd.

Apache, MariaDB and php-fpm

dnf -y install httpd mariadb-server
systemctl enable --now httpd php-fpm mariadb

The php-fpm package drops /etc/httpd/conf.d/php.conf, and that one file is what tells Apache to hand .php and .phar requests to the FPM socket at /run/php-fpm/www.sock through mod_proxy_fcgi. The same file carries the .user.ini protection, the AddType line and DirectoryIndex, so php.conf is the only Apache config PHP puts on the box. There is no /etc/httpd/conf.d/php-fpm.conf on EL9, whatever older CentOS notes say; the php-fpm.conf that does exist is a systemd drop-in under /usr/lib/systemd/system/httpd.service.d/ (Remi puts its copy in /etc/systemd/system/) and all it does is make httpd want the php-fpm service. Run rpm -qf /etc/httpd/conf.d/php.conf to see which package owns the file on your machine; with Remi both php and php-fpm list it. The SetHandler line sits inside an IfModule block that applies when no mod_php is loaded, which is the normal case on EL9, where the distribution ships no mod_php at all. There is no mod_php here and you should not want one. With FPM, Apache proxies the request and PHP runs in its own process pool as its own user, which is what makes per site isolation possible at all. Look at /etc/httpd/conf.modules.d/00-mpm.conf to see which MPM is enabled and leave it alone unless you have a specific reason to change it.

Securing MariaDB

AlmaLinux 9 ships MariaDB 10.5 by default, with newer streams available on recent minor releases. From 10.5 onwards the binaries were renamed, so the current command is mariadb-secure-installation and mysql_secure_installation is a compatibility symlink to the same script:

mariadb-secure-installation

It will offer to switch the root account to unix_socket authentication, remove anonymous users, disallow remote root login and drop the test database. Say yes to all of those on a fresh install. On MariaDB 10.4 and newer, root is normally already set up with unix_socket, which means mariadb -u root works for the OS root user with no password at all and fails for anyone else. That surprises people who go looking for a password they never set. Confirm what is actually configured:

mariadb -u root -e "SELECT User, Host, plugin FROM mysql.user;"

Then create the application database and a user that is not root. Unlike every other block here these are SQL statements, so feed them to the client instead of pasting them into the shell:

mariadb -u root <<'SQL'
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'a-long-random-string';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
SQL

Check what the server is listening on. Without an explicit bind-address MariaDB will accept connections on every interface, and a database reachable from the internet is a database that will be found:

ss -tlnp | grep 3306

If the application runs on the same host, put bind-address = 127.0.0.1 under [mysqld] in /etc/my.cnf.d/mariadb-server.cnf and restart. Do not open port 3306 in the firewall to solve a connection problem, fix the connection instead.

firewalld

firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
firewall-cmd --list-all

If firewall-cmd is missing, the image was built without firewalld. Either install it or make sure filtering happens at the network layer instead, but do not assume a minimal cloud image is filtered by default.

SELinux, the part that eats the afternoon

Leave SELinux enforcing. Almost every LAMP problem on EL9 that looks inexplicable is a labelling issue with a one line fix. Install the tools first, because a minimal install does not have semanage:

dnf -y install policycoreutils-python-utils setroubleshoot-server
getsebool httpd_can_network_connect httpd_can_network_connect_db

Both are off by default. The first one blocks outbound network connections from the web stack, so PHP calling a payment API, an SMTP relay or a remote HTTP service fails with a connection refused that has nothing to do with the network. The second covers connections to a database on another host. These are two independent switches and the block below lists both: run only the line your application actually needs, because every boolean you turn on widens what a compromised site can reach:

setsebool -P httpd_can_network_connect on
setsebool -P httpd_can_network_connect_db on

Note that php-fpm runs in the same httpd_t domain as Apache on EL, so these booleans govern your PHP code, not just the web server.

File contexts are the other half. Anything under /var/www inherits httpd_sys_content_t automatically, which is why keeping content there saves work. Content that PHP must write to needs httpd_sys_rw_content_t, and it should be a specific directory, never the whole document root. A rule labels nothing until the path exists, so create the tree first, the next section gives it its owner:

install -d /var/www/example.com/public/uploads
semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/public/uploads(/.*)?"
restorecon -Rv /var/www/example.com
ls -Zd /var/www/example.com/public /var/www/example.com/public/uploads

The classic trap: mv keeps the original label, cp inherits the destination label. Move a site from /root or /home into /var/www with mv and every file keeps a type Apache cannot read, and you get a 403 with a permissions error in the log even though ls -l looks perfect. Restoring an old tar with extended attributes does the same thing. Run restorecon -Rv after any bulk move.

If you run Apache on a non standard port, the port needs a label too:

semanage port -a -t http_port_t -p tcp 8090

Pick a port the shipped policy does not already know. 8080 is a bad example: it is defined as http_cache_port_t out of the box, so -a stops with a duplicate port error, and Apache is allowed to bind that type anyway, so there was nothing to change. When a port already has a type and you want to move it, the flag is -m rather than -a. semanage port -l | grep 8090 tells you which case you are in.

When something is denied, read the denial instead of guessing:

ausearch -m AVC,USER_AVC -ts recent
sealert -a /var/log/audit/audit.log

Never solve this with setenforce 0. It hides the problem until reboot, then the problem comes back at the worst moment.

Giving each site its own PHP user

The default pool runs as the apache user, which means every site on the box can read every other site's files and configuration. Give each site a system account and its own pool. Create the user and the directories first:

useradd -r -M -s /sbin/nologin -d /var/www/example.com example
install -d -o root -g root -m 0755 /var/www/example.com
install -d -o example -g example -m 0755 /var/www/example.com/public
install -d -o example -g example -m 0755 /var/www/example.com/public/uploads
install -d -o example -g example -m 0700 /var/www/example.com/sessions
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/sessions(/.*)?"
restorecon -Rv /var/www/example.com

Then write /etc/php-fpm.d/example.conf:

[example]
user = example
group = example
listen = /run/php-fpm/example.sock
listen.mode = 0660
listen.acl_users = apache
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm/example-error.log
php_admin_value[open_basedir] = /var/www/example.com/:/tmp/
php_value[session.save_path] = /var/www/example.com/sessions
systemctl restart php-fpm
ls -l /run/php-fpm/

The session directory matters more than it looks. The packaged /var/lib/php/session is owned by root:apache with mode 0770, so a pool running as a different user cannot even enter it, and a subdirectory underneath does not help: the parent still blocks the pool user. The path also has to sit inside open_basedir, otherwise session_start() answers with an open_basedir restriction and the session goes nowhere. A directory under the site root settles both at once, the pool user owns it and open_basedir already covers it, and because it sits next to public rather than inside it, Apache never serves what is in there. listen.acl_users is what lets Apache talk to a socket owned by root. The FPM master runs as root, so the socket comes out root owned without being told to, and listen.owner or listen.group set next to an ACL earn you nothing but an is ignored notice in the log at every start. If the filesystem has no ACL support, drop the acl line and set listen.group = apache instead, keeping mode 0660.

Size pm.max_children against real memory: measure the RSS of a busy worker and divide the RAM you are willing to spend by that number. Twenty children of 80 MB each is 1.6 GB before the database gets anything. On a small VPS keep it low and let requests queue, on a busy site a dedicated server gives you the headroom to raise it.

A minimal virtual host

Enterprise Linux has no sites-available and sites-enabled. Anything ending in .conf in /etc/httpd/conf.d is read at start. Create /etc/httpd/conf.d/example.com.conf:

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    DocumentRoot /var/www/example.com/public

    <Directory /var/www/example.com/public>
        AllowOverride All
        Require all granted
    </Directory>

    <FilesMatch \.(php|phar)$>
        SetHandler "proxy:unix:/run/php-fpm/example.sock|fcgi://localhost"
    </FilesMatch>

    ErrorLog /var/log/httpd/example.com-error.log
    CustomLog /var/log/httpd/example.com-access.log combined
</VirtualHost>

The FilesMatch block inside the virtual host overrides the global one in the packaged php.conf, which points at www.sock. Without it your site runs on the shared default pool as the apache user and all the isolation above does nothing. Match the same extensions the packaged block matches, .php and .phar: narrow it to .php alone and a request for a .phar file still falls through to the global block and executes on the shared pool as apache, which is the hole you built the pool to close. The AlmaLinux welcome page comes from /etc/httpd/conf.d/welcome.conf; leave it or empty it, but do not delete the file, a package update will put it back.

Verifying the whole stack

httpd -t
php-fpm -t
systemctl restart httpd php-fpm
systemctl is-enabled httpd php-fpm mariadb
curl -sI -H "Host: example.com" http://127.0.0.1/

A warning from httpd -t about being unable to determine the server's fully qualified domain name is harmless, set ServerName globally if it annoys you. Now prove PHP actually executes through the right pool:

printf '%s\n' '<?php echo PHP_VERSION, " as ", posix_getpwuid(posix_geteuid())["name"], "\n";' > /var/www/example.com/public/v.php
curl -s -H "Host: example.com" http://127.0.0.1/v.php
rm -f /var/www/example.com/public/v.php

That should print the version and the pool user, not apache. The posix functions live in php-process, which is why that package is on the install line above, without it the page dies on an undefined function instead of answering. Delete the file straight after. Do not leave a phpinfo page on a public server, it hands an attacker your paths, extensions and configuration.

Test the database through the web server, not from the shell. A php -r one liner run as root executes unconfined and will happily connect while the same code fails under httpd_t, which sends people looking for a PHP bug that does not exist. Do this only after the check above returned the pool user: a document root that is not executing PHP yet hands the file out as plain text, password included:

printf '%s\n' '<?php $d = new PDO("mysql:host=localhost;dbname=appdb;charset=utf8mb4", "appuser", "a-long-random-string"); echo $d->query("SELECT VERSION()")->fetchColumn();' > /var/www/example.com/public/db.php
curl -s -H "Host: example.com" http://127.0.0.1/db.php
rm -f /var/www/example.com/public/db.php

Logs and the failures you will actually hit

tail -f /var/log/httpd/example.com-error.log
tail -f /var/log/php-fpm/example-error.log
tail -f /var/log/mariadb/mariadb.log
journalctl -xeu php-fpm
ausearch -m AVC -ts recent

A 503 from Apache with a connection refused or permission denied line means the FPM socket is missing or unreadable. Check that php-fpm started, that /run/php-fpm/example.sock exists and that listen.acl_users names apache. Remember /run is tmpfs, so the socket is recreated on every start and hand crafting one there is pointless.

If the browser downloads the .php file instead of running it, the handler never matched: either mod_proxy_fcgi is not loaded or your FilesMatch is outside the virtual host. A 403 on files you can read as root is almost always the SELinux label. A completely blank page with a 200 status is a PHP fatal error with display_errors off, and it will be sitting in the pool error log. Sessions that reset on every request are the session directory again: either the pool user cannot write there or the path falls outside open_basedir.

Once the stack answers on port 80, add TLS before anything real goes on it: install mod_ssl, open the https service in firewalld, and issue a certificate. If you would rather someone else keeps the packages patched and the labels right, our engineers are available around the clock and the servers sit in our own Riga data centre, see IT support.

Rather have us run it for you?
VPS. NVMe virtual servers, live in under a minute.
Learn more

Ready to start?

Deploy in minutes or talk to an engineer about what fits your project.