Enabling SELinux on a Server That Had It Disabled for Years — And Somehow Nothing Broke

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 docs and zdev
  • 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 fcontext defines what the label should be for a path.
  • restorecon applies 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:

  1. Is this a labeling problem?
  2. Is this service using a non-standard path?
  3. Is it listening on a non-standard port?
  4. Is there an existing SELinux boolean for this behavior?
  5. 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

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.