Introduction, or “What Could Possibly Go Wrong?”
This server had been upgraded through multiple major Enterprise Linux releases, had Apache, MariaDB, Remi PHP-FPM, BookStack, a Laravel controller, custom PHP-FPM pools, a non-standard SSH port, old cron jobs, repository tooling, and years of accumulated history.
SELinux was disabled.
sestatus
SELinux status: disabled
Not permissive. Not “temporarily relaxed”. Fully disabled, with this hiding in the kernel command line:
selinux=0
Naturally, the sensible thing to do after an AlmaLinux 8 → 9 → 10 migration and a fifteen-month BookStack upgrade was to enable SELinux on the same day.
This article is the procedure that actually worked: disabled → permissive → full relabel → test → enforcing, while keeping production applications online and using the SELinux audit log to tell us what needed fixing.
The important lesson is that enabling SELinux on an old production server does not have to mean “turn it on and discover what explodes”. Permissive mode exists specifically so you can load policy, generate correct labels, exercise the real workloads, and inspect denials without blocking them.
The Server We Were Working With
The box was AlmaLinux 10.2 with a fairly normal-but-old production stack:
- Apache on ports 80/443
- OpenSSH on TCP
65535 - MariaDB listening only on
127.0.0.1:3306 - Remi PHP 8.2 FPM
- BookStack at
/var/www/html/docs/bookstack - Laravel controller at
/var/www/html/zcloud - Custom PHP-FPM pools for
docsandzdev - Application cron jobs
- A few old scripts we would rather not discuss in public
The network state before enabling SELinux looked roughly like this:
netstat -taupvn
tcp 127.0.0.1:3306 LISTEN mariadbd
tcp 0.0.0.0:80 LISTEN httpd
tcp 0.0.0.0:65535 LISTEN sshd
tcp6 :::443 LISTEN httpd
That inventory matters. SELinux policy is not interested in what you intended to run. It is interested in what a process is actually doing, which files it touches, which ports it binds to, and which other services it contacts.
Step 1 — Install the SELinux Management Tools
On AlmaLinux/RHEL 10, the package we needed for semanage was:
dnf install -y policycoreutils-python-utils
Then verify the core packages:
rpm -q \
selinux-policy-targeted \
policycoreutils \
policycoreutils-python-utils \
libselinux-utils
Step 2 — Fix Custom Service Ports Before the First SELinux Boot
Our SSH daemon listens on TCP 65535 instead of 22. SELinux policy has port types, so a service using a non-standard port should have that port assigned to the appropriate type.
semanage port -a -t ssh_port_t -p tcp 65535
Verify:
semanage port -l | grep '^ssh_port_t'
ssh_port_t tcp 65535, 22
If the port already exists under some SELinux port definition and -a complains, inspect the existing assignment first and use -m only when you understand what you are changing.
This is one of the biggest practical SELinux rules: do not change a daemon to a random port and then blame SELinux when it refuses to start. Tell the policy about the port.
Step 3 — Label Only the Writable Parts of the Web Applications
Both applications lived under the standard Apache web root, so the application code itself could use the normal read-only web content type. The places that PHP must write to need a writable web type.
BookStack
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/docs/bookstack/storage(/.*)?'
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/docs/bookstack/bootstrap/cache(/.*)?'
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/docs/bookstack/public/uploads(/.*)?'
Then apply the configured labels:
restorecon -Rv \
/var/www/html/docs/bookstack/storage \
/var/www/html/docs/bookstack/bootstrap/cache \
/var/www/html/docs/bookstack/public/uploads
Laravel Controller
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/zcloud/storage(/.*)?'
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/zcloud/bootstrap/cache(/.*)?'
restorecon -Rv \
/var/www/html/zcloud/storage \
/var/www/html/zcloud/bootstrap/cache
This is deliberately narrow. We did not mark the whole application writable.
application code → httpd_sys_content_t
storage/cache/uploads → httpd_sys_rw_content_t
That distinction is part of the value of SELinux. If PHP is compromised, the process should not automatically gain permission to rewrite the entire application tree just because the Unix owner happens to allow it.
semanage fcontext vs restorecon
This confused me for years until I finally had to use it properly:
semanage fcontextdefines what the label should be for a path.restoreconapplies the expected label to files that already exist.
So the normal pattern is:
semanage fcontext -a -t SOME_TYPE '/some/path(/.*)?'
restorecon -Rv /some/path
By contrast, chcon changes a context directly but does not create the persistent path rule you normally want. A future relabel can undo it. For production configuration, persistent fcontext rules are much easier to reason about.
Step 4 — Allow the Web Applications to Reach What They Actually Need
BookStack and the Laravel application connect to MariaDB on localhost over TCP, so we enabled the database-specific boolean:
setsebool -P httpd_can_network_connect_db on
Our Laravel controller also makes outbound network requests, so we enabled the broader HTTP network-connect boolean:
setsebool -P httpd_can_network_connect on
The second option is broader, so do not enable it automatically just because you saw it in a blog post. If your PHP applications only need database access, the DB-specific boolean may be enough.
You can inspect relevant booleans with:
getsebool httpd_can_network_connect
getsebool httpd_can_network_connect_db
getsebool -a | grep '^httpd_'
Temporary changes omit -P:
setsebool httpd_can_network_connect off
That is useful during testing. The -P form writes the persistent state.
Step 5 — Check the Kernel Command Line, Not Just /etc/selinux/config
This was the first real trap.
Changing:
SELINUX=disabled
to:
SELINUX=permissive
inside /etc/selinux/config was not enough, because the current kernel command line still contained:
selinux=0
selinux=0 means the kernel does not load SELinux infrastructure at all. An enforcing=0 argument cannot make an SELinux-disabled kernel permissive.
Remove the hard-disable argument from all installed kernels:
grubby --update-kernel=ALL --remove-args='selinux=0'
Then force the first SELinux-enabled boot to be permissive:
grubby --update-kernel=ALL --args='enforcing=0'
Verify the arguments that the next boot will actually use:
grubby --info=ALL | grep -E '^(kernel|args)'
At this stage we wanted:
enforcing=0
and absolutely no:
selinux=0
Do not use /proc/cmdline to verify the next boot. It shows the arguments of the kernel that is running right now.
Step 6 — Prepare a Full Filesystem Relabel
This server had spent years with SELinux disabled, which means files created during that time could be unlabeled or incorrectly labeled.
Set the configuration to permissive:
sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
Then request a relabel on the next boot:
fixfiles -F onboot
Verify:
ls -la /.autorelabel
Before rebooting, we also checked the rules we had added:
echo '=== SSH PORT ==='
semanage port -l | grep '^ssh_port_t'
echo '=== LOCAL FCONTEXT RULES ==='
semanage fcontext -l -C | grep '/var/www/html'
echo '=== AUDIT ==='
systemctl is-active auditd
auditd being active is useful because the next phase is all about reading SELinux AVCs.
Step 7 — Reboot in Permissive Mode
This is the point where having hypervisor/console access is a very good idea. A full relabel can make the boot take significantly longer than normal.
reboot
After the machine returned:
sestatus
getenforce
We got:
SELinux status: enabled
Current mode: permissive
The SSH session came back on port 65535, all services started, and the relabel completed successfully.
Step 8 — Test the Real Workloads While SELinux Is Permissive
Permissive mode is not “SELinux off”. Policy is loaded and denials are audited, but the denied operation is still allowed. That makes it ideal for discovering what enforcing mode would block.
First, service health:
systemctl --failed
systemctl is-active \
httpd \
mariadb \
php82-php-fpm \
sshd
Then the actual web endpoints:
curl -sS -o /dev/null -w 'docs: %{http_code}\n' https://docs.nixpal.com/
curl -sS -o /dev/null -w 'zdev: %{http_code}\n' https://zdev.myip.gr/
curl -sS -o /dev/null -w 'updates: %{http_code}\n' https://updates.nixpal.com/
curl -sS -o /dev/null -w 'repo: %{http_code}\n' https://repo.nixpal.com/
Our result:
docs: 200
zdev: 302
updates: 200
repo: 200
The 302 was expected: the Laravel controller redirects its front page to /login. Following redirects confirmed the final page was 200:
curl -IL https://zdev.myip.gr/ | head -50
curl -IL https://zdev.myip.gr/login | head -50
Then the important part:
ausearch -m AVC,USER_AVC,SELINUX_ERR -ts boot
and:
ausearch -m AVC,USER_AVC -ts boot | audit2why
We got:
<no matches>
At this point Remi PHP-FPM, the custom docs.sock and zdev.sock, MariaDB, Apache, BookStack, Laravel and SSH on 65535 were all working with loaded SELinux policy and no denials.
Step 9 — Do Not Forget Cron Jobs and systemd Timers
HTTP tests do not exercise maintenance jobs that only run once per minute, once per day, or once per month.
Inventory them:
echo '=== SYSTEMD TIMERS ==='
systemctl list-timers --all
echo '=== ROOT CRONTAB ==='
crontab -l 2>/dev/null
echo '=== SYSTEM CRON ==='
cat /etc/crontab
grep -Rns '^[^#]' /etc/cron.d/ 2>/dev/null
echo '=== USER CRONTABS ==='
ls -la /var/spool/cron/ 2>/dev/null
Most of our timers were normal distro units such as dnf-makecache, dnf-automatic, fstrim and systemd-tmpfiles-clean.
The interesting custom job was a Laravel cron under the dev account:
* * * * * curl https://zdev.myip.gr/cron -q > /dev/null 2>&1
Test the same operation as the same Unix user:
runuser -u dev -- /bin/bash -c 'curl -fsS https://zdev.myip.gr/cron'
Then:
ausearch -m AVC,USER_AVC -ts recent | audit2why
Still no denials.
We also verified that cron itself had actually been executing the job:
grep 'zdev.myip.gr/cron' /var/log/cron | tail -20
One bonus archaeological discovery was an old full-directory backup job that attempted to tar huge parts of the system and rsync the archive elsewhere. That was disabled pending redesign. SELinux did not break it; archaeology did.
Step 10 — Switch Live to Enforcing
Once the applications had been exercised and the AVC log was clean:
setenforce 1
getenforce
Expected:
Enforcing
Then repeat the service and application tests:
curl -sS -o /dev/null -w 'docs: %{http_code}\n' https://docs.nixpal.com/
curl -sS -o /dev/null -w 'zdev: %{http_code}\n' https://zdev.myip.gr/
curl -sS -o /dev/null -w 'updates: %{http_code}\n' https://updates.nixpal.com/
curl -sS -o /dev/null -w 'repo: %{http_code}\n' https://repo.nixpal.com/
systemctl --failed
ausearch -m AVC,USER_AVC -ts recent | audit2why
Again: all endpoints worked, zero failed units, zero AVCs.
Step 11 — Make Enforcing Permanent
Change the persistent configuration:
sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config
Remove the temporary permissive boot parameter:
grubby --update-kernel=ALL --remove-args='enforcing=0'
Verify both config and boot entries:
grep '^SELINUX=' /etc/selinux/config
grubby --info=ALL | grep -E '^(kernel|args)'
At the end there should be no selinux=0 and no enforcing=0 in the normal kernel arguments.
I also recommend one final reboot so you know the server can boot directly into enforcing mode from a cold state rather than merely surviving a live setenforce 1.
Deploying Another Laravel/PHP Application Later
Once SELinux is enabled, adding another application is not complicated. You just need to make SELinux part of your deployment routine.
For a new app under the standard web root:
/var/www/html/myapp
After extracting, cloning, copying or moving files:
restorecon -Rv /var/www/html/myapp
For Laravel writable directories:
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/myapp/storage(/.*)?'
semanage fcontext -a -t httpd_sys_rw_content_t \
'/var/www/html/myapp/bootstrap/cache(/.*)?'
restorecon -Rv \
/var/www/html/myapp/storage \
/var/www/html/myapp/bootstrap/cache
If it has a custom upload directory, add that explicitly as well.
If the webroot is outside the normal Apache path, for example /srv/myapp, first define the readable content tree:
semanage fcontext -a -t httpd_sys_content_t \
'/srv/myapp(/.*)?'
restorecon -Rv /srv/myapp
Then override only writable subdirectories with httpd_sys_rw_content_t.
The “mv” Trap
If you build something under /root or a home directory and then move it into a webroot, the files can retain their old SELinux labels.
That is why this is such a useful deployment habit:
restorecon -Rv /var/www/html/myapp
Check contexts with:
ls -laZ /var/www/html/myapp
or compare current vs expected labels:
matchpathcon -V /var/www/html/myapp/*
How to Troubleshoot an SELinux Denial Without Turning SELinux Off
When something breaks after enabling enforcing mode, the correct response is not:
setenforce 0
# shrug
Permissive mode is a debugging tool, not a final configuration.
Start with the audit log:
ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
Explain the result:
ausearch -m AVC,USER_AVC -ts recent | audit2why
For everything since boot:
ausearch -m AVC,USER_AVC,SELINUX_ERR -ts boot
If setroubleshoot-server is installed, sealert can provide additional explanation:
sealert -l '*'
Then ask, in this order:
- Is this a labeling problem?
- Is this service using a non-standard path?
- Is it listening on a non-standard port?
- Is there an existing SELinux boolean for this behavior?
- Only then: does this truly require custom policy?
Do not make this your first troubleshooting step:
ausearch ... | audit2allow -M whatever
Blindly converting every denial into an allow rule is a very efficient way to recreate “SELinux disabled” while technically leaving SELinux enabled.
Useful Inspection Commands
Mode and policy
sestatus
getenforce
cat /etc/selinux/config
cat /proc/cmdline
Processes and domains
ps -eZ
ps -eZ | grep -E 'httpd|php-fpm|mariadbd|sshd|crond'
File labels
ls -lZ /path
ls -ldZ /path
matchpathcon /path
matchpathcon -V /path
Persistent custom file-context rules
semanage fcontext -l -C
Ports
semanage port -l
semanage port -l | grep '^ssh_port_t'
semanage port -l | grep 'http_port_t'
Booleans
getsebool -a
getsebool -a | grep '^httpd_'
getsebool httpd_can_network_connect
getsebool httpd_can_network_connect_db
Audit
ausearch -m AVC,USER_AVC -ts recent
ausearch -m AVC,USER_AVC -ts boot
ausearch -m AVC,USER_AVC -ts today
ausearch -m AVC,USER_AVC -ts recent | audit2why
SELinux Cheatsheet
This is the short version I wish I had next to me years ago.
Current state
sestatus
getenforce
Temporary mode change
setenforce 0 # permissive
setenforce 1 # enforcing
Permanent mode
# /etc/selinux/config
SELINUX=enforcing
SELINUXTYPE=targeted
Enable SELinux after it was fully disabled
grubby --update-kernel=ALL --remove-args='selinux=0'
grubby --update-kernel=ALL --args='enforcing=0'
sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
fixfiles -F onboot
reboot
After testing, make enforcing permanent
setenforce 1
sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config
grubby --update-kernel=ALL --remove-args='enforcing=0'
Apply the normal expected context for a path
restorecon -Rv /path
Make a web tree readable
semanage fcontext -a -t httpd_sys_content_t '/srv/app(/.*)?'
restorecon -Rv /srv/app
Make only an application data directory writable
semanage fcontext -a -t httpd_sys_rw_content_t '/srv/app/storage(/.*)?'
restorecon -Rv /srv/app/storage
See locally-added fcontext rules
semanage fcontext -l -C
Add a custom SSH port
semanage port -a -t ssh_port_t -p tcp 65535
Allow Apache/PHP to connect to databases
setsebool -P httpd_can_network_connect_db on
Allow HTTP-domain processes broader outbound connections
setsebool -P httpd_can_network_connect on
Find denials
ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
ausearch -m AVC,USER_AVC -ts recent | audit2why
Check a service after a denial
systemctl status SERVICE --no-pager
journalctl -u SERVICE -n 100 --no-pager
Test a command as the same service/user account
runuser -u USER -- /bin/bash -c 'COMMAND'
What Actually Surprised Me
I expected the Remi PHP installation or the custom FPM pools to be the difficult part. They were not.
The custom sockets:
/var/opt/remi/php82/run/php-fpm/docs.sock
/var/opt/remi/php82/run/php-fpm/zdev.sock
worked normally after relabeling. Apache, PHP-FPM, MariaDB, BookStack and Laravel all behaved under the standard targeted policy once the application write paths and network requirements were described correctly.
The real gotcha was much simpler: selinux=0 had survived in the kernel arguments. Without noticing that, changing /etc/selinux/config would have accomplished absolutely nothing.
Conclusion, or “The Horror Story Was That SELinux Was Disabled”
The final sequence looked like this:
inventory
configure custom ports
configure persistent file contexts
configure required booleans
remove selinux=0
boot permissive
full relabel
exercise real workloads
inspect AVCs
setenforce 1
repeat tests
make enforcing permanent
reboot and verify
At the end of the process:
SELinux: enforcing
SSH 65535: working
Apache: working
Remi PHP-FPM: working
MariaDB: working
BookStack: working
Laravel zcloud: working
cron: working
failed units: 0
AVC denials: 0
That is the part I did not expect.
SELinux was not the thing making the old server complicated. The old server was already complicated. SELinux just forced us to describe the intended behavior properly.
Which, annoyingly, is a pretty good argument for enabling it.
References
- Red Hat Enterprise Linux 10 — Changing SELinux states and modes
- Red Hat Enterprise Linux 10 — Troubleshooting problems related to SELinux
- Red Hat Enterprise Linux 10 — Using SELinux
- Red Hat Enterprise Linux 10 — Deploying web servers and reverse proxies
- Red Hat Enterprise Linux 10 — OpenSSH and non-default ports
Commands and policy behavior in this article were verified against AlmaLinux/RHEL 10-era tooling in August 2026. Test your own application workloads in permissive mode before enforcing policy on production.