Fedora

SELinux Survival Guide for Fedora 45 / 44: Fix Denials

A “Permission denied” on Fedora usually has nothing to do with file permissions. When nginx cannot bind a port as root, or PostgreSQL cannot read a config file it owns, the kernel’s SELinux check said no, and the fix is a label or a boolean, not setenforce 0. Turning SELinux off removes confinement from every service on the box to fix one of them, and you lose the layer that stops a compromised daemon from reading /etc/shadow.

Original content from computingforgeeks.com - post 167939

This guide follows one label at a time through real denials: how to read an AVC, then the four fixes that cover nearly every case (port types, file contexts, booleans and, last, a custom module). It then works through the services people actually break, with SELinux enforcing the whole time. Everything below was run on Fedora 45 and Fedora 44 Cloud Edition VMs in October 2026, and both releases gave the same denials and fixes unless a section says otherwise.

Labels decide access, not users

Unix permissions ask whether a user may touch a file. SELinux asks a second question on every system call: may a process running in this type do this to an object of that type? Root does not get a pass, because the check never looks at the UID. That’s why a root-owned nginx gets refused while your own shell reads the same file without complaint.

Every process, file and port carries a context with four fields, and they read left to right:

  1. SELinux user (system_u, unconfined_u, staff_u), which is not your Unix login
  2. Role (system_r, object_r for files)
  3. Type (httpd_t, shadow_t), the field the targeted policy actually enforces
  4. MLS/MCS level (s0, or s0:c26,c491 for a container)

You can see the type field at work on any fresh install, because the files that matter most already carry the labels the policy was written around. List three of them:

ls -Z /usr/sbin/sshd /etc/shadow
ls -dZ /var/log/audit

None of these labels says who owns the file. Each one says what kind of object it is (sshd_exec_t, shadow_t, auditd_log_t), and that kind is the only thing the policy rules ever mention:

   system_u:object_r:shadow_t:s0 /etc/shadow
system_u:object_r:sshd_exec_t:s0 /usr/sbin/sshd
system_u:object_r:auditd_log_t:s0 /var/log/audit

Those labels drive what happens at startup. When systemd (init_t) executes a file labelled sshd_exec_t, the policy moves the new process into sshd_t, a domain that cannot write shadow_t. You can ask the loaded policy for that rule directly with sesearch -T -s init_t -t sshd_exec_t, which answers type_transition init_t sshd_exec_t:process sshd_t;. Nearly every fix in this guide comes down to giving a file or port the type its daemon’s domain is allowed to use.

SELinux on Fedora is already enforcing

You don’t need to turn SELinux on, because Fedora ships it enforcing the targeted policy on every edition. That policy confines system services such as sshd, nginx and PostgreSQL and leaves your login shell alone, so most people never see a denial until they move a service off its defaults. Check the state before you debug anything, since a box someone left permissive logs denials without blocking them and sends you looking in the wrong place:

getenforce
sestatus
id -Z

Both mode lines should read enforcing. The id -Z line explains why root in your terminal can read files that root inside nginx cannot, because your shell runs in unconfined_t, which the targeted policy deliberately leaves unconfined.

getenforce, sestatus and id -Z showing SELinux enforcing the targeted policy on Fedora 45

If the two mode lines disagree, someone ran setenforce 0 and the box goes back to the mode in /etc/selinux/config on the next boot. If getenforce prints Disabled, files created while it was off carry no labels, so plan for the full relabel covered near the end before you switch it back on.

Do the two releases need different fixes? No. Every denial in this guide fired the same way on Fedora 45 and Fedora 44, and every fix cleared it on both, because the types and booleans involved did not change. The policy packages underneath did move, and these numbers from the two lab VMs after a full update show where:

ItemFedora 45Fedora 44
selinux-policy-targeted45.2344.11
policycoreutils3.113.11
setroubleshoot-server3.3.373.3.36
setools-console4.7.14.6.0
Booleans (getsebool -a)368367
Policy modules (semodule -l)432431
Built-in permissive domains2939
dontaudit rules88388809

Fedora 45’s extra boolean is corecmd_bin_sys_resource. The bigger change is the permissive list: twelve domains that Fedora 44 still runs permissive are no longer on the Fedora 45 list, so they now get denials where Fedora 44 only logged them. The permissive section below lists them.

Install the tools that read denials

The Cloud Edition images had none of the diagnostic tools, and command -v semanage sealert sesearch found nothing on either release. That matters more than it sounds, because the usual first move on a fresh denial is sealert, and a “command not found” there gets read as proof that SELinux is not involved. Install the three packages, plus nginx for the first test case:

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

Each package brings one part of the workflow:

  • policycoreutils-python-utils: semanage, audit2allow, audit2why
  • setools-console: sesearch and seinfo for querying the loaded policy
  • setroubleshoot-server: sealert and the daemon that writes plain-English summaries to the journal

If you’re still learning DNF5’s syntax, the DNF5 cheatsheet covers the flags used here.

Break nginx on purpose

The fastest way to learn the workflow is to cause two denials you already know the answer to, so you can watch each tool report them before you meet one in production. Move nginx to port 8888 and serve a webroot under /srv, both of which the policy doesn’t expect:

sudo mkdir -p /srv/web/myapp
echo "<h1>Hello SELinux</h1>" | sudo tee /srv/web/myapp/index.html

Point the default server block at the new port and webroot. These three substitutions match the stock /etc/nginx/nginx.conf on both releases:

sudo sed -i -e 's/listen       80;/listen       8888;/' -e 's/listen       \[::\]:80;/listen       [::]:8888;/' -e 's#root         /usr/share/nginx/html;#root         /srv/web/myapp;#' /etc/nginx/nginx.conf
sudo nginx -t

nginx -t passes, and that is the trap, because nothing in the config is wrong and the syntax check never asks SELinux anything. The failure only appears once systemd starts the daemon and it enters httpd_t. Start the service and read the journal:

sudo systemctl start nginx
sudo journalctl -u nginx -n 6 -o cat | grep emerg

The start fails, and the screenshot below shows the whole sequence on Fedora 45, from the failed start to the fix the next sections explain.

nginx bind denied on port 8888, the AVC from ausearch and semanage port adding 8888 to http_port_t on Fedora 45

nginx reports bind() to 0.0.0.0:8888 failed (13: Permission denied). It runs as root at that point, so a Unix permission problem is impossible, and that mismatch is your first hint that SELinux is involved. The rest of the screenshot is the fix, which the next three sections take apart.

Denials live in the audit log

Your service log only says “Permission denied” and never mentions SELinux, which is why so many people chase file modes and ownership first. The real reason is an AVC (Access Vector Cache) record in /var/log/audit/audit.log, written by the kernel at the moment it refused the call, and ausearch is the tool that reads it:

sudo ausearch -i -m AVC -ts recent -c nginx

-ts recent means the last ten minutes, -c filters on the command name and -i turns numeric fields into readable ones. Leave out -c and you get every denial on the box, which on a busy server buries the one you want. The record from the failed start:

type=AVC msg=audit(10/26/2026 01:37:15.143:736) : avc:  denied  { name_bind } for  pid=1873 comm=nginx src=8888 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0 tclass=tcp_socket permissive=0

Read it in this order and the fix usually picks itself:

  1. { name_bind }: the permission that was refused
  2. scontext: who asked, here nginx in httpd_t
  3. tcontext: what it asked for, a port labelled unreserved_port_t
  4. tclass: the kind of object (tcp_socket, file, dir)
  5. permissive=0: the access was blocked, not just logged

Why ausearch returns “<no matches>” in scripts

A common explanation online is that ausearch lags behind the log. It does not. When its standard input is not a terminal, ausearch reads the events from stdin instead of the log file, so it finds nothing in a cron job, an SSH one-liner or anything that pipes into it. The same search from an interactive terminal and with a pipe in front shows it:

sudo ausearch -m AVC -ts today -c nginx | grep -c denied
echo | sudo ausearch -m AVC -ts today -c nginx
echo | sudo ausearch --input-logs -m AVC -ts today -c nginx | grep -c denied

Thirteen denials, then none, then thirteen again once --input-logs tells it to read the log file named in auditd.conf:

13
<no matches>
13

Use --input-logs in any script, Ansible task or remote command, because none of those give ausearch a terminal. Your interactive searches are fine without it, so you only notice the problem once you automate a check that worked by hand. The alternative, -if /var/log/audit/audit.log, reads that one file only, while --input-logs also reads the rotated audit.log.N files, which matters for -ts today searches on a busy box. The ausearch man page documents the option for exactly this cron case.

Let sealert suggest the fix

The setroubleshootd daemon reads each AVC as it lands and writes a one-line summary to the journal, such as SELinux is preventing nginx from name_bind access on the tcp_socket port 8888. sealert prints the full analysis with ranked suggestions:

sudo sealert -a /var/log/audit/audit.log

The analysis is long, so read the plugin headers and their confidence scores first. The suggestion part of the output for the nginx port denial on Fedora 45 follows (the “Additional Information” and raw audit sections after it are trimmed):

found 1 alerts in /var/log/audit/audit.log
--------------------------------------------------------------------------------

SELinux is preventing nginx from name_bind access on the tcp_socket port 8888.

*****  Plugin bind_ports (92.2 confidence) suggests   ************************

If you want to allow nginx to bind to network port 8888
Then you need to modify the port type.
Do
# semanage port -a -t PORT_TYPE -p tcp 8888
    where PORT_TYPE is one of the following: http_cache_port_t, http_port_t, jboss_management_port_t, jboss_messaging_port_t, ntop_port_t, puppet_port_t.

*****  Plugin catchall_boolean (7.83 confidence) suggests   ******************

If you want to allow nis to enabled
Then you must tell SELinux about this by enabling the 'nis_enabled' boolean.

Do
setsebool -P nis_enabled 1

*****  Plugin catchall (1.41 confidence) suggests   **************************

If you believe that nginx should be allowed name_bind access on the port 8888 tcp_socket by default.
Then
    You should report this as a bug.

    If you are certain this access is legitimate and not an intrusion attempt, you
    can generate a local policy module to allow it.
    Custom policy modules are not supported as they may weaken the system policy and expose the system to security vulnerabilities.

Do
# ausearch -c 'nginx' --raw | audit2allow -M my-nginx
# semodule -X 300 -i my-nginx.pp

Take the top suggestion and ignore the other two. nis_enabled switches on 785 rules across dozens of domains (sesearch -A -b nis_enabled | wc -l), which is far wider than one web server on one port. The catchall module is the last resort, covered further down. Fedora 44’s sealert words the catchall block differently but ranks the same three plugins with the same scores.

Ports need the right port type

A name_bind or name_connect denial means the port’s type is not one the domain may use, so you change the port’s type, not the daemon. Add the port to the type the daemon’s domain is allowed to bind, then list that type to confirm it took:

sudo semanage port -a -t http_port_t -p tcp 8888
sudo semanage port -l | grep -w ^http_port_t

Port 8888 now leads the list, next to the defaults Fedora ships for http_port_t:

http_port_t                    tcp      8888, 80, 81, 443, 488, 8008, 8009, 8443, 9000
http_port_t                    udp      80, 443

After sudo systemctl restart nginx the service stays up, but curl http://localhost:8888/ answers 403. That is progress, not a new problem. The bind denial is gone and nginx now reaches the second denial you planted, because the kernel stops a process at its first refusal and it never got as far as the webroot before.

Other daemons follow the same naming, so if you can name the service you can usually guess its port type. List the database, mail and Cockpit types to see their default ports:

sudo semanage port -l | grep -wE '^(mysqld_port_t|postgresql_port_t|websm_port_t|smtp_port_t)'

Cockpit is websm_port_t, which is the one name you wouldn’t guess from the service:

mysqld_port_t                  tcp      1186, 3306, 63132-63164
postgresql_port_t              tcp      5432, 9898
smtp_port_t                    tcp      25, 465, 587
websm_port_t                   tcp      9090
websm_port_t                   udp      9090

Moving one of these services to a new port is the same one-line add into its type, provided nothing else owns that port already. That condition matters more than it looks.

semanage port -a moves a port that already has a type

A port belongs to one type at a time. If you -a a port that another type already owns, semanage does not fail: it prints a notice and reassigns the port, which silently breaks the first service. Here 8888 goes to SSH while nginx still needs it:

sudo semanage port -a -t ssh_port_t -p tcp 8888
sudo semanage port -l | grep -w 8888
sudo systemctl restart nginx

The port is now listed only under ssh_port_t, and nginx can no longer start. This capture was taken after the SSH section further down, which is why 2222 is in the list too:

Port tcp/8888 already defined, modifying instead
ssh_port_t                     tcp      8888, 2222, 22
Job for nginx.service failed because the control process exited with error code.
See "systemctl status nginx.service" and "journalctl -xeu nginx.service" for details.

You avoid this by running sudo semanage port -l | grep -w <port> before every add, and treat “modifying instead” as a warning that something else just lost its port. To undo it, delete the port from the wrong type and add it back to the right one:

sudo semanage port -d -t ssh_port_t -p tcp 8888
sudo semanage port -a -t http_port_t -p tcp 8888
sudo systemctl restart nginx

systemctl is-active nginx reports active again once 8888 is back in http_port_t.

Files inherit the label of their directory

A new file takes the type of the directory it’s created in, not of the service that will read it. /srv/web/myapp inherited var_t from /srv, and httpd_t may only read web content types such as httpd_sys_content_t, so nginx gets a 403 even though the file is world-readable. The audit log shows the refusal behind it:

type=AVC msg=audit(10/26/2026 01:25:51.524:361) : avc:  denied  { getattr } for  pid=1210 comm=nginx path=/srv/web/myapp/index.html dev="sda3" ino=75889 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:var_t:s0 tclass=file permissive=0

Fixing it takes two commands. semanage fcontext writes a rule into the policy saying what the path should be labelled, and restorecon applies that rule to files already on disk. matchpathcon shows what the policy expects before and after:

matchpathcon /srv/web/myapp/index.html /usr/share/nginx/html/index.html
sudo semanage fcontext -a -t httpd_sys_content_t "/srv/web/myapp(/.*)?"
sudo restorecon -Rv /srv/web/myapp
curl -s http://localhost:8888/

Before the rule exists, matchpathcon maps the default webroot to httpd_sys_content_t and the custom one to var_t, which is the whole problem in two lines. After the relabel the page loads, and nothing about nginx’s config or the file’s Unix mode changed.

matchpathcon, semanage fcontext and restorecon relabelling a custom nginx webroot from var_t on Fedora 45

The (/.*)? suffix covers the directory and everything under it. Because the rule lives in the policy store, a file created there later comes out as httpd_sys_content_t on its own, and a full relabel will not undo your fix. The -C flag lists only your local additions, which is the quickest audit of a server you inherited:

sudo semanage fcontext -l -C
sudo semanage port -l -C
sudo semanage boolean -l -C

On a fresh Cloud Edition VM the boolean list is not empty. selinuxuser_execmod, virt_sandbox_use_all_caps and virt_use_nfs were already set locally before any of these tests, so don’t assume every -C line is your own change.

mv keeps the old label, cp takes the new one

This is the denial people hit right after fixing the webroot. A file edited in your home directory is user_home_t. Copy it into the webroot and the copy is created there, so it gets the directory’s label. Move it and the inode keeps the label it had:

echo "<p>moved</p>" > ~/moved.html
echo "<p>copied</p>" > ~/copied.html
sudo mv ~/moved.html /srv/web/myapp/
sudo cp ~/copied.html /srv/web/myapp/
ls -Z /srv/web/myapp/moved.html /srv/web/myapp/copied.html

The moved file still says user_home_t:

unconfined_u:object_r:httpd_sys_content_t:s0 /srv/web/myapp/copied.html
        unconfined_u:object_r:user_home_t:s0 /srv/web/myapp/moved.html

copied.html returned 200 and moved.html returned 403, with an AVC for read on user_home_t. sudo restorecon -v /srv/web/myapp/moved.html relabels it to httpd_sys_content_t and the next request gets 200. Deploy scripts that mv or rsync from a home directory should run restorecon -R on the target as their last step.

chcon only lasts until something relabels

chcon writes a label straight onto a file and skips the policy store, so restorecon or a full relabel puts back whatever the fcontext rule says. That makes it fine for a quick test and wrong for anything you need to keep. It also behaves unexpectedly where no rule exists, such as under /tmp:

sudo touch /tmp/testfile
sudo chcon -t etc_t /tmp/testfile
sudo restorecon -v /tmp/testfile
ls -Z /tmp/testfile

restorecon refuses to touch it and the chcon label stays:

Warning no default label for /tmp/testfile
unconfined_u:object_r:etc_t:s0 /tmp/testfile

Fedora’s file contexts give /tmp contents no default label, so there’s nothing to restore to. Use semanage fcontext plus restorecon for every label you want to keep.

Booleans switch whole behaviours

Some access is not about one port or one path. A reverse proxy has to connect to whatever upstream you point it at, and the policy ships that as an off-by-default boolean. To reproduce it, proxy /api/ to a small upstream on 127.0.0.1:5000 (Python’s http.server, started in the background):

mkdir -p ~/upstream && echo "upstream ok" > ~/upstream/index.html
nohup python3 -m http.server 5000 --bind 127.0.0.1 --directory ~/upstream >/tmp/up.log 2>&1 &
echo 'location /api/ { proxy_pass http://127.0.0.1:5000/; }' | sudo tee /etc/nginx/default.d/api.conf
sudo systemctl restart nginx
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8888/api/

The answer is 502, and nginx’s error log blames the connect, not the upstream:

connect() to 127.0.0.1:5000 failed (13: Permission denied) while connecting to upstream, client: ::1, server: _, request: "GET /api/ HTTP/1.1", upstream: "http://127.0.0.1:5000/", host: "localhost:8888"

The AVC is a name_connect to commplex_main_port_t, the type that port 5000 already has. Adding 5000 to http_port_t is not an option, because of the move behaviour above. audit2why names the real fix:

sudo ausearch -m AVC -ts recent -c nginx | grep name_connect | tail -1 | audit2why

It reads the loaded policy and traces the denial to a boolean, so you don’t need to know the boolean’s name in advance:

type=AVC msg=audit(1792979309.787:1722): avc:  denied  { name_connect } for  pid=3293 comm="nginx" dest=5000 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:commplex_main_port_t:s0 tclass=tcp_socket permissive=0

	Was caused by:
	The boolean httpd_can_network_connect was set incorrectly.
	Description:
	Allow httpd to can network connect

	Allow access by executing:
	# setsebool -P httpd_can_network_connect 1

Flip it with -P so it survives a reboot, then retry:

sudo setsebool -P httpd_can_network_connect on
curl -s http://localhost:8888/api/

Now the proxy returns upstream ok. The web booleans worth knowing by name are listed by getsebool -a | grep ^httpd_can, which shows thirteen on both releases. Pick the narrowest one that matches your upstream:

  • httpd_can_network_connect: any TCP port, for generic reverse proxies
  • httpd_can_network_connect_db: PostgreSQL, MongoDB, MSSQL, Oracle and Firebird ports, but not MySQL (next section)
  • httpd_can_network_memcache and httpd_can_network_redis: a single rule each, for memcache_port_t and redis_port_t
  • httpd_can_network_relay: HTTP, HTTP cache (8080), Squid, FTP, gopher, memcache and ephemeral ports, which makes it the narrower choice for a proxy to an HTTP upstream
  • httpd_can_sendmail: web apps that hand mail to the local MTA

semanage boolean -l prints a one-line description and the current and default state of each, which is the quickest way to check a boolean’s name before you flip it.

httpd_can_network_connect_db skips MySQL

Its name suggests it covers databases, and most guides say to use it for a web app talking to a remote MySQL or MariaDB server. In the policy builds tested here (selinux-policy 45.23 on Fedora 45 and 44.11 on Fedora 44) it does not include mysqld_port_t. The upstream policy source wraps a MySQL connect rule in this boolean, so a later build may fix it; check with the sesearch line below before you rely on either answer. A proxy to a test listener on port 3306 (another Python http.server, though the denial fires at connect time, so any listener or none gives the same AVC), with the generic boolean turned back off and only the database boolean on, proves it:

sudo setsebool -P httpd_can_network_connect off
echo 'location /db/ { proxy_pass http://127.0.0.1:3306/; }' | sudo tee /etc/nginx/default.d/db.conf
sudo systemctl restart nginx
sudo setsebool -P httpd_can_network_connect_db on
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8888/db/
sudo ausearch -i -m AVC -ts recent -c nginx | grep 3306 | tail -1
sesearch -A -s httpd_t -t mysqld_port_t -p name_connect

The request fails with 502, the denial names mysqld_port_t, and the policy’s only rule for that port is the generic boolean:

502
type=AVC msg=audit(10/26/2026 01:50:12.959:1902) : avc:  denied  { name_connect } for  pid=3940 comm=nginx dest=3306 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:mysqld_port_t:s0 tclass=tcp_socket permissive=0
allow httpd_t port_type:tcp_socket name_connect; [ httpd_can_network_connect ]:True

Run the same query for postgresql_port_t and you get a second, httpd_can_network_connect_db rule, so PostgreSQL works with the narrow boolean and MySQL does not. For a web app on a remote MySQL or MariaDB server you need httpd_can_network_connect.

Ask the policy what a boolean controls

When you’re not sure what turning a boolean on actually grants, sesearch shows the rules behind it. This asks what httpd_t may do to web content files:

sesearch -A -s httpd_t -t httpd_sys_content_t -c file

Read access is unconditional. Write access to the same files needs three booleans on together, which is why writable upload directories should use httpd_sys_rw_content_t instead of turning on httpd_unified:

allow domain file_type:file map; [ domain_can_mmap_files ]:True
allow httpd_t httpd_content_type:file { getattr ioctl lock map open read };
allow httpd_t httpdcontent:file { append create getattr ioctl link lock open read rename setattr unlink watch watch_reads write }; [ ( httpd_builtin_scripting && httpd_unified && httpd_enable_cgi ) ]:True
allow httpd_t httpdcontent:file { execute getattr ioctl map open read }; [ ( httpd_builtin_scripting && httpd_unified && httpd_enable_cgi ) ]:True

sesearch -A -t shadow_t -c file -p write works the same way in reverse, listing the domains allowed to write the password file. The list is short (passwd_t, useradd_t, groupadd_t, updpwd_t and a few more), and httpd_t is not on it, which is why a compromised web server cannot change a password even as root.

audit2allow points back to the boolean

When no port type, file context or boolean fits, audit2allow builds a policy module from the denials you feed it. Feed it the proxy denial while httpd_can_network_connect is still off (as the previous section left it), and it tells you not to use it:

curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8888/api/
sudo ausearch -m AVC -ts recent -c nginx | grep dest=5000 | tail -1 > /tmp/proxy.avc
audit2allow -M cfgproxy < /tmp/proxy.avc
cat cfgproxy.te

Your generated .te source carries a #!!!! line naming the boolean that already covers this access:

audit2allow module whose te file points to the httpd_can_network_connect boolean for an nginx proxy on Fedora 45

Always read the .te before running semodule -i. A #!!!! hint means use the boolean and delete the module. An allow line naming a broad type such as file_type or port_type means you’re about to grant far more than the one denial. Feed it only the denials you understand, which is why the command above isolates a single record instead of piping every recent AVC in. Do it within ten minutes of the failure, because -ts recent only looks that far back. If the boolean is already on, audit2allow marks the record as allowed in the current policy instead of naming the boolean.

If you do load a module, sudo semodule -i cfgproxy.pp installs it, sudo semodule -l | grep cfgproxy confirms it, and sudo semodule -r cfgproxy removes it. Keep the .te file in version control next to the service it serves, so the next person can see exactly what you allowed. A module nobody can explain is the one that gets left behind after the service is gone.

Permissive works per domain

setenforce 0 stops enforcing for every confined service on the machine at once, so while you debug one web app, sshd, PostgreSQL and every container run unprotected. When an application throws a chain of denials and you want to collect them all in one run, make only its domain permissive. Everything else stays enforcing, and the denials are still logged:

sudo setsebool -P httpd_can_network_connect off
sudo semanage permissive -a httpd_t
curl -s http://localhost:8888/api/
sudo ausearch -i -m AVC -ts recent -c nginx | grep name_connect | tail -1

With the boolean off, the proxy still works, and the same AVC is logged with permissive=1. Collected over a full test run, those records are the list of everything the domain would have been denied:

upstream ok
type=AVC msg=audit(10/26/2026 01:48:36.940:1739) : avc:  denied  { name_connect } for  pid=3293 comm=nginx dest=5000 scontext=system_u:system_r:httpd_t:s0 tcontext=system_u:object_r:commplex_main_port_t:s0 tclass=tcp_socket permissive=1

Behind the scenes the exemption is a policy module of its own, permissive_httpd_t in semodule -l, and it persists across reboots. Remove it as soon as you’ve collected the denials; the proxy goes back to 502 straight away:

sudo semanage permissive -d httpd_t
sudo semanage permissive -l

semanage permissive -l splits its output into “Builtin Permissive Types”, which the distribution ships, and “Customized Permissive Types”, which are yours. The second heading should be gone after the delete. The built-in list is where the two releases differ most. These twelve domains are permissive on Fedora 44 and no longer on the Fedora 45 list. All twelve types still exist in the Fedora 45 policy (seinfo -t finds each one), so they are now enforced:

  • Desktop: gnome_remote_desktop_t, switcheroo_control_t, powerprofiles_t, tuned_ppd_t
  • systemd: systemd_oomd_t, systemd_user_runtimedir_t, systemd_mountfsd_t
  • Services and hardware: samba_bgqd_t, ktlshd_t, qgs_t, pcmsensor_t, redfish_finder_t

Fedora 45 adds go_fdo_server_t and coreos_bootloader_migrate_generator_t to its permissive list. If GNOME Remote Desktop, tuned’s power-profiles daemon or the Samba background queue worked on Fedora 44 and misbehaves after the upgrade, check for AVCs from those domains before you look anywhere else.

dontaudit hides some denials

Not every denial is logged, which surprises people who trust an empty audit log. The policy carries thousands of dontaudit rules (8838 on Fedora 45) that block an access without writing an AVC, to keep harmless probes out of the log. When something fails with “Permission denied” and ausearch finds nothing, a hidden denial is the next suspect.

Three signs point to a hidden denial:

  • The app reports “Permission denied” or EACCES
  • ausearch from a terminal finds no AVC
  • The same action works for root in your shell

One clean example came from the lab. Commands run through the QEMU guest agent (qm guest exec on Proxmox) execute in the confined virt_qemu_ga_t domain, and even getenforce fails there with security_getenforce() failed: Permission denied on both releases, with no AVC at all. Turn the dontaudit rules off, reproduce, then turn them back on:

sudo semodule -DB

Now repeat the action that failed (in the lab, getenforce through the guest agent from the Proxmox host), then search for the denial and restore the rules:

sudo ausearch -i -m AVC -ts recent -c getenforce
sudo semodule -B

With the rules disabled, the previously silent denial appears:

type=AVC msg=audit(10/26/2026 01:32:42.348:1498) : avc:  denied  { read } for  pid=2924 comm=getenforce name=enforce dev="selinuxfs" ino=4 scontext=system_u:system_r:virt_qemu_ga_t:s0 tcontext=system_u:object_r:security_t:s0 tclass=file permissive=0

Each rebuild took 5 to 7 seconds on a 2 vCPU VM, and seinfo showed the dontaudit count drop to 0 and return to 8838. Always run semodule -B when you’re done, or the log fills with noise that buries real denials. For the guest agent itself, virt_qemu_ga_run_unconfined does not help: it only covers files labelled virt_qemu_ga_unconfined_exec_t (the fsfreeze hook scripts), and turning it on left guest exec in virt_qemu_ga_t.

SSH on another port fails quietly

Moving SSH off port 22 has a nasty failure mode: sshd does not exit when it cannot bind the new port. With Port 22 and Port 2222 in a drop-in, the restart succeeds and systemctl is-active sshd says active, but only port 22 listens. The journal shows why:

echo -e "Port 22\nPort 2222" | sudo tee /etc/ssh/sshd_config.d/50-port.conf
sudo sshd -t
sudo systemctl restart sshd
sudo journalctl -u sshd --no-pager -n 4 -o cat

Only the first line shows the bind error, and the next three look perfectly healthy:

error: Bind to port 2222 on :: failed: Permission denied.
Started sshd.service - OpenSSH server daemon.
Server listening on 0.0.0.0 port 22.
Server listening on :: port 22.

If you had replaced 22 with 2222 instead of adding it, you’d be locked out after the next disconnect. Keep port 22 in the config until the new port answers. ssh_port_t holds only 22 by default, so this add does not move anything:

sudo semanage port -a -t ssh_port_t -p tcp 2222
sudo systemctl restart sshd
sudo ss -tlnp | grep sshd

Both ports listen on IPv4 and IPv6:

LISTEN 0      128          0.0.0.0:22        0.0.0.0:*    users:(("sshd",pid=1953,fd=8))
LISTEN 0      128          0.0.0.0:2222      0.0.0.0:*    users:(("sshd",pid=1953,fd=6))
LISTEN 0      128             [::]:22           [::]:*    users:(("sshd",pid=1953,fd=9))
LISTEN 0      128             [::]:2222         [::]:*    users:(("sshd",pid=1953,fd=7))

Move SSH in this order, so that you always keep a way back in:

  1. Add the new port to ssh_port_t
  2. Add Port 2222 next to Port 22, not instead of it
  3. Open 2222 in firewalld if it is running
  4. Log in on 2222 from a second terminal
  5. Only then remove Port 22

firewalld was not running on the Cloud Edition images, so step 3 did nothing there. Server and Workstation run it by default, and the firewalld guide for Fedora covers the zone and port commands for that step.

PostgreSQL needs postgresql_db_t on a new data directory

Moving the PostgreSQL data directory to a separate disk fails in a confusing order: initdb succeeds, then the service will not start. postgresql-setup runs from your unconfined sudo session, while the service runs in the confined postgresql_t domain. Point the unit at /srv/pgdata with a drop-in and initialise:

sudo dnf -y install postgresql-server
sudo mkdir -p /srv/pgdata
sudo chown postgres:postgres /srv/pgdata
sudo chmod 700 /srv/pgdata
sudo mkdir -p /etc/systemd/system/postgresql.service.d
printf '[Service]\nEnvironment=PGDATA=/srv/pgdata\n' | sudo tee /etc/systemd/system/postgresql.service.d/pgdata.conf
sudo systemctl daemon-reload
sudo postgresql-setup --initdb
sudo systemctl start postgresql

Now the start fails, and the journal blames a file the postgres user owns:

postgres: could not access the server configuration file "/srv/pgdata/postgresql.conf": Permission denied
postgresql.service: Main process exited, code=exited, status=2/INVALIDARGUMENT

The AVC is a getattr from postgresql_t on var_t, the same inherited-label problem as the webroot, so the fix is the same too. Give the new path the type the policy uses for /var/lib/pgsql/data, relabel it and start again:

sudo semanage fcontext -a -t postgresql_db_t "/srv/pgdata(/.*)?"
sudo restorecon -Rv /srv/pgdata
sudo systemctl restart postgresql
sudo -u postgres psql -Atc 'show data_directory'

restorecon prints a line for each of the 900 or so files in a fresh cluster. After that the service starts in postgresql_t, and the server reports the new path:

/srv/pgdata

The same three steps work for any confined database or daemon you relocate, whether it is MariaDB, Redis or a mail spool:

  1. matchpathcon on the default path to get the type
  2. semanage fcontext -a with that type on the new path
  3. restorecon -Rv on the new path, then restart

Do the relabel before the first start if you can. A service that fails at boot on a label problem looks exactly like a broken config to whoever gets paged.

Custom services run unconfined

Old guides say a custom systemd service “inherits init_t” and gets blocked, so you should label its script bin_t. Neither half held on either release. This test service is a one-line Python web server in /opt/myapp with a plain unit file:

sudo mkdir -p /opt/myapp
printf '#!/usr/bin/python3\nimport http.server,socketserver\nsocketserver.TCPServer(("127.0.0.1",7070),http.server.SimpleHTTPRequestHandler).serve_forever()\n' | sudo tee /opt/myapp/serve.py
sudo chmod +x /opt/myapp/serve.py
printf '[Unit]\nDescription=myapp\n[Service]\nExecStart=/opt/myapp/serve.py\nWorkingDirectory=/opt/myapp\n[Install]\nWantedBy=multi-user.target\n' | sudo tee /etc/systemd/system/myapp.service
sudo systemctl daemon-reload

Even with the script labelled usr_t, the service starts. Check which domain it landed in:

sudo systemctl start myapp
ps -eZ | grep serve.py

It runs in unconfined_service_t, the domain Fedora gives services that have no policy of their own:

system_u:system_r:unconfined_service_t:s0 2074 ? 00:00:00 serve.py

Relabelling the script to bin_t with semanage fcontext and restorecon changed nothing: the restarted process was unconfined_service_t again, and it still bound port 7070. So is SELinux protecting that service? No. It is not blocking your custom service, and it isn’t protecting anything from it either. Confining it means writing a policy module with its own type and a transition, which the Red Hat SELinux guide covers. If you’d rather not, run it in a container, which gets container_t for free.

Containers get their own label

Why does a file you own fail inside your own container? Every Podman container runs as container_t with a random pair of MCS categories, and it can only read files labelled container_file_t with a matching pair. A bind mount from your home directory is user_home_t, so the container is refused even though the Unix permissions are fine. The :Z and :z volume options relabel the source for you. Try all three forms as your normal user:

mkdir -p ~/html && echo "<h1>from host</h1>" > ~/html/index.html
podman run --rm -v ~/html:/data docker.io/library/alpine cat /data/index.html
podman run --rm -v ~/html:/data:Z docker.io/library/alpine cat /data/index.html
ls -dZ ~/html ~/html/index.html
podman run --rm -v ~/html:/data:z docker.io/library/alpine cat /data/index.html

The output on Fedora 45, including the cleanup covered below:

Podman bind mount denied without Z, container_file_t labels after Z and restorecon -F resetting them on Fedora 45

Each run in the capture leaves the volume in a different state:

  1. No option: cat: can't open '/data/index.html': Permission denied, and an AVC for container_t reading user_home_t
  2. :Z: a private label, container_file_t:s0:c26,c491, that only this container’s category pair can read
  3. :z: a shared label, container_file_t:s0 with no categories, readable by every container

Use :Z for a directory one container owns and :z only when several need it, because a shared label lets every container on the host read the data. Never point either at a system path such as /etc or /var. The relabel is permanent, so the host services that read those paths lose access the moment the container starts.

“not reset as customized by admin”

Most guides clean up with restorecon -Rv on the directory, and here it does nothing. container_file_t is a customizable type, so restorecon leaves it alone and prints /home/kenneth/html not reset as customized by admin to system_u:object_r:container_file_t:s0. Force it:

restorecon -RFv ~/html

-F resets customizable types too, so the directory goes back to user_home_t and your host tools can use it again. Without it you are left with a home directory that only containers can read, which breaks backups and editors in ways that look nothing like SELinux. Three more things the lab turned up:

  • --security-opt label=disable ran a rootless container as spc_t, which skips type enforcement for that container, so treat it as a debugging switch
  • container_use_cgroup, still quoted in many cheat sheets, does not exist on either release; container_manage_cgroup is the boolean for systemd inside a container
  • container_connect_any, container_use_devices and container_read_certs are off by default and each opens one specific thing

The Podman install guide for Fedora covers rootless setup, and Quadlet units take the same :Z option in their Volume= lines.

Confined admins need a sudo role

Mapping an admin login to staff_u confines their session, which is the main hardening step beyond the default targeted setup. A confined admin who runs a compromised tool can only do what staff_t allows, instead of everything an unconfined root shell can. Create a user, give it the SSH key you log in with and an ordinary sudo rule, then map it and confirm the mapping. The key copy assumes your own account logs in with a key in ~/.ssh/authorized_keys; if it doesn’t, give opsadm a password with sudo passwd opsadm instead:

sudo useradd -m opsadm
sudo mkdir -p /home/opsadm/.ssh && sudo cp ~/.ssh/authorized_keys /home/opsadm/.ssh/ && sudo chown -R opsadm: /home/opsadm/.ssh && sudo chmod 700 /home/opsadm/.ssh
echo 'opsadm ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/opsadm
sudo semanage login -a -s staff_u opsadm
sudo semanage login -l

Your new mapping sits next to the defaults:

Login Name           SELinux User         MLS/MCS Range        Service

__default__          unconfined_u         s0-s0:c0.c1023       *
opsadm               staff_u              s0-s0:c0.c1023       *
root                 unconfined_u         s0-s0:c0.c1023       *

Sudo is the trap. With the ordinary NOPASSWD: ALL line you just wrote, sudo changes the Unix user to root but keeps the SELinux domain, so root is still staff_t and cannot read root’s own files. Log in as opsadm over SSH, as the lab did, so the new mapping applies to the session, then run:

id -Z
sudo id -Z
sudo cat /root/.bash_profile

Both contexts are staff_t and the read fails:

staff_u:staff_r:staff_t:s0-s0:c0.c1023
staff_u:staff_r:staff_t:s0-s0:c0.c1023
cat: /root/.bash_profile: Permission denied

From your original, unconfined admin session (not the opsadm one, whose sudo you just saw is still confined), give the sudoers rule a role and type, so that sudo moves the session into the administrator domain:

echo "opsadm ALL=(ALL) ROLE=sysadm_r TYPE=sysadm_t NOPASSWD: ALL" | sudo tee /etc/sudoers.d/opsadm
sudo visudo -c -f /etc/sudoers.d/opsadm

From a fresh login as opsadm, sudo id -Z now prints staff_u:sysadm_r:sysadm_t:s0-s0:c0.c1023 and the file reads normally, while the session outside sudo stays staff_t. Test this from a second session before you log out of the one you’re editing from. The Fedora hardening guide builds on this mapping.

A full relabel takes seconds, not minutes

When labels are wrong across the whole filesystem, after running with SELinux disabled or restoring files without their xattrs, relabel everything on the next boot:

sudo touch /.autorelabel
sudo systemctl reboot

selinux-autorelabel.service relabels, removes the flag file and reboots a second time. On the lab VMs it measured:

ReleaseFiles on /Relabel (wall clock)Reboot to SSH
Fedora 4550,4578.0 s41 s
Fedora 4447,9469.3 s42 s

In the journal, the unit ends with Failed with result 'signal'. That’s the second reboot cutting it off, not a failure: both VMs came back Enforcing with the flag file gone. A Workstation install with a full home directory takes longer, so don’t interrupt the console when it sits on the relabel. If the box uses Btrfs, a Snapper snapshot taken before large policy changes gives you a rollback that a relabel cannot.

Match the denial to the fix

Every case above reduces to the permission in braces and the target type in the AVC. Work down this table in order, and only reach the last row when nothing above it fits:

What the AVC or log showsFixCheck
name_bind on unreserved_port_tsemanage port -a -t <daemon>_port_tsemanage port -l | grep -w <port> first
name_connect from a web or app daemonNarrowest httpd_can_* or similar boolean, with -Paudit2why names it
File access on var_t, default_t or user_home_tsemanage fcontext -a + restorecon -Rmatchpathcon on the default path
Worked in a test, broke after a deployrestorecon -R on the targetls -Z for files moved with mv
Container read on user_home_t:Z on the volumerestorecon -RF to undo
“Permission denied” with no AVCsemodule -DB, reproduce, semodule -BRun ausearch from a terminal, or with --input-logs
Many denials from one appsemanage permissive -a <domain>, then -dpermissive=1 in the AVCs
Nothing above fitsaudit2allow -M on that one denialRead the .te for a #!!!! boolean hint

None of the rows involve setenforce 0. The Fedora SELinux troubleshooting page covers the same tools. If you just came through the Fedora 44 to 45 upgrade, run semanage fcontext -l -C and semanage port -l -C to confirm your local rules came across intact.

Keep reading

How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) Fedora How to Upgrade Fedora 44 to Fedora 45 (GUI, CLI and Rollback) Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Fedora Enable RPM Fusion and Install Multimedia Codecs on Fedora 45 / 44 / 43 Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Fedora Harden Fedora 45 / 44 for Desktop and Server: Complete Guide Switch Fedora From GRUB to systemd-boot With bootctl Fedora Switch Fedora From GRUB to systemd-boot With bootctl DNF5 Cheatsheet for Fedora 45 / 44: Tested Commands and Fixes Fedora DNF5 Cheatsheet for Fedora 45 / 44: Tested Commands and Fixes Install Brave Browser on Fedora 45 / 44 Fedora Install Brave Browser on Fedora 45 / 44

Leave a Comment

Press ESC to close