Managing SELinux like code: policy modules stored in git, distributed via Ansible, boolean and fcontext templating, testing in CI, and versioned build pipelines with structured rollback.

After isolating untrusted applications with a sandbox in episode 19, now we answer a far more strategic question: how do you manage SELinux in infrastructure that isn't a single server? In episode 18 we touched on the importance of a uniform policy. Episode 20 turns that into real practice with policy-as-code: the entire SELinux policy — modules, booleans, fcontext — treated like application code, with git, review, CI, and versioned releases.
Ever seen a server whose SELinux configuration only exists in the head of an admin who's already resigned? That's the opposite of policy-as-code. Imagine if your application's source code were stored only in someone's memory — no one could verify, test, or roll back versions. An SELinux policy applied via semanage and semodule -l directly in a shell suffers the same fate: undocumented, unauditable, and prone to drift.
The solution: make the module source tree a git repository. A typical structure:
myapp/
├── myapp.te # type enforcement rules
├── myapp.fc # file context
├── myapp.if # interface (optional)
└── MakefileWith this, every change is a commit, every commit can be reviewed, and every release can be tagged.
The modules you wrote in previous episodes can be rebuilt at any time from the source tree. The .te file, for example:
policy_module(myapp, 1.0.0)
type myapp_t;
type myapp_exec_t;
domain_type(myapp_t)
domain_entry_file(myapp_t, myapp_exec_t)
allow myapp_t self:tcp_socket create_socket_perms;
allow myapp_t myapp_conf_t:file read_file_perms;
allow myapp_t myapp_log_t:file { create append_file_perms };
allow myapp_t myapp_log_t:dir { create read search add_name };The file context for labeling paths:
/opt/myapp(/.*)? gen_context(system_u:object_r:myapp_exec_t,s0)
/etc/myapp(/.*)? gen_context(system_u:object_r:myapp_conf_t,s0)
/var/log/myapp(/.*)? gen_context(system_u:object_r:myapp_log_t,s0)An important note: the columns in .fc are separated by a tab, and the second column must be treated as a single field — don't replace tabs with spaces. Building the module from the source tree uses the standard Makefile:
make -f /usr/share/selinux/devel/Makefilesemodule -i myapp.ppsemodule -l | grep myappThe interesting part: this make command also works in CI — a pipeline can reject a module that fails to compile without ever touching a production server.
Distributing policies to many hosts is tedious and error-prone when done manually. Ansible answers with idempotency: the same playbook run many times gives the same result. For SELinux there are three main modules you need to know:
ansible.posix.selinux — manages state (enforcing/permissive) and booleans.community.general.selinux_fcontext — manages file contexts.community.general.selinux_permissive — manages permissive domains.Example playbook to apply booleans and fcontexts uniformly:
- name: Apply uniform SELinux policy
hosts: webservers
become: true
vars:
httpd_booleans:
- name: httpd_can_network_connect
state: true
fcontexts:
- target: /var/www/myapp(/.*)?
setype: httpd_sys_content_t
state: present
tasks:
- name: Set the SELinux mode
ansible.posix.selinux:
policy: targeted
state: enforcing
- name: Configure httpd booleans
ansible.posix.selinux:
name: "{{ item.name }}"
state: "{{ 'on' if item.state else 'off' }}"
persistent: true
loop: "{{ httpd_booleans }}"
- name: Apply file contexts
community.general.selinux_fcontext:
target: "{{ item.target }}"
setype: "{{ item.setype }}"
state: "{{ item.state }}"
loop: "{{ fcontexts }}"Notice two things. First, persistent: true on booleans ensures the value survives a reboot — without it, the boolean change only lasts until restart. Second, the fcontext module doesn't run restorecon automatically; you still need to call restorecon for existing files so their labels get updated:
restorecon -Rv /var/www/myappTip
Combine the playbook above with the baseline verification from episode 18: after running restorecon, the same playbook can check semodule -l and compare the module list against the expected one. Ansible isn't just an applying tool, but also an ensuring tool — and that's what keeps the policy uniform across the fleet.
A successful build doesn't mean the policy is safe. In CI, add a testing layer:
name: SELinux policy
on:
push:
paths: ["policy/**"]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build the module
run: make -f /usr/share/selinux/devel/Makefile
- name: Analyze with SETools
run: |
seinfo -x myapp.pp
sesearch --allow -t shell_exec_t myapp.pp || true
- name: Trial install in a container
run: ./tests/install-and-smoke-test.shThe two layers above deserve attention:
seinfo and sesearch from SETools read the .pp without needing to boot — for example to make sure there's no rule allowing the application domain to execute a shell (shell_exec_t).tests/install-and-smoke-test.sh loads the module on a container or test machine, runs the application briefly, then asserts that ausearch -m avc contains no unexpected denials. This makes new denials known in CI, not in production.The module artifacts produced by CI must have a clear version — for example myapp-v1.2.0.pp — and every version is stored (Nexus, GitHub Release, or a storage bucket). The semodule -l command shows the list of active modules to match the installed version. Why? Because rollback must go to a known version, not a guess.
semodule -r myapp
semodule -i myapp-v1.1.0.pp
restorecon -Rv /opt/myapp /etc/myappWarning
Never delete old modules without keeping the previous version. A rollback that "rebuilds from memory" is a counter-rollback: at the critical moment you're improvising. Store versioned .pp files as CI artifacts, record which version is installed on which host, and make semodule -r followed by installing the old version a standard procedure — not one dramatic memorized step.
A summary of the practices that make this approach last:
.te, .fc, .if in git — one repository per application or service.seinfo, sesearch) before deploy.ausearch -m avc) on a test machine..pp artifacts; rollback is a written procedure.In this episode 20, you've learned to treat SELinux as real code: module sources in git, build and test in CI, idempotent distribution with Ansible (booleans, fcontext, and permissive through the ansible.posix.selinux, community.general.selinux_fcontext, and community.general.selinux_permissive modules), and versioned pipelines with structured rollback. With this pattern, "SELinux is hard to manage at scale" is no longer an excuse — because every decision is documented, auditable, and reversible.
In the next episode 21, we look ahead: the SELinux userspace 3.11 release (July 2026) with secilcheck, restorecon -F, setfiles -A and -U, security fixes in libselinux and dbus, and the roadmap for the 3.12 release in 2027. See you there!