How to Write Custom SELinux Policies for Your Applications
It was 2:47 AM on a Tuesday when my phone buzzed. The API server was down. Again. I’d been on-call for the infrastructure team at a mid-sized SaaS company for about six months at that point. We had maybe 15 developers, a handful of sysadmins, and a bunch of custom applications running on Linux servers spread across different environments.
Nothing fancy, just the usual mix of Node.js services, Python workers, and a couple of older Java applications. I SSH’d into the server, checked the logs, and immediately saw the problem: permission denied errors everywhere. The application was trying to write to a log file and SELinux was blocking it. I looked at the time. Two in the morning.
I had a standup in four hours. So I did what I’m not proud of but what I’m sure you’ve done too: I ran setenforce 0 and went back to bed. SELinux was disabled. Problem solved. I’d deal with it properly later.
“Later” turned into months. Then a year. Every time there was a weird permission issue on any server, the easiest fix was to check if SELinux was enabled. If it was, disable it. If it wasn’t, move on. I never had to think about it again.
By the time I’d been in that role for a year and a half, SELinux was disabled on every production server we had. I’d gotten so used to it being off that I didn’t even think about it anymore. It was just a checkbox in my mental “Linux setup” checklist: ✓ Disable SELinux. Then we had a serious security incident.
When Disabling Things Becomes A Problem
Our accounting application got compromised. One of our less tech-savvy customers clicked a phishing link, gave up their password, and an attacker got into their account. But here’s where it gets bad: the attacker didn’t stop at that customer’s data. They used their access to probe around the application server, found some other vulnerabilities, and managed to get shell access.
Once inside, they had basically full access to everything. They could read database credentials from config files. They could access other customers’ data. They could have installed a backdoor, but they didn’t. They just looked around for a few hours before triggering our intrusion detection and we kicked them out.
In our post-incident meeting, our security consultant asked a question that really stuck with me: “What if that application process only had the permissions it actually needed? What if it couldn’t read files it didn’t need to access?”
The answer was uncomfortable: it had root permissions. Not technically root, but close enough. Because we’d disabled SELinux, nobody had ever bothered setting up proper file permissions or process isolation. We just gave the app what it needed and hoped nobody would break in.
When they did break in, there was basically nothing stopping them from poking around. That question sat with me for weeks. I started thinking about all the times I’d disabled SELinux just to make something work quickly. How many of those things were now running with more permissions than they actually needed?
Making The Decision To Actually Learn
I made a decision: I was going to actually learn how to work with SELinux instead of just disabling it. Not because I was some security zealot all of a sudden, but because I was tired of being the person who causes the incident.
The problem was that every resource I found about SELinux was either completely theoretical or so specific to one use case that it wasn’t helpful for what I was trying to do. I wanted to know: how do I write a custom policy for my own application?
I started with the simplest thing I could find: a Python Flask application that was causing permission errors. It read from a config file, wrote logs, and connected to a PostgreSQL database. Three things. I could manage three things.
I set up a test virtual machine that was an exact copy of the production server. I told myself I’d figure out SELinux on this VM, and only once it was working would I touch production.
What SELinux Actually Is?
Before I could write a policy, I needed to actually understand what SELinux was doing. Not the abstract security theory. Just: what happens when I run an application and SELinux blocks something?
Here’s what I learned:
SELinux works with labels. Every file on the system has a label. Every process has a label. When a process tries to do something with a file, SELinux checks: “Is the process’s label allowed to do that to the file’s label?”
The labels are called contexts and they look like this: system_u:object_r:httpd_t:s0
You can break that down, but honestly the important part is the httpd_t part. That’s the “type”. It says “I’m an httpd thing” or “I’m a web server thing.” The system has rules that say what httpd types are allowed to do.
When you run an application, by default it gets a generic label. The files it tries to access have their own labels. If the labels don’t match the rules, things get blocked.
Check what mode SELinux is in:
getenforce
There are three modes:
Enforcing – Things actually get blocked. This is production mode.
Permissive – Things don’t get blocked, but everything that would’ve been blocked gets logged. This is testing mode.
Disabled – SELinux is completely off. Don’t do this unless you really have to.
The Method That Actually Works
I found the process that worked by basically trial and error:
Step 1: Set up test environment in permissive mode
I had a staging server set up. I put SELinux into permissive mode:
sudo setenforce 0
Now the application would work fine, but SELinux would log everything it would’ve blocked if it was enforcing.
Step 2: Run the application normally
I started the Flask app and did what a normal user would do. Made API requests. Wrote some data. Created log files. Hit the database.
Step 3: Look at what got blocked
I checked the audit logs to see what SELinux would have prevented:
sudo ausearch -m avc -ts recent -i
I got output like this:
type=AVC msg=audit(1615555200.123:456): avc: denied { read } for pid=1234
comm="python" name="config.json" dev="sda1" ino=45678
scontext=system_u:system_r:unconfined_t:s0
tcontext=system_u:object_r:user_home_t:s0 tclass=file
What this is saying: A Python process (unconfined_t context) tried to read a file called config.json (user_home_t context) and SELinux denied it because unconfined_t isn’t allowed to read user_home_t files.
I went through all the denials and wrote down what the application actually needed:
- Read the Python executable from /opt/myapp/
- Read config.json from /opt/myapp/
- Write logs to /var/log/myapp/
- Read and write data to /var/lib/myapp/
- Make network connections to the database
Step 4: Create the policy
I created a file called myapp.te (the .te extension means “Type Enforcement”):
policy_module(myapp, 1.0.0)
type myapp_t;
type myapp_exec_t;
type myapp_config_t;
type myapp_data_t;
type myapp_log_t;
I’m just defining label types. The actual application process will get the myapp_t label. Executable files get myapp_exec_t. Config files get myapp_config_t. Data files get myapp_data_t. Log files get myapp_log_t.
Then I added the actual rules:
allow myapp_t myapp_exec_t:file { read execute_no_trans open getattr };
allow myapp_t myapp_config_t:file { read open getattr };
allow myapp_t myapp_config_t:dir { read search open };
allow myapp_t myapp_data_t:dir { read write open search add_name remove_name };
allow myapp_t myapp_data_t:file { read write open create getattr };
allow myapp_t myapp_log_t:file { write append open create getattr };
allow myapp_t myapp_log_t:dir { write add_name open };
allow myapp_t self:tcp_socket { bind listen accept read write };
allow myapp_t self:process { fork };
The format is always: allow [source] [target]:[ class] { [permissions] };
So allow myapp_t myapp_exec_t:file { read execute_no_trans open getattr }; means: allow myapp_t processes to read, execute, open, and get attributes from myapp_exec_t files.
Step 5: Create file context rules
I created a file called myapp.fc:
/opt/myapp/.*\.py -- system_u:object_r:myapp_exec_t:s0
/opt/myapp/config\.json -- system_u:object_r:myapp_config_t:s0
/var/lib/myapp(/.*)? -- system_u:object_r:myapp_data_t:s0
/var/log/myapp(/.*)? -- system_u:object_r:myapp_log_t:s0
This tells the system: “Files matching these patterns get these labels.”
Step 6: Compile the policy
checkmodule -M -m -o myapp.mod myapp.te
semodule_package -o myapp.pp -m myapp.mod
sudo semodule -i myapp.pp
Step 7: Apply the labels
sudo semanage fcontext -a -t myapp_exec_t "/opt/myapp(/.*)?"
sudo semanage fcontext -a -t myapp_config_t "/opt/myapp/config\.json"
sudo semanage fcontext -a -t myapp_data_t "/var/lib/myapp(/.*)?"
sudo semanage fcontext -a -t myapp_log_t "/var/log/myapp(/.*)?"
sudo restorecon -Rv /opt/myapp/
sudo restorecon -Rv /var/lib/myapp/
sudo restorecon -Rv /var/log/myapp/
Step 8: Test it
I started the application and made sure it worked. If something didn’t work, I checked the denials again and added another rule.
Once everything worked in permissive mode, I switched to enforcing:
sudo setenforce 1
And ran all my tests again. Same tests, enforcing mode. If everything passed, the policy was good.
A Real World Example: The Node.js API
After I got comfortable with the process, I applied it to our actual production application: a Node.js REST API that’s one of our core services.
The app lives in /opt/api-service/, reads configuration from /etc/api-service/, stores data in /var/lib/api-service/, writes logs to /var/log/api-service/, and connects to PostgreSQL and Redis.
I set up a staging environment, put it in permissive mode, ran the app for a few hours, and checked the denials:
sudo ausearch -m avc -ts recent -i
Got about 40 different denials. Went through them and wrote down what the app actually needed.
Created the policy:
policy_module(api_service, 1.0.0)
type api_service_t;
type api_service_exec_t;
type api_service_config_t;
type api_service_data_t;
type api_service_log_t;
allow api_service_t api_service_exec_t:file { read execute_no_trans open getattr };
allow api_service_t api_service_config_t:file { read open getattr };
allow api_service_t api_service_config_t:dir { read search open getattr };
allow api_service_t api_service_data_t:dir { read write open search add_name remove_name };
allow api_service_t api_service_data_t:file { read write open create append getattr };
allow api_service_t api_service_log_t:file { write append open create getattr };
allow api_service_t api_service_log_t:dir { write add_name open getattr };
allow api_service_t self:tcp_socket { bind listen accept read write connect };
allow api_service_t self:process { fork signal };
allow api_service_t self:capability { setuid setgid };
Compiled it, applied the file context rules, tested it in permissive mode.
Got a few more denials I didn’t expect. Added rules for:
- Reading /etc/passwd (Node needs to look up user info)
- Accessing /tmp/ (for temporary files)
- Network connections to specific ports
Recompiled, retested, no more denials.
Switched to enforcing mode, ran the full test suite including load tests. Everything worked.
Deployed to staging for a week, monitored the logs. No surprise denials.
Then deployed to production.
That was six months ago. No issues. The policy works.
When Things Break And How You Fix It
A month after we deployed the Node.js API policy, we added a monitoring tool that reads the application’s log files. It’s running as a different process type, and when it tried to read the logs, SELinux blocked it.
The app was fine. The monitoring tool errored out. We saw it immediately because it was in our monitoring alerts.
I checked the audit logs:
sudo ausearch -m avc -ts recent -i
Found the denial: monitoring tool process trying to read api_service_log_t files and getting blocked.
Added one line to the policy:
allow monitoring_tool_t api_service_log_t:file { read open };
Recompiled:
checkmodule -M -m -o api_service.mod api_service.te
semodule_package -o api_service.pp -m api_service.mod
Tested it in permissive mode first on a test server. Worked fine.
Pushed the update to production.
The whole thing took about 20 minutes.
And here’s the important part: I knew exactly what was wrong because SELinux logged it. I didn’t have to guess. I didn’t have to go digging through code or configuration files wondering why the monitoring wasn’t working. The audit log told me exactly what permission was missing.
The Difference It Makes
A few months after we started using proper SELinux policies, one of our applications had a vulnerability. Nothing catastrophic, but bad enough that we had to patch it immediately.
While we were working on the patch, we noticed someone was trying to exploit the vulnerability. They got shell access inside that container. But because we had SELinux policies in place, they couldn’t do much.
They tried to read the database credentials from the config file. Blocked by SELinux.
They tried to access files in other application directories. Blocked.
They tried to listen on a different network port. Blocked.
They were completely confined to that one application. We patched the vulnerability the next morning, and by then they’d given up and moved on.
If we’d disabled SELinux like we used to, that attacker would’ve been able to read database credentials, access other applications’ files, potentially access the actual database if they could find the right vulnerabilities.
Instead, SELinux kept them locked down to one compromised process.
That’s when I realized: this wasn’t just about following security best practices. This actually mattered.
What I Wish I’d Known From The Start
If I could go back to that 2 AM version of me who just disabled SELinux without thinking about it, here’s what I’d say:
First: You’re gonna spend about 4-5 hours learning this stuff. That’s it. That’s the total time investment to go from “SELinux is magical and confusing” to “SELinux is just a set of rules and I can write them.”
Second: Once you do it once, you can do it for any application. The process is always the same. Check denials. Write a policy. Test in permissive. Test in enforcing. Deploy.
Third: The audit logs are your friend. When something doesn’t work, SELinux tells you exactly why. You don’t have to guess. That’s actually amazing compared to debugging most other things.
Fourth: The effort you spend now stops one breach later. And that breach would cost way more than a few hours of your time.
Fifth: You’re gonna make mistakes. You’ll write a policy that’s too restrictive and something won’t work. You’ll make it too permissive and realize it later. That’s fine. You learn and you fix it.
How To Actually Start
Here’s what to do:
Pick an application. Something running in your environment that either has SELinux disabled or is causing issues. Doesn’t have to be critical.
Set up a test environment. A staging server or VM that’s identical to production. You need to test before deploying to real servers.
Follow the process:
- Put it in permissive mode
- Run the application normally for a few hours
- Check what got denied
- Write the policy
- Test in permissive mode
- Test in enforcing mode
- Deploy to staging and monitor for a few days
- Deploy to production
That’s it.
The first time you do it, it’ll take you a few hours. The second time, maybe two hours. The third time, an hour. Once you’ve written a few policies, you can do them in your sleep.
Final Thoughts
I used to be the guy who disabled SELinux on every server I touched. I didn’t think about why. I didn’t think about the security implications. I just thought about solving the immediate problem and moving on. Then we got compromised and I realized how stupid that was.
Now I’m the guy who actually configures SELinux properly, and honestly it’s not that bad. It’s not even that hard. It just requires spending the time to learn it instead of taking the shortcut. And more importantly, my servers are actually secure. Not just in theory, not just because I hope nobody breaks in.
But actually secure because if someone does get into one application, they’re boxed in and can’t spread to the rest of my infrastructure. That’s worth the five hours it takes to learn how to write policies. Stop disabling SELinux. Spend an afternoon learning how to configure it. Your future self will thank you.
