Targeted sudo rules instead of blanket root access, and why SUID does not work on scripts
Deployment scripts in practice often run with far more privileges than needed, usually because a blanket sudo grant feels more convenient than a precise rule. Taking least privilege seriously means writing targeted sudoers entries per script and skipping the SUID bit on shell scripts, which modern Linux kernels ignore for good reason.
Table of Contents
- 1. Least privilege for deployment scripts: the core problem
- 2. Precise sudoers rules instead of a blanket ALL=(ALL)
- 3. Calling sudo for specific steps instead of running the whole script as root
- 4. Why the SUID bit on a Bash script has no effect
- 5. Alternatives to the SUID bit: small C wrappers and sudo policies
- 6. Linux capabilities as a fine-grained alternative to root
- 7. Making sudo usage traceable: logging and auditing
- 8. Practical example: a least-privilege deployment setup for Magento
- 9. sudo, SUID, and capabilities compared
- 10. Summary
- 11. FAQ
1. Least privilege for deployment scripts: the core problem
A deployment script rarely truly needs full root access. Usually it is enough to write a handful of files with certain permissions, restart a service, or bind a single privileged port. Yet such scripts regularly end up with a sudoers line like deploy ALL=(ALL) NOPASSWD: ALL in practice, because that is faster to set up than a precise rule tailored to the script.
The problem is not theoretical: if the account running the script gets compromised, for example through a vulnerability in one of the applications it calls, an attacker immediately inherits full root access to the entire server, instead of being confined to the actually needed, limited set of actions. Least privilege means keeping that blast radius as small as possible from the start, not restricting it after the fact once it is already too late.
2. Precise sudoers rules instead of a blanket ALL=(ALL)
A targeted sudoers rule allows exactly the commands a script actually needs, with exact arguments or argument patterns, instead of a blanket free pass. A file like /etc/sudoers.d/deploy-nginx-reload can specify that the deploy user may run only systemctl reload nginx without a password, but neither systemctl stop nor restart any other arbitrary service.
It matters to specify the full path to the command and avoid wildcards that grant more than intended. An entry like deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx cannot be extended with additional arguments, while /usr/bin/systemctl * effectively grants full access to every systemctl subcommand again, including potentially dangerous ones like systemctl edit.
# /etc/sudoers.d/deploy-nginx-reload -- edit only with visudo -f
# Precise: exact command, exact arguments, no wildcards
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart php8.4-fpm
# Bad: grants full control over every systemctl subcommand
# deploy ALL=(root) NOPASSWD: /usr/bin/systemctl *
3. Calling sudo for specific steps instead of running the whole script as root
Instead of starting a whole deployment script as root, it is more robust to run the script mostly as an unprivileged user and only call the individual steps that genuinely need elevated rights through targeted sudo invocations. This significantly reduces the attack surface, because a bug or vulnerability in the bulk of the script does not automatically translate into full root access.
In practice this means splitting a script into clearly separated function blocks: build steps, file copy operations, and health checks run as the normal deployment user, while only restarting a service or writing into a root-owned directory goes through a precise sudo call that is exactly whitelisted in the sudoers file.
#!/usr/bin/env bash
set -euo pipefail
# Runs as the unprivileged 'deploy' user, no sudo needed here
rsync -a --delete ./build/ /var/www/app/
bin/magento setup:upgrade
# Only this single step needs elevated rights, scoped by sudoers
sudo /usr/bin/systemctl reload nginx
curl -fsS https://shop.example.com/health >/dev/null
4. Why the SUID bit on a Bash script has no effect
A natural but wrong idea is to set the SUID bit on a script like a compiled binary (chmod u+s script.sh), expecting it to automatically run with its owner's privileges. For a real ELF binary that actually works, but for a script with a shebang line, the Linux kernel deliberately ignores the SUID bit and keeps running it with the privileges of the invoking user instead.
The reason is a race condition known for decades: between the moment the kernel reads the shebang line and starts the matching interpreter, and the moment that interpreter actually opens the script, an attacker can swap out the file (time-of-check-to-time-of-use). A SUID interpreter would then run an arbitrary, attacker-controlled script with elevated privileges. The Linux kernel therefore disables the SUID bit categorically for all interpreted scripts.
# This has NO effect on Linux for a script with a #!/bin/bash shebang
chmod u+s /usr/local/bin/deploy.sh
ls -l /usr/local/bin/deploy.sh
# -rwsr-xr-x 1 root root ... deploy.sh <- bit is set, kernel ignores it
# Verify: still runs as the invoking user, not as root
whoami
/usr/local/bin/deploy.sh # prints the caller's UID, not root's
5. Alternatives to the SUID bit: small C wrappers and sudo policies
Anyone who genuinely needs a SUID-like privilege elevation for one specific action, such as binding a privileged port or writing to a root-owned file, should write a minimal, compiled C wrapper that performs exactly that one action and then immediately drops its privileges again (setuid/seteuid in code, not through the filesystem bit). Such a wrapper is auditable, small, and unaffected by the shebang race condition, because it is a real binary.
For the vast majority of deployment cases, though, a C wrapper is unnecessary overhead when a precise sudoers rule achieves the same result without extra code to maintain. The rule of thumb: sudo for actions another person can review in plain text and version in a text file, a wrapper only for the rare cases where even sudo is too coarse-grained.
6. Linux capabilities as a fine-grained alternative to root
Instead of granting a process blanket root rights, Linux capabilities allow unlocking exactly one specific privileged ability, such as CAP_NET_BIND_SERVICE to bind a port below 1024, without receiving every other root privilege. With setcap, this ability can be bound directly to a binary, which represents a noticeably finer alternative to SUID for compiled programs.
For plain Bash scripts, setcap does not apply directly for the same reason as SUID, since capabilities are also bound to an executable binary, not an interpreted script. Anyone needing capabilities for a script-driven action has to move the actual privileged operation into a small compiled helper and assign it the matching capability, while the Bash script itself stays unprivileged.
# Grant only the ability to bind privileged ports, nothing else
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/http-helper
# Verify which capabilities a binary actually carries
getcap /usr/local/bin/http-helper
# /usr/local/bin/http-helper cap_net_bind_service=ep
7. Making sudo usage traceable: logging and auditing
Every action run through sudo lands in the system log by default, usually via syslog or journald, including timestamp, invoking user, and the exact command. For deployment scripts, it pays off to review these logs deliberately, to spot whether a script gets called with unexpected arguments regularly or whether a sudo rule is more generous than what is actually used.
With sudo -l, any user's currently allowed commands can be checked at any time, which makes an excellent recurring audit step, especially right before a new sudoers rule goes live in production. A rule that, after a few months of logs, was never used with its full argument range should be tightened, instead of staying broad just because that is how it was originally set up.
8. Practical example: a least-privilege deployment setup for Magento
In a typical Magento deployment, the bulk of the work, composer install, static content deploy, cache flush, runs entirely as the owning www-data or deployment user with no elevated rights at all. Only two steps genuinely need elevated privileges: restarting PHP-FPM after an opcache-relevant update, and, occasionally, fixing file ownership after a composer install that accidentally left the wrong owner behind.
For exactly these two cases, two precise sudoers lines are entirely sufficient, without the deployment user ever needing full root rights. This pattern transfers to practically any deployment script: sketch out the full flow without sudo first, then identify only the actually privileged individual steps, and write exactly one sudoers line for each of them.
# /etc/sudoers.d/deploy-magento
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart php8.4-fpm
deploy ALL=(root) NOPASSWD: /usr/bin/chown -R www-data\:www-data /var/www/app/pub/static
#!/usr/bin/env bash
set -euo pipefail
composer install --no-dev --optimize-autoloader
bin/magento setup:static-content:deploy -f
sudo /usr/bin/chown -R www-data:www-data /var/www/app/pub/static
sudo /usr/bin/systemctl restart php8.4-fpm
9. sudo, SUID, and capabilities compared
All three mechanisms solve the same basic problem, giving a process more rights than its invoking user has, but they differ significantly in security, auditability, and fitness for shell scripts. sudo is the only practical, directly usable option for scripts, while SUID stays ineffective on scripts and capabilities only apply to compiled helpers.
The table below summarizes which mechanism fits which use case, and makes clear why sudo with precise rules remains the correct, and only directly effective, choice for the vast majority of Bash deployment scripts.
| Mechanism | Effective on Bash scripts | Granularity | Typical use |
|---|---|---|---|
| sudo with a precise rule | Yes, directly | Per command and argument | Individual privileged deployment steps |
| SUID bit | No, ignored by the kernel | Not applicable to scripts | Only relevant for compiled binaries |
| Linux capabilities | No, binaries only | Very fine, per capability | Compiled helpers for single privileges |
| sudoers ALL=(ALL) | Yes, but unsafe | None, full access | Should practically never be used |
| Compiled C wrapper | Yes, with setuid in code | Exactly one defined action | Rare cases where sudo is too coarse |
Mironsoft
Shell automation, DevOps tooling and deployment infrastructure
Shell scripts that hold up in production?
We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.
Code Review
ShellCheck analysis and manual review for critical Bash pattern violations.
Refactoring
Retrofitting error handling, logging and safe file operations.
CI Integration
Wiring ShellCheck and BATS into pipelines and building regression tests.
10. Summary
Least Privilege for Bash Scripts: The Essentials at a Glance
Core rule
Every deployment script should run mostly unprivileged and only elevate individual steps through sudo.
sudoers
Register exact commands with full paths and no wildcards, never use ALL=(ALL) for a deployment user.
SUID myth
chmod u+s on a Bash script has no effect on Linux, the kernel ignores the bit because of the shebang race condition.
Alternatives
For genuine privilege elevation outside sudo, use small compiled C wrappers or Linux capabilities via setcap.