When you host one site and it falls over, you have a bad afternoon. When you host two hundred sites on one box and one of them starts leaking memory, forking children, or hammering MySQL with a badly written plugin, everyone else on that box feels it before you even open your laptop. Shared hosting is a physics problem as much as a security problem. Every tenant is sharing the same file descriptors, the same CPU, the same page cache, the same semaphore arrays, and if you do not draw hard lines, the greediest or the most compromised tenant sets the ceiling for everybody.
I run a hosting company on the side, so I have tuned and re-tuned this exact kind of box more times than I can count. This is not a tips list. This is the actual sequence I go through, with the config that goes in the files, and the reasoning for why each knob is where it is. You will still have to tune the numbers to your own hardware and workload and then watch what happens. Anyone who hands you final numbers without asking about your RAM and your traffic shape is guessing.
Table of contents
Open Table of contents
- The short version
- The mindset: blast radius first
- SSH and the edge: keep it boring
- Per-tenant isolation: the core of the whole thing
- Resource limits: one tenant cannot starve the rest
- Kernel and sysctl tuning: the part people skip
- Service and app performance under load
- Filesystem and the small wins
- Backups you have actually restored
- Final takeaway
The short version
- On a shared box, failure is contagious. The whole job is limiting blast radius while keeping the machine quick under many noisy neighbors. Security and performance are the same discipline: control what each tenant can consume and reach.
- Isolate per tenant. One system user, one PHP-FPM pool running as that user, files not readable across the box, PHP fenced in with
open_basediranddisable_functions. - Cap everything. ulimits and systemd cgroup limits so a runaway loop hits its own ceiling, not the machine’s.
- Tune the kernel for many short connections and many processes. File descriptors, the TCP backlog chain, VM writeback, and yes, the System V IPC semaphores that databases quietly depend on.
- Pick the few high-leverage app knobs. nginx workers, PHP OpCache, FPM sizing tied to real memory, and an InnoDB buffer pool sized to the box.
- A backup you have never restored is a rumor. Test the restore.
The mindset: blast radius first
Everything below flows from one idea. On a shared host you are not protecting a site, you are protecting the neighbors from each other. A single tenant will always be the weakest link, because you do not control their code, their WordPress plugin graveyard, or their password hygiene. So you assume one tenant will get compromised or will misbehave, and you design so that when it happens, it stays inside that tenant’s box.
That framing collapses the usual split between “security work” and “performance work.” Fencing a tenant so its compromised PHP cannot read another tenant’s files is the same act as fencing it so its runaway process cannot eat all the CPU. Both are about bounding what one tenant can consume and reach. Keep that lens on and the whole config below reads as one coherent thing instead of two checklists.
SSH and the edge: keep it boring
This is the warm-up. It matters, but it is well trodden, so I keep it tight. The edge of the box should expose exactly what you serve and nothing else.
Key-only SSH, no root login, and an explicit allowlist of who gets in:
# /etc/ssh/sshd_config.d/hardening.conf
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
AllowUsers deploy admin
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 20
Then a default-deny firewall that opens only the ports you actually serve. On a web box that is 80, 443, and SSH, full stop. Everything administrative binds to localhost or a private interface, never the public one. Your database, your control panel’s internal bits, your mail admin, your Redis: none of those should answer on a routable address.
# nftables, default deny inbound, allow only what you serve
nft add table inet filter
nft add chain inet filter input '{ type filter hook input priority 0; policy drop; }'
nft add rule inet filter input ct state established,related accept
nft add rule inet filter input iif lo accept
nft add rule inet filter input tcp dport { 22, 80, 443 } accept
nft add rule inet filter input ip protocol icmp accept
Why it matters: every listening port is a door. If a service does not need to face the internet, do not let it. Bind MySQL to 127.0.0.1, bind your panel’s admin interface to a private interface or localhost, and put a real firewall in front so a service that accidentally starts listening on 0.0.0.0 still is not reachable.
Add fail2ban on both sshd and your control panel login. Bots find panel login pages within hours of a box going live, and the panel is often the softest target because tenants pick weak passwords there.
# /etc/fail2ban/jail.d/local.conf
[sshd]
enabled = true
maxretry = 4
bantime = 1h
findtime = 10m
[panel]
enabled = true
port = http,https
filter = panel-auth
logpath = /var/log/panel/access.log
maxretry = 5
bantime = 1h
That is the edge. It stops the drive-by noise. The real isolation work is inside.
Per-tenant isolation: the core of the whole thing
If you take one thing from this post, take this section. The single most important decision on a shared box is that each tenant is a separate system user, and nothing customer-facing runs as root.
One user per tenant, home directory owned by that user, and permissions that do not let one tenant read another’s files. The classic mistake is leaving everything group-readable or, worse, world-readable, so a compromised tenant can wander the filesystem reading config files full of database passwords.
useradd -m -d /srv/www/tenant42 -s /usr/sbin/nologin tenant42
chown tenant42:tenant42 /srv/www/tenant42
chmod 750 /srv/www/tenant42
The 750 matters. The tenant owns their tree, the group (your web stack, if you scope it that way) can traverse, and the rest of the world, meaning every other tenant, gets nothing. Give tenants a nologin shell unless they genuinely need SSH, and if they do, that is a separate conversation about chroot or SFTP-only.
Now the important part: one PHP-FPM pool per tenant, running as that tenant’s user. This is what stops tenant A’s PHP process from touching tenant B’s files at all. A shared pool running as a single www-data user means any compromised site can read every other site on the box, because the OS sees one identity. Separate pools give the kernel a real identity to enforce against.
Here is a real pool. This is the file I actually write, one per tenant:
; /etc/php-fpm.d/tenant42.conf
[tenant42]
user = tenant42
group = tenant42
listen = /run/php-fpm/tenant42.sock
listen.owner = tenant42
listen.group = nginx
listen.mode = 0660
pm = ondemand
pm.max_children = 8
pm.process_idle_timeout = 10s
pm.max_requests = 500
request_terminate_timeout = 60s
php_admin_value[open_basedir] = /srv/www/tenant42:/tmp/tenant42
php_admin_value[disable_functions] = exec,system,passthru,shell_exec,proc_open,popen,proc_nice,pcntl_exec,dl,mail
php_admin_value[upload_tmp_dir] = /srv/www/tenant42/tmp
php_admin_value[session.save_path] = /srv/www/tenant42/tmp
A few things earn their place here.
pm = ondemand: idle tenants cost nothing, no children sitting around holding memory for a site that gets three visits a day. On a box with hundreds of mostly-idle tenants, this is the difference between fitting and swapping. Busy tenants can usepm = dynamic, but then you sizepm.max_childrento memory (below).request_terminate_timeout: kills a runaway PHP request, so one infinite loop does not pin a worker forever.open_basedir: fences PHP’s filesystem view to that tenant’s tree plus a private temp. Even a working shell exploit cannotreadfile()its way into a neighbor.disable_functions: removes the process-spawning primitives that turn a PHP RCE into a full shell. You will get the odd ticket from someone whose backup plugin wantedexec. That is a feature, not a bug: you now know that plugin exists.
The listen socket is a Unix socket owned by the tenant, group-readable by nginx only. TCP sockets for FPM are fine too, but Unix sockets skip the loopback stack and keep the permission model tight.
Resource limits: one tenant cannot starve the rest
Isolation stops tenants reading each other. Limits stop them starving each other. These are different failures and you need both.
Start with the boring one that bites everyone eventually: the default open-file limit. Most distros still ship a per-process soft limit of 1024 open files. A busy nginx or a PHP-FPM pool under load blows through 1024 file descriptors without breaking a sweat, and when it does, you get cryptic Too many open files errors in the logs and requests that just fail. Raise it deliberately.
# /etc/security/limits.d/hosting.conf
* soft nofile 65535
* hard nofile 65535
nginx soft nofile 65535
nginx hard nofile 65535
# cap processes per tenant so a fork bomb hits its own wall
tenant42 soft nproc 256
tenant42 hard nproc 512
nofile is open file descriptors. nproc is number of processes, and capping it per tenant means a fork bomb in one tenant’s code exhausts that tenant’s process budget and then dies, instead of taking the box down. Note that services started by systemd do not read limits.conf, they read their own unit’s LimitNOFILE, so set that too:
# /etc/systemd/system/php-fpm.service.d/limits.conf
[Service]
LimitNOFILE=65535
Now the heavier hammer: systemd cgroup limits. limits.conf caps per-process things, but it will not stop a tenant from running many processes that collectively eat all the CPU or all the RAM. Cgroups do. Put each tenant’s FPM pool in a slice with a hard ceiling.
# /etc/systemd/system/php-fpm@tenant42.service.d/cgroup.conf
[Service]
CPUQuota=50%
MemoryMax=512M
TasksMax=100
CPUQuota=50% means half of one core, sustained. MemoryMax=512M is a hard wall: the pool’s cgroup gets OOM-killed inside its own limit rather than dragging the whole box into swap and taking the neighbors with it. TasksMax caps the total task count in the slice. Why it matters: without this, “noisy neighbor” is not a metaphor, it is a Tuesday. One tenant with a runaway cron or a crypto-miner-flavored compromise will happily consume everything you let it. Cgroups turn “everything” into “its allotment.”
Kernel and sysctl tuning: the part people skip
This is where a shared host quietly lives or dies, and it is the part most people never touch because the defaults boot fine and look fine right up until the box is under real load. The defaults are conservative and general. A busy multi-tenant web and database host is a specific, demanding workload. Here is a /etc/sysctl.d file close to what I actually deploy, and then I will walk every line.
# /etc/sysctl.d/90-hosting.conf
# --- File descriptors ---
fs.file-max = 2097152
fs.nr_open = 2097152
fs.inotify.max_user_watches = 524288
# --- Network: many short-lived connections ---
net.core.somaxconn = 4096
net.core.netdev_max_backlog = 16384
net.ipv4.tcp_max_syn_backlog = 8192
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_max_tw_buckets = 1440000
# --- Virtual memory behaviour ---
vm.swappiness = 10
vm.dirty_ratio = 15
vm.dirty_background_ratio = 5
vm.overcommit_memory = 0
# --- System V IPC: semaphores and shared memory ---
kernel.sem = 250 32000 100 128
kernel.shmmax = 8589934592
kernel.shmall = 2097152
File descriptors.
fs.file-max: the system-wide ceiling on open files across all processes. On a box with hundreds of FPM children plus nginx plus a database, the aggregate count gets big fast, and this cap is separate from the per-processnofileyou set earlier.fs.nr_open: the ceiling on what any single process is even allowed to request. It must be at least as high as your per-processnofile, or the limit silently caps lower.fs.inotify.max_user_watches: deploy tools, editors and file-sync agents watch files. Many tenants each watching their tree exhaust the default (often 8192) and throw baffling “no space” errors from tools that are really just out of inotify watches.
Network stack for many short connections. Web serving is a firehose of short-lived TCP connections, and the setup path has its own queues the defaults size for a quieter machine.
net.core.somaxconn: the accept queue (handshake done, waiting for your app toaccept()). Small queue plus a burst means dropped connections.net.core.netdev_max_backlog: packets the NIC received but the kernel has not processed yet.net.ipv4.tcp_max_syn_backlog: the half-open queue during the handshake. Matters for legitimate bursts and for SYN floods.net.ipv4.tcp_tw_reuse: reuse sockets stuck in TIME_WAIT for new outbound connections. Valuable when the box makes many outbound connections (database, upstreams, APIs) and would otherwise run out of local ports.net.ipv4.tcp_fin_timeout: shortens how long a socket sits in FIN-WAIT, so ports come back faster.net.ipv4.ip_local_port_range: widens the ephemeral port pool so you do not exhaust source ports under high outbound concurrency.net.ipv4.tcp_max_tw_buckets: caps how many TIME_WAIT sockets pile up before the kernel recycles them instead of eating memory.
Virtual memory behaviour.
vm.swappiness = 10: strongly prefer reclaiming page cache over swapping process memory. You want FPM and database working sets in RAM. Swapping on a shared host feels like the machine got poured in mud, and every tenant notices at once. I do not use 0 (a little swap headroom is a safety valve), but I keep it low.vm.dirty_ratioandvm.dirty_background_ratio: how much modified data can sit in memory before the kernel forces it to disk. The defaults let a big pile accumulate and then flush all at once, which shows up as periodic latency spikes. Lower values (background flush at 5 percent, hard limit at 15 percent) smooth writeback into a trickle instead of a stall.vm.overcommit_memory = 0: keep the default heuristic overcommit. It lets many mostly-idle FPM pools coexist without the kernel refusing allocations they will never touch.
System V IPC: the part everyone forgets. Here is the one that will cost you an evening if you have never seen it.
Databases and some apps do not just use plain memory and sockets. They use System V IPC: semaphores to coordinate access, and shared memory segments for shared buffers. MySQL and MariaDB use them, PostgreSQL leans on them heavily, and various app servers do too.
The kernel ships with modest defaults. On a busy shared host with many database connections and many processes, you can exhaust the semaphore or shared-memory limits. And the errors look nothing like the real cause: semget failed, or the infamous No space left on device from a process that has plenty of actual disk. People go hunting for a full partition that does not exist while the real problem is a drained semaphore array.
kernel.sem is four numbers in a specific order: SEMMSL SEMMNS SEMOPM SEMMNI. In kernel.sem = 250 32000 100 128:
- SEMMSL = 250 is the max number of semaphores per array (per set).
- SEMMNS = 32000 is the max number of semaphores system-wide, across all sets.
- SEMOPM = 100 is the max operations allowed in a single
semop()call. - SEMMNI = 128 is the max number of semaphore arrays (sets) system-wide.
The two that bite first are usually SEMMNI (you run out of arrays because many DB connections and processes each want their own) and SEMMNS (the total pool is drained). If your database docs recommend specific values, use theirs; they know their allocation pattern. kernel.shmmax caps the size of a single shared memory segment in bytes, and kernel.shmall caps the total shared memory system-wide, counted in pages (usually 4KB each), so shmall * pagesize is your real ceiling. If a database wants a large shared buffer and shmmax is too small, it either fails to start or silently shrinks its buffers.
Inspect what you actually have before and after:
# current semaphore and shared memory limits
ipcs -ls
ipcs -lm
sysctl kernel.sem kernel.shmmax kernel.shmall
# what is actually in use right now
ipcs -u
Apply the whole file and check it took:
sysctl --system
sysctl kernel.sem
# -> kernel.sem = 250 32000 100 128
sysctl --system reads every drop-in under /etc/sysctl.d and friends in order, so your 90-hosting.conf is the one talking. Do not hand-edit /etc/sysctl.conf and wonder why a drop-in overrode it later.
Service and app performance under load
Kernel tuned, now the services on top of it. I am opinionated here on purpose: there are three or four knobs per service that carry almost all the weight, and a hundred others that do not. Chase the few.
nginx. Set worker_processes auto so nginx spawns one worker per CPU core, and raise worker_connections because the default (often 512 or 768) is low for a busy box. The rough ceiling of concurrent connections is workers times connections, and each connection needs a file descriptor, which is exactly why you raised nofile earlier. It all chains together.
# /etc/nginx/nginx.conf
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 8192;
multi_accept on;
}
PHP OpCache. This is the single highest-leverage PHP setting on a hosting box and it is off or under-provisioned more often than it should be. OpCache keeps compiled PHP bytecode in memory so the interpreter does not re-parse and re-compile every file on every request. On a box running WordPress and friends across many tenants, that is enormous.
; /etc/php.d/10-opcache.ini
opcache.enable = 1
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 100000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
memory_consumptionis per FPM master. Size it to how many distinct PHP files live across your tenants; 128 to 256 MB is a sane start for a busy box.max_accelerated_filesis how many scripts it caches. With many tenants each shipping thousands of files, the default (a few thousand) is way too small, so push it up.validate_timestampsis the interesting one. In a single-tenant app you control, set it to 0 in production (fastest, but you must flush on every deploy). On shared hosting, tenants upload whenever they feel like it, so 0 means their edits never take effect until you flush, which generates tickets. I keep it at 1 withrevalidate_freqof 30 to 60 seconds: changes show up within a minute, and you only pay a stat check that often, not on every request.
PHP-FPM sizing. The number that matters is pm.max_children, and you size it to memory, not to hope.
Measure the real average resident size of one FPM child under load (ps or your monitoring will tell you, call it 60 to 80 MB for a typical WordPress stack), decide how much RAM you will give PHP total, and divide. Give PHP 8 GB with 80 MB children and that is roughly 100 children across the box, split across pools.
Setting max_children higher than memory allows is how you turn a traffic spike into a swap storm: FPM spawns children it cannot fit, the box swaps, everything crawls, and the OOM killer starts making decisions you would not have. Cap it at what fits, and let requests queue rather than thrash.
MySQL / MariaDB. Two knobs carry the day.
innodb_buffer_pool_size is the pool InnoDB uses to cache data and indexes, and it is the most important database setting on the box. Too small and every query hits disk. Sized right and your hot working set lives in RAM. On a dedicated DB box you give it most of the RAM. On a shared box that also runs nginx and FPM, you carve out a share, commonly a quarter to a half of total RAM depending on how hungry the web tier is.
max_connections: keep it sane. Every connection costs memory and, relevant to the earlier section, adds semaphore and shared-memory pressure. Setting it to some huge number does not help, it just lets a connection storm exhaust memory and IPC resources. If you exhausted kernel.sem and got mysterious semget failures, this is the loop closing: many DB connections plus modest kernel IPC defaults is exactly the combination that trips it.
# /etc/my.cnf.d/tuning.cnf
[mysqld]
innodb_buffer_pool_size = 4G
innodb_log_file_size = 512M
max_connections = 200
innodb_flush_log_at_trx_commit = 1
Filesystem and the small wins
Mount the filesystems that hold web content and logs with noatime. By default Linux updates a file’s access time every time it is read, which on a busy web root means a write for every read: pure overhead you get nothing for. noatime turns it off and it is free performance.
# /etc/fstab
UUID=... /srv ext4 defaults,noatime 0 2
UUID=... /tmp ext4 defaults,noatime,nosuid,nodev 0 2
Keep /tmp and tenant data on their own space so one tenant filling a disk with uploads or a runaway log does not take down / and freeze the whole box. A separate /tmp mounted nosuid,nodev also removes a favorite staging ground for exploits that drop a payload in world-writable temp and try to run it.
And read your logs. Not “have logging configured,” actually read them. Watch auth.log (or the journal) for the SSH brute-force patterns, watch your firewall’s dropped-packet counts, watch fail2ban’s ban list. Rotate logs so they do not fill the disk, but do not rotate them into oblivion; you want enough history to see the slow patterns. Most of the compromises I have cleaned up left a trail in the logs days before anyone noticed. The logs were doing their job. Nobody was reading them.
Backups you have actually restored
I will keep this short because the point is short. A backup you have never restored is a rumor, not a backup. Take them off the box, because a backup on the same machine dies with the machine, and encrypt them, because a backup of a shared host is a tarball of everyone’s data and passwords. Then, on a schedule, actually restore one to a scratch box and confirm the sites come up and the databases import clean. The first time you learn your backups are silently truncated or missing a table cannot be the night you need them. Automate the restore drill if you can. The confidence is worth more than the automation cost.
Final takeaway
Hardening and tuning a shared host are the same job seen from two angles, and the angle is always the same question: what can one tenant consume, and what can one tenant reach. Answer it at every layer. Fence tenants into their own users and FPM pools so a compromise stays local. Cap them with ulimits and cgroups so a runaway loop dies in its own slice. Tune the kernel so the box has the file descriptors, the connection queues, and the IPC semaphores to carry many tenants at once, because the defaults were written for a quieter machine than yours. Then pick the few app knobs that carry real weight and leave the rest alone. Start conservative, change one thing at a time, watch what the box does under real load, and write down what you changed. The machine will tell you what it needs if you are actually watching. Most of this job is watching.