Skip to content
Guilherme Nogueira
Go back

Hardening and Tuning a Shared Hosting Linux Box

19 min read

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

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.

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.

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.

Virtual memory behaviour.

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:

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

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.


Share this post:

Previous Post
It Is Always DNS. This Time It Was Hiding in the Defaults.
Next Post
Diskless Workstations With PXE and iSCSI