You compiled a custom tool and SELinux killed it
You built a monitoring daemon or a Python script that needs to read /proc/net/ and write to a specific socket. You run the binary, and it exits immediately. No error message appears on the terminal. You check the logs and see avc: denied { read } for pid=12345 comm="myapp". SELinux is protecting the system, but your application is dead in the water. You need to give the application exactly the permissions it requires without opening a hole that lets everything else through. Confinement is the process of building a custom security domain for your application so it can only touch the resources it needs.
What's actually happening
SELinux does not rely on user permissions alone. It uses security contexts, which are labels attached to every file and process. A context looks like user:role:type:level. The type field is the security domain. Think of a type like a security badge in a high-rise building. The badge tells the elevator which floors you can visit and which doors you can open.
By default, a new binary you drop into /usr/local/bin/ has no specific badge. It runs as unconfined_t, which is like having a master key. That defeats the purpose of confinement. Confinement means creating a custom type for your application. The type grants access only to the specific files, ports, and capabilities the app requires. Everything else remains locked.
When you label a binary with a type like myapp_exec_t, the kernel performs a transition automatically. When a process executes that binary, the kernel switches the process type from the parent type to myapp_t. The new process inherits the restrictions of myapp_t. You build the policy rules that define what myapp_t is allowed to do. The policy module contains those rules. You compile the rules into a module and load it into the kernel. The kernel enforces the rules immediately.
The fix
Check SELinux mode
Verify SELinux is enforcing before you start building policy. A policy that works in permissive mode often hides gaps that crash the application in enforcing mode.
getenforce
# Returns Enforcing on a default Fedora install.
# If it returns Permissive, the system is logging denials but not blocking them.
# Fix this immediately to test your policy against real enforcement.
sudo setenforce 1
Use sestatus -v if you need the full policy version and current mode in one view. getenforce is the muscle-memory check for a quick status.
Enforce before you build. Half the time a policy looks complete until you flip the switch and the app hits a hidden denial.
Install the policy development tools
Install the development tools needed to generate and compile policy modules. These tools stay installed across updates and are safe to keep on the system.
sudo dnf install selinux-policy-devel policycoreutils-python-utils audit
# selinux-policy-devel provides the Makefile and macros for building modules.
# policycoreutils-python-utils gives you semanage and other management tools.
# audit provides ausearch for reading the denial logs.
Collect denials in permissive mode
Run the application in permissive mode to collect denials without breaking functionality. This lets you see every access attempt the application makes.
# Create a temporary permissive domain for your app type.
# Replace myapp_t with the type you plan to use.
sudo semanage permissive -a myapp_t
# Run your application now.
# It will fail silently if it hits a denial, but the denial gets logged.
./myapp
# Run the full workflow.
# A denial during startup is useless if the app also needs access to a temp file during shutdown.
./myapp --run-full-cycle
# Search the audit log for AVC denials since the start of the session.
sudo ausearch -m avc -ts recent
# ...output truncated for clarity
Check journalctl -t setroubleshoot for a human-readable summary of denials. The setroubleshoot service parses the raw audit log and prints a one-line explanation of what was blocked and why. This is faster than reading raw AVC records.
Run the full workflow. A policy built from a partial run will break the application in production.
Generate a policy module from audit logs
Convert the audit log denials into a policy module source file. The tool reads the denials and writes the corresponding allow rules.
sudo ausearch -m avc -ts recent | audit2allow -M myapp
# This pipes the denials into audit2allow.
# The -M flag creates myapp.te and myapp.pp in the current directory.
# The .te file contains the human-readable policy rules.
# The .pp file is the compiled module ready for installation.
Review and refine the policy source
Inspect the generated policy file and remove any overly broad permissions before compiling. audit2allow generates rules that satisfy the denials, but it does not optimize for security. It often grants more access than necessary.
cat myapp.te
# Look for rules like allow myapp_t self:capability sys_admin.
# sys_admin is a dangerous capability that grants broad system control.
# Replace broad rules with specific file or socket permissions where possible.
# Check the require block to ensure all referenced types and classes are defined.
# The require block lists dependencies that the module needs from the base policy.
make -f /usr/share/selinux/devel/Makefile myapp.pp
# The Makefile compiles the .te source into a loadable .pp module.
# It also runs checkmodule to catch syntax errors.
# If checkmodule fails, fix the .te file and run make again.
Review the source. Never install a policy you have not read. A policy with sys_admin is no better than no policy at all.
Install the module and label the binary
Load the compiled module and apply the correct file context to your binary. The module adds the new type to the kernel. The file context ensures the binary gets the right label.
sudo semodule -i myapp.pp
# Installs the module into the running policy store.
# The new type myapp_t is now available for use.
sudo semanage fcontext -a -t myapp_exec_t '/usr/local/bin/myapp'
# Adds a file context rule so the binary gets the right label.
# This rule persists across relabels and reboots.
# Always use semanage fcontext instead of chcon.
# chcon changes the label on disk but does not update the file context database.
# The next restorecon or reboot will revert a chcon change.
sudo restorecon -v /usr/local/bin/myapp
# Applies the new label immediately to the file on disk.
# The -v flag prints the label change so you can verify it worked.
Label the binary. An unlabeled binary runs as unconfined_t and defeats confinement.
Switch to enforcing and test
Remove the permissive override and verify the application works under full enforcement. This is the final validation step.
sudo semanage permissive -d myapp_t
# Removes the permissive exception.
# The domain is now fully confined.
getenforce
# Confirm the system is still Enforcing.
./myapp
# Run the app again.
# If it crashes or hangs, check the logs immediately.
# The logs will show the new denials that the policy does not cover.
Remove the safety net. If the app breaks, the logs tell you exactly what is missing.
Verify it worked
Confirm the process is running with the correct context and no new denials are appearing. A correct context proves the transition happened. A clean audit log proves the policy is complete.
ps -eZ | grep myapp
# Shows the process list with SELinux contexts.
# You should see myapp_t in the context column.
# If you see unconfined_t, the binary label is wrong or the transition rule is missing.
sudo ausearch -m avc -ts recent
# Should return no output if the policy is complete.
# Any output means the app is still hitting a wall.
# Use audit2why on the denial to understand the missing rule.
sudo ausearch -m avc -ts recent | audit2why
Check the context. If the type is wrong, the policy is not applying.
Common pitfalls and what the error looks like
If you see avc: denied { name_bind } for scontext=system_u:system_r:myapp_t:s0 tcontext=system_u:object_r:unreserved_port_t:s0, your app is trying to bind a port that SELinux does not allow for its type. You need to add a rule for the specific port or label the port correctly. Network ports have their own types. The policy must grant access to the port type, not just the port number.
Using chcon to change labels is a trap. chcon changes the label on disk but does not update the file context database. The next restorecon or reboot will revert the change. Always use semanage fcontext to define the rule, then restorecon to apply it. Config files in /etc/ are user-modified. Files in /usr/lib/ ship with the package. Edit /etc/. Never edit /usr/lib/. This applies to SELinux policy too. Custom modules go into the policy store via semodule, not by dropping files into /usr/share/selinux/.
SELinux denials show up in journalctl -t setroubleshoot with a one-line summary. Read those before disabling SELinux. The summary often points directly to the missing rule or the boolean you need to toggle.
Read the denial. The error message contains the subject, object, and action. Use that to write the rule.
When to use this vs alternatives
Use a custom SELinux module when you are shipping a binary that needs specific access to system resources and you want to limit its blast radius. Use semanage boolean when the base policy already supports the behavior but requires a toggle to enable it. Use audit2allow only to generate a starting point for policy, never to install the result without review. Use chcon only for temporary debugging sessions where persistence does not matter. Use semanage fcontext when you need file labels to survive a relabel or reboot. Stay on the default policy when your application follows standard conventions and does not require special permissions.