Security model
The threat model assumes one of your sites will eventually be compromised — bad WordPress plugin, vulnerable dependency, dumb code. The goal is for the blast radius to stop at that site. Here's what's between an attacker who got PHP-RCE on site A and the rest of the host.
Layered defenses
| layer | what's enforced | where |
|---|---|---|
| Network | Only SSH (22) + HTTP/S (80/443) open; everything else blocked. | ufw (setup.sh step 9) |
| SSH | Key-based auth strongly recommended over passwords. | /etc/ssh/sshd_config |
| nginx | Catch-all default 444s unknown hostnames. Dotfiles + backup extensions denied. Rate limits applied. | conf.d/00-tulixhost-default.conf, snippets/tulixhost-security.conf |
| nginx → FPM | try_files $uri =404 prevents fall-through; cgi.fix_pathinfo=0 globally. | per-site vhost, global php.ini |
| PHP-FPM pool | Runs as per-site unix user; cannot escalate without a kernel exploit. | per-site pool user=web_* |
| PHP runtime | open_basedir limits file access; disable_functions removes RCE primitives; allow_url_include=off. | per-site pool php_admin_* |
| Filesystem | Site dirs mode 0750 owned by per-site user; .envtulix mode 0640 root-owned, readable only via per-site group. | create_vhost.sh step 3, 5 |
| MariaDB | Per-site user limited to one DB. Listens only on 127.0.0.1. Root is unix_socket only. | create_vhost.sh step 7, server config |
| Redis | Per-site ACL user restricted to one DB + one key prefix; dangerous commands denied. Localhost only, password required. | create_vhost.sh step 8, server config |
| OS | unattended-upgrades enabled for security pocket on first run. | setup.sh step 10 |
What an attacker with PHP-RCE on site A can do
- Read files in
public/,tmp/,conf/(their own site only). - Read
conf/.envtulixvia the per-site group (PHP runs as the user, the user is the owner's group). - Use the site's own MariaDB user — full access to
web_<site>database. - Use the site's own Redis ACL user — full access to the
web_<site>:*keyspace. - Make outbound HTTP/DNS requests (nothing blocks egress by default).
- Exhaust the FPM pool's worker count, briefly blocking that site.
What they cannot do (without an additional kernel/CVE exploit)
- Read any file in
/data/web/<other-site>/. - Read
/etc/tulixhost/tulixhost.conf(mode 0600, root only). - Read
/etc/redis/users.acl(mode 0640, root:redis). - Spawn a shell or execute system commands (
exec,shell_exec,system,proc_open,popen,pcntl_execare all indisable_functions). - Connect to other sites' MariaDB users (no password, can't auth).
- Connect to other sites' Redis keyspaces (ACL denies).
- Issue
FLUSHALL/FLUSHDB/KEYS/CONFIG/SHUTDOWNon Redis (all in@dangerous). - Affect other sites' FPM workers, sessions, or upload tmp.
- Write to
/var/log,/etc, or any global cron file (filesystem perms). - Persist via cron (the site's cron file runs as the per-site user, edits to
/etc/cron.d/require root).
Secrets handling
Each site's conf/.envtulix is mode 0640, owned root
with group web_<site>. The site's PHP-FPM pool runs as web_<site>
(member of its own group), so it can read but not write. The file contains:
- DB password (specific to this site's DB user)
- Redis password (specific to this site's Redis ACL user)
- App key (32-char alnum, for the application's own crypto)
None of these are useful for cross-site escalation. Rotating any of them is a matter of editing
the file and the corresponding server-side entry (DB ALTER USER or
users.acl edit), then reloading the app.
TLS
HTTPS sites get Mozilla intermediate cipher suite, TLSv1.2 + TLSv1.3 only, OCSP stapling,
HSTS for 2 years (no preload by default — set preload in the
Strict-Transport-Security header manually when you're sure). Cert renewal for
Let's Encrypt is handled by certbot's own systemd timer (already installed); tulixhost doesn't
need to do anything.
For wildcards or non-public hostnames, supply your own cert with --ssl=./ssl.
Place SITE_NAME.crt and SITE_NAME.key in ./ssl/ before
running create_vhost.sh. tulixhost copies them into conf/ssl/ and
references them from the vhost.
Auditing
Every privileged invocation of a tulixhost script appends a line to
/var/log/tulixhost/audit.log:
2026-05-26T22:14:03Z pid=14392 user=george argv=./create_vhost.sh cmd=example.com --ssl=auto --demo
This is rotated weekly by logrotate. Combined with shell history and
journalctl _COMM=sudo, you have a full forensic trail of admin actions on the host.
What's not defended (and what to do about it)
- Egress filtering. A compromised site can
file_get_contents()any URL on the internet. If you care, add an outbound proxy at the firewall level (Squid in deny-by-default mode, or ufwrouterules) and force apps through it. - Resource starvation between sites. Redis maxmemory is shared. MariaDB connections are shared. PHP-FPM workers per site are bounded, but CPU/IO are not. For strong isolation, run per-site cgroups (systemd slices) — tulixhost doesn't do this out of the box.
- Kernel exploits. If the kernel has an LPE bug, all of the above is
bypassable. Keep
unattended-upgradeson; considerlivepatchfor Ubuntu Pro. - Supply-chain. A malicious npm/composer dependency can do everything PHP can do. Pin versions, use lockfiles, and review updates.
- Server-side request forgery (SSRF). PHP can hit internal IPs (Redis, MariaDB localhost). Apps should validate URLs and prevent fetching internal addresses.