The lemp stack hardening to ensure multi tenants doesn't corrupt each other

Most WordPress hosting guides assume one site, one server. That’s not the real problem. The real problem is running several client sites on one box without one compromised or bloated site taking down the rest. That’s an OS-level problem, not an app-level one, and it’s the one I built this role to solve.

Why Ansible, not Terraform

Terraform provisions infrastructure. It doesn’t harden an operating system. The isolation I needed, per-site users, per-site resource limits, per-site process boundaries, lives inside the OS and the web stack config, not in cloud provider APIs. So the tool for the job is a configuration management tool, not an infra-as-code tool for cloud resources. That’s not a default choice, it’s a deliberate one: hardening belongs at the OS/config layer.

Multi-PHP without breaking isolation

Multi-tenant means different sites often need different PHP versions. The obvious answer is Red Hat’s module streams. The obvious answer is wrong here, because module streams are mutually exclusive on a system. You install one PHP module stream, that’s the PHP version for the whole box. That breaks the entire premise of multi-tenant.

The fix is Remi’s SCL packages, phpXY-*, which install in parallel instead of replacing each other. Site A runs PHP 8.1, Site B runs PHP 8.3, both on the same host, no conflict. This is the correct approach for this use case, not a workaround.

The isolation stack

Four things stop one tenant from touching another:

  • Per-site systemd units with cgroup v2 (MemoryMax, CPUQuota). One site’s PHP-FPM pool eating memory doesn’t starve the others, because it’s capped at the systemd unit level, not left to compete for whatever’s free.
  • Per-site system users. Each site’s PHP-FPM pool runs as its own Linux user, not a shared www-data. A compromised site can’t write to another site’s files because the filesystem permissions don’t allow it.
  • Unix sockets instead of shared TCP ports. Nginx talks to each PHP-FPM pool over its own socket, scoped to that site’s user and permissions.
  • open_basedir per site. Even inside PHP, a script can’t reach outside its own site directory. That’s the last line of defense if the process boundary somehow gets crossed. Put together: if Site A gets a backdoored plugin, that process cannot read Site B’s wp-config.php, cannot exhaust Site B’s memory, and cannot write anywhere outside its own directory. That’s the actual guarantee this role is trying to make, not just “sites are separate folders.”

CI that knows its own limits

The role has GitHub Actions running Molecule against Docker. That catches syntax errors, task failures, idempotency breaks, the stuff that’s cheap to catch. What it can’t catch is whether cgroup v2 limits and SELinux contexts are actually enforced at runtime, because containers don’t give you a real kernel boundary to test against. Docker CI proves the role runs. It doesn’t prove the hardening holds.

That’s why Oracle Cloud’s Always Free tier is in the plan for a second validation pass, a real VM where cgroup and SELinux enforcement can actually be checked. Container CI is the fast, cheap first filter. It is not the whole story, and I’m not going to pretend it is.

Three bugs, three fixes

None of this came together clean. Three specific things broke CI:

  1. Galaxy namespace had to be lowercase. Ansible Galaxy’s FQRN convention rejected the role until the namespace was forced to lowercase. Small, easy to miss, wasted time until it wasn’t.
  2. ANSIBLE_ROLES_PATH was wrong. Molecule couldn’t resolve the role path in CI until this env var was set explicitly. Worked locally, failed in the pipeline, classic environment mismatch.
  3. become: false was required against geerlingguy/docker-rockylinux9-ansible. The container already runs as root via docker exec, so setting become: true triggered a broken PAM path inside the container. This is a known upstream image bug, not something wrong with the role itself. Fix was to drop become for the Molecule playbook specifically. Each of these is the kind of bug that only shows up when you actually run CI against a real target, not when you eyeball the YAML.

Where it stands

The role converges through most of its provisioning but was still failing at the last full converge run. Next step is local iteration against an Oracle VM, molecule converge, molecule login, molecule destroy, until it’s clean end to end. I’m not going to write a victory lap before that’s true.

What this demonstrates

This is infrastructure-as-code with security as the default, not something bolted on after. Per-tenant isolation designed in from the start, a CI pipeline that’s honest about what it can and can’t verify, and real debugging against real upstream bugs instead of a tutorial that never breaks. That’s the point of building it in public.