InfraRunBook
    Back to articles

    Implementing Mandatory Access Control With SELinux on Production Servers

    Cyber Security for Servers
    Published: Aug 24, 2026
    Updated: Aug 24, 2026

    A practical, field-tested guide to deploying SELinux mandatory access control on production servers, covering setup, policy tuning, verification, and the mistakes that most often take down a fleet.

    Implementing Mandatory Access Control With SELinux on Production Servers

    I have rolled SELinux out across mixed fleets more than a dozen times now, and the pattern is always the same. Someone sets it to permissive during initial provisioning, everyone gets busy, and eighteen months later there's a production box running as root's best friend with zero mandatory access control doing anything useful. This runbook is my attempt to stop that cycle. We're going to take a server from disabled or permissive SELinux to a properly enforced, verified, production-grade configuration, and we're going to do it in a way that doesn't page anyone at 2 AM.

    SELinux is not a firewall and it's not an antivirus. It's a label-based mandatory access control (MAC) system built into the kernel that constrains what processes can do regardless of what discretionary permissions (the regular Unix rwx bits) allow. Even if an attacker gets code execution as your web server user, SELinux confines that process to only the actions its policy type permits — reading specific directories, binding specific ports, talking to specific sockets. Discretionary access control says "the owner decides." Mandatory access control says "the kernel decides, and the owner doesn't get a vote." That distinction matters enormously once you've had an application server compromised and watched a policy violation in the audit log stop lateral movement cold.

    Prerequisites

    Before you touch anything, you need a few things in place. I've seen this step skipped and it always ends in a rushed rollback.

    • A RHEL-family distribution (RHEL, Rocky, AlmaLinux, or Fedora) — this guide assumes SELinux is already compiled into the kernel, which it is on all of these by default.
    • Root or sudo access on the target host, here referred to as sw-infrarunbook-01.
    • The policycoreutils, policycoreutils-python-utils, and setroubleshoot-server packages installed for management and troubleshooting tooling.
    • A maintenance window. Switching enforcement modes on a live production server without one is how you end up locked out of SSH at an inconvenient hour.
    • A rollback plan — specifically, console or out-of-band access (IPMI, iLO, or a hypervisor console) in case enforcing mode blocks the very service you use to reach the box.
    • Backups of
      /etc/selinux/config
      and any custom policy modules you plan to touch.

    In my experience, the single biggest predictor of a smooth SELinux rollout is whether the team has console access ready before flipping to enforcing. Skip that and you're one bad policy decision away from a truck roll.

    Step-by-step setup

    Start by checking the current state. Don't assume — I've walked into environments where three different people believed three different things about whether SELinux was even running.

    $ getenforce
    Permissive
    
    $ sestatus
    SELinux status:                enabled
    SELinuxfs mount:                /sys/fs/selinux
    SELinux root directory:         /etc/selinux
    Loaded policy name:              targeted
    Current mode:                    permissive
    Mode from config file:           permissive
    Policy MLS status:               enabled
    Policy deny_unknown status:      allowed
    Max kernel policy version:       33

    If you see

    disabled
    instead of
    permissive
    , that's a bigger job — you'll need to relabel the entire filesystem on next boot, which takes time on large disks and requires a reboot with
    autorelabel
    triggered. If it's already permissive, you're in a much better spot because the kernel is already tracking what it would have denied, without actually blocking anything.

    Install the tooling you'll need for policy management and debugging:

    $ sudo dnf install -y policycoreutils policycoreutils-python-utils \
        setroubleshoot-server setools-console

    Now, the actual workflow. Never jump straight from disabled or permissive to enforcing. The correct sequence is: run in permissive mode long enough to capture real traffic and generate a denial log, review those denials, write policy exceptions for anything legitimate, then flip to enforcing. I usually budget at least a full business week of permissive logging on a new production workload before I trust it enough to enforce.

    With permissive mode active and the workload running normally, watch the audit log for AVC (Access Vector Cache) denials — these are the events SELinux would have blocked had it been enforcing:

    $ sudo ausearch -m avc -ts recent
    
    type=AVC msg=audit(1745582011.222:481): avc:  denied  { name_connect } for  pid=2214 comm="nginx" dest=8443 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=1

    That denial tells you exactly what's happening: the nginx process, running under the

    httpd_t
    domain, tried to connect out to TCP port 8443, but that port is labeled
    unreserved_port_t
    rather than something httpd is allowed to reach. This is a real, common finding whenever an app is proxying to a non-standard backend port. The fix is not to disable SELinux — it's to tell SELinux that this port is legitimately part of the httpd workflow.

    $ sudo semanage port -a -t http_port_t -p tcp 8443
    $ sudo semanage port -l | grep http_port_t
    http_port_t                    tcp      80, 81, 443, 488, 8008, 8009, 8443, 9000

    For file context issues — say, a custom application directory serving content from /srv/solvethenetwork-app/html instead of the default /var/www/html — you need to set the correct label so the policy recognizes it as web content, not just "some directory root happened to allow":

    $ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/solvethenetwork-app/html(/.*)?"
    $ sudo restorecon -Rv /srv/solvethenetwork-app/html

    For denials that don't map cleanly to an existing boolean or port label — custom daemons, in-house tooling, that sort of thing — you'll lean on

    audit2allow
    to generate a custom policy module from the actual denials you've collected:

    $ sudo ausearch -m avc -ts today | audit2allow -M solvethenetwork_custom
    $ sudo semodule -i solvethenetwork_custom.pp

    I want to flag something here because I've seen it go wrong more than once: don't pipe

    audit2allow
    straight into
    semodule -i
    without reading the generated
    .te
    file first. Open solvethenetwork_custom.te and actually read what rules it's proposing. Sometimes it will happily generate a rule granting far broader access than the one denial actually requires, because it's pattern-matching against the AVC record rather than understanding intent. A denial for reading one config file can turn into a blanket read/write grant on an entire directory type if you're not paying attention.

    Once you've worked through a reasonable set of denials and you're not seeing new ones show up in the logs for a few days of normal traffic, it's time to flip the switch:

    $ sudo setenforce 1
    $ getenforce
    Enforcing

    setenforce 1
    changes the running kernel state immediately, but it won't survive a reboot unless you also update the config file. Edit /etc/selinux/config:

    SELINUX=enforcing
    SELINUXTYPE=targeted

    Full configuration example

    Here's a complete, representative configuration for a hardened application server at sw-infrarunbook-01 running an nginx reverse proxy in front of a backend service on a non-standard port, with a custom content directory and a couple of booleans tuned for the actual workload rather than left at defaults.

    # /etc/selinux/config
    SELINUX=enforcing
    SELINUXTYPE=targeted
    
    # Port labeling for the reverse proxy backend
    # (run once, persists in policy store)
    semanage port -a -t http_port_t -p tcp 8443
    
    # Custom web root labeling
    semanage fcontext -a -t httpd_sys_content_t "/srv/solvethenetwork-app/html(/.*)?"
    restorecon -Rv /srv/solvethenetwork-app/html
    
    # Allow nginx to make outbound network connections
    # (needed when proxying to an upstream API)
    setsebool -P httpd_can_network_connect on
    
    # Allow nginx to connect to the backend on a non-standard port
    # already covered by the http_port_t labeling above
    
    # Deny nginx from writing to content directories it only needs to read
    setsebool -P httpd_unified off
    
    # Custom module for the in-house monitoring agent
    # generated from a week of permissive-mode denials, reviewed by hand
    semodule -i solvethenetwork_custom.pp
    
    # Confirm final boolean state relevant to this host's role
    getsebool -a | grep httpd

    Notice what's not in this configuration: no blanket

    setenforce 0
    fallback cron job, no wildcard fcontext rules, no disabling of the audit daemon to "reduce noise." Every rule here maps to a specific, understood requirement. That traceability is what makes SELinux maintainable long-term instead of becoming an unexplainable pile of exceptions nobody wants to touch.

    Verification steps

    Don't declare victory the moment

    getenforce
    says
    Enforcing
    . Verify the whole chain actually works under load and under attack-shaped conditions.

    First, confirm enforcement survived a reboot, since a live

    setenforce 1
    without the config file change will silently revert:

    $ sudo reboot
    
    # after reboot
    $ getenforce
    Enforcing
    $ sestatus | grep "Current mode"
    Current mode:                   enforcing

    Second, confirm the actual application functions end to end. Hit the service the way a real client would and check for errors that look like silent SELinux blocks — connection refused where you'd expect a normal response, 502s from a proxy that can't reach its upstream, or a systemd unit that fails with a permission error despite correct file ownership:

    $ curl -I https://sw-infrarunbook-01.solvethenetwork.com:8443/health
    HTTP/1.1 200 OK
    
    $ systemctl status nginx
    ● nginx.service - The nginx HTTP and reverse proxy server
         Loaded: loaded
         Active: active (running)

    Third, and this is the step people forget most often, go back to the audit log and confirm there are zero new denials during a full business cycle — not just a quick smoke test, but a real day of traffic including whatever batch jobs, log rotations, or backup runs happen on a schedule:

    $ sudo ausearch -m avc -ts today
    
    
    $ sudo sealert -a /var/log/audit/audit.log
    No problems found

    An empty

    ausearch
    result after a full day, including cron-triggered jobs, is your real signal that the policy tuning was complete. If you only tested during business hours and skip the 3 AM backup job, you'll get paged when that job runs and finally hits a denial nobody anticipated.

    Finally, verify context labels haven't drifted, particularly after any package updates or manual file operations that might have reset them to defaults:

    $ ls -Z /srv/solvethenetwork-app/html
    unconfined_u:object_r:httpd_sys_content_t:s0 index.html
    unconfined_u:object_r:httpd_sys_content_t:s0 assets

    Common mistakes

    The number one mistake, by a wide margin, is treating

    setenforce 0
    as a debugging tool that gets left in place. I've inherited servers where someone hit an SELinux denial during an incident, ran
    setenforce 0
    to make the problem go away, fixed the actual issue, and then just... never turned it back on. Weeks later nobody remembers it happened. If you must disable enforcement temporarily to isolate whether SELinux is the cause of a problem, put a reminder on the calendar and treat the permissive window as a ticking clock, not a new steady state.

    A close second is disabling SELinux entirely at the kernel boot parameter level (

    selinux=0
    ) rather than setting permissive or enforcing in the config file. This is a much heavier hammer — it removes the /sys/fs/selinux mount entirely, and re-enabling it later requires a full filesystem relabel and reboot, which on a large production disk can take a genuinely long time and holds the server offline the whole time. If you ever find yourself needing SELinux back on a box where it was fully disabled, budget real downtime for that relabel.

    Blindly accepting every

    audit2allow
    suggestion is another one I've flagged already but it deserves repeating because of how often it happens under deadline pressure. The tool is a starting point for writing policy, not a policy generator you can trust unread. Grant the narrowest rule that resolves the actual denial you're chasing.

    People also frequently forget that SELinux context and Unix permissions are independent layers. I've debugged plenty of "permission denied" errors where the file had perfectly correct ownership and mode bits, and the actual blocker was a mislabeled context — usually because someone moved or copied a file with

    cp
    or an unusual tool that didn't preserve the SELinux label, rather than using
    mv
    within the same filesystem or explicitly running
    restorecon
    afterward. Any time a file is placed somewhere non-standard, get in the habit of running
    restorecon -v
    on it as a reflex.

    Last one: not testing failure paths. Everyone tests the happy path where the service starts fine. Fewer people test what happens when the service restarts after a crash, when a backup script kicks off at 3 AM, or when a log rotation triggers a file move. Those are exactly the moments hidden denials surface, usually while nobody is watching, and usually a week after the rollout when everyone's confidence is highest and vigilance is lowest.

    Done right, SELinux in enforcing mode gives you a genuinely meaningful second layer of defense — one that keeps doing its job even after an attacker has already gotten further than you wanted them to. The setup cost is real, but it's front-loaded, and it gets dramatically cheaper on every subsequent server once you've built the policy modules and muscle memory for how a given application stack behaves under mandatory access control.

    Frequently Asked Questions

    How long should I run SELinux in permissive mode before switching to enforcing?

    At minimum, run it through a full business cycle that includes scheduled jobs like backups, log rotation, and any batch processing — not just a quick smoke test during business hours. For most production workloads I budget about a week of permissive logging before I trust the denial log enough to flip to enforcing.

    Is it safe to disable SELinux instead of tuning policy for a legacy application?

    It works, but it removes a meaningful layer of defense and makes re-enabling it later expensive, since a full kernel-level disable requires a filesystem relabel and reboot to turn back on. It's almost always cheaper long-term to write a targeted custom policy module than to disable SELinux entirely.

    What's the difference between setenforce 1 and editing /etc/selinux/config?

    setenforce 1 changes the live kernel state immediately but does not persist across a reboot. You need to also set SELINUX=enforcing in /etc/selinux/config, otherwise the server will silently drop back to its prior mode after a restart.

    Why do I keep getting permission denied errors even though file ownership and mode bits look correct?

    SELinux context labels are a separate access control layer from standard Unix permissions. A file can have correct owner, group, and mode bits and still be blocked if it has the wrong SELinux type, which commonly happens after copying files with tools that don't preserve context. Run restorecon on the file or directory to fix this.

    Should I trust audit2allow output as-is?

    No. Treat it as a first draft. Read the generated .te file before installing the module, since audit2allow sometimes proposes broader access than the specific denial actually requires.

    Related Articles