Bash Permission Denied: Fix +x and Shebang Fast
Add execute rights with chmod +x script.sh, then run ./script.sh.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
- ✓A Linux shell with a script you can chmod freely
- ✓Basic comfort with ls -l output and sudo access for mount checks
- ✓A text editor that shows or converts line endings
- 'Permission denied' when running ./script.sh almost always means the file lacks the execute bit: fix it with chmod +x script.sh
- You can also run it without the bit via bash script.sh, which reads the file instead of executing it — handy for a quick test
- Check the shebang line (#!/bin/bash) and line endings: a Windows CRLF or a wrong interpreter path fails even with +x set
- If +x won't stick, suspect a noexec mount, a read-only filesystem, or wrong ownership — and don't reflexively sudo past it
Think of a script as a recipe card. Reading it aloud yourself (bash script.sh) always works. But asking the kitchen to execute it on its own (./script.sh) requires a signed approval stamp — the execute bit. Without the stamp, the kitchen refuses. A wrong stamp (bad shebang), a card in a locked display case (noexec mount), or someone else's card you can't stamp (wrong ownership) all produce the same refusal with different fixes.
You wrote a deploy script, made it beautiful, typed ./deploy.sh — and Bash answered 'Permission denied'. Your code is fine. Your logic is fine. The operating system simply refused to execute the file, and it did so for one of a short list of reasons: the execute bit is missing, the shebang points nowhere, the filesystem forbids execution, or you don't own the file.
This error is the kernel's exec gatekeeper doing its job. Running ./script.sh asks the OS to load the file as a program, which requires the execute permission on your user class, a valid interpreter in the shebang, and a filesystem mounted with exec allowed. Fail any one check and you get the same two words. Running it as bash script.sh bypasses the gate entirely by reading the file as data — useful, but it masks the real problem.
Diagnosis takes under a minute: ls -l for the bit, head -1 plus file for the shebang, mount plus ownership when the bit won't stick. The dangerous move is sudo ./deploy.sh as a first resort, which executes unknown code with full privileges and teaches nothing. This guide walks the checklist in order — bit, interpreter, mount, ownership — so you fix the actual gate that's closed.
The Execute Bit: the Gate Behind 90% of Cases
Unix files carry three permission classes — owner, group, others — each with read, write, and execute slots. Running ./script.sh requires the execute slot for your class; without it the kernel refuses before reading a single line. New files arrive at 644 (rw-r--r--) under a standard 022 umask: readable by all, executable by none. Downloads, editor saves, and cp copies all inherit this, which is why fresh scripts fail on first run with such consistency.
chmod +x script.sh flips the execute slot for all three classes (equivalent to a+x), producing 755 on a 644 file. Prefer explicit chmod 755 for scripts you publish — it states the full mode rather than toggling relative to whatever exists. Verify with test -x script.sh, which exits true exactly when the kernel would permit your exec. Git tracks only this bit (plus the file/dir distinction), so committing a 755 script preserves executability through every clone — the incident team's local copies worked for precisely this reason.
Reserve 755 for files meant to run and 644 for data. World-writable scripts (777) invite tampering, and setuid bits on shell scripts are ignored by the kernel on Linux — a classic trap where chmod 4755 appears to grant elevation but changes nothing. Match the mode to the intent, and most refusals disappear.
Run via Bash vs Execute Directly: Know the Bypass
bash script.sh and ./script.sh reach the same lines through different doors. The first opens the file as data and feeds it to an already-running interpreter — no execute bit needed, no shebang consulted, current shell's Bash version in charge. The second asks the kernel to exec the file, which checks the bit, reads the shebang, and launches the named interpreter. Same text, different gatekeepers, different failure modes.
The bypass is a superb diagnostic: if bash script.sh works but ./script.sh refuses, the code is fine and the gate is the problem — bit, shebang, or mount. It's also the safe way to run a downloaded script once without blessing it executable permanently. But never treat it as the fix for scripts others will run. CI jobs, cron entries, and teammates' checkouts all invoke ./script.sh, so a script that 'works with bash' still breaks every automated caller until its own gate opens.
Watch for behavior drift between the doors. A script with #!/bin/sh behaves differently under ./ (dash on Ubuntu) versus bash script.sh (Bash extensions available). Arrays, [[ ]], and process substitution work in the second and explode in the first. Either fix the shebang to #!/usr/bin/env bash and exec directly, or write POSIX-clean code — but don't let the two doors disagree silently.
Shebang and Line Endings: Executable but Unrunnable
With the bit set, the kernel reads the first line for #! plus an interpreter path and execs that program with your script as input. Three defects hide here. A missing shebang makes exec fall back to /bin/sh, where Bash-isms die mysteriously. A wrong path (#!/bin/bash on NixOS, minimal containers, or macOS-with-Homebrew layouts) fails with 'bad interpreter: No such file or directory' — permission-looking, but really a missing binary. And Windows CRLF endings append a carriage return to the interpreter path, so the kernel hunts for /bin/bash\r and reports it missing.
Diagnose with two commands: head -1 script.sh shows what the kernel sees, and file script.sh reports CRLF, missing newlines, and encoding. Resolve CRLF with dos2unix or a sed strip, and prefer #!/usr/bin/env bash over hard paths — env searches PATH, surviving across distros and containers. Verify the interpreter exists with command -v bash before blaming anything else.
Editors and Git cause most CRLF cases: a Windows checkout plus an over-eager autocrlf setting, or a file pasted through a web UI. Lock it down with .gitattributes (*.sh text eol=lf) so every clone lands Unix-clean regardless of platform. One attribute line prevents an entire category of 'works on my machine' exec failures.
Noexec Mounts and Read-Only Filesystems
When chmod +x won't stick or exec fails on a 755 file, the filesystem is vetoing execution. Mount option noexec blocks all exec on that mount — common on /tmp, removable media, and hardened partitions — regardless of bits. Read-only mounts (ro) block chmod itself, since mode changes are writes. Both produce permission-shaped errors with nothing wrong in the file's own metadata.
Confirm with mount | grep showing noexec on the path's filesystem, or findmnt -T . for the exact mount entry. df -h . names the device when the mount table is noisy. For noexec, the fixes are remount with exec (mount -o remount,exec, when policy allows) or relocate the script to an exec-allowed path like /usr/local/bin or the app directory. For ro, remount rw if appropriate — or accept read-only and run from a writable copy.
Never stage executables in /tmp on hardened hosts; it's noexec by policy in most CIS benchmarks. CI workspaces, artifact directories, and /opt/app are the honest homes. When containers are involved, check the volume mount flags too — a host noexec bind-mount vetoes exec inside the container identically. Document allowed exec paths in your hardening baseline so developers stop discovering them via failures.
Ownership, sudo, and Running as the Right User
Only the owner (or root) can chmod a file, so 'Operation not permitted' on chmod means someone else owns it — a root-created artifact, a coworker's copy, an extracted tarball. ls -l names the owner; either take ownership with chown (when policy allows) or have the owner set the bit. The immutable attribute (lsattr showing 'i') blocks even the owner until chattr -i clears it — rare, but unforgettable once seen.
Resist sudo ./script.sh as a reflex. It executes the entire script with full privileges, bypassing the ownership question instead of answering it, and any bug or malice inside now runs as root. Worse, files the script creates come out root-owned, seeding the next permission failure. Sudo is for identified privilege needs (binding port 80, writing /etc), granted to minimal commands — never a blanket around an unexamined script.
Run automation as dedicated service users with exactly the rights the job needs, and set ownership at install time (install -o app -m 755). Cron and systemd encode the user explicitly (crontab owner, User= in units), so the exec context is declared, not accidental. When the right user owns an executable file on an exec mount, the gate opens quietly every time.
Harden the Pipeline So Bits Survive Shipping
The incident's real bug lived in packaging, not on any host — so the permanent fix lives there too. Build tarballs with preserved modes (tar --preserve-permissions or -p on extract), publish zip artifacts with executable attrs intact, and gate CI with test -x over every file under scripts/ plus bash -n syntax checks. A one-line gate that fails the build on a missing bit converts 23-host outages into red pull requests.
Teach Git to carry the bit: chmod +x before the first commit recording, and review mode changes (old mode/new mode lines in diffs) as carefully as content changes. A PR that flips deploy.sh to 644 deserves the same scrutiny as one deleting a deploy step. Pair with .gitattributes eol=lf and an env shebang standard, and scripts arrive executable, Unix-clean, and portable on every clone.
Document the triage order where on-call eyes find it: bit (ls -l, test -x), interpreter (head -1, file), mount (findmnt, noexec?), ownership (ls -l, lsattr). Five commands, one minute, in likelihood order. Teams that print this on the deploy runbook stop losing Fridays to two-word errors. Review the runbook after every related incident so each outage sharpens the checklist instead of repeating it.
Deploy Script Lost +x in the Tarball and Blocked Releases for 52 Minutes
- Ship modes, not just bytes. Packaging pipelines must preserve and verify execute bits — a test -x gate in CI costs one line and would have caught this before a single host saw the tarball. Treat file metadata as release content.
- Read 'Permission denied' as metadata first, code second. ls -l plus test -x takes five seconds and answers the most common cause immediately. Debugging script contents before checking the bit burns incident minutes on the wrong layer.
- Never sudo past an exec refusal you don't understand. Passwordless sudo would have 'fixed' this by executing as root — hiding the packaging bug and granting a release script full privileges it never needed.
| File | Command / Code | Purpose |
|---|---|---|
| bypass-vs-exec.sh | bash scripts/deploy.sh | Run via Bash vs Execute Directly |
| fix-shebang.sh | head -1 scripts/deploy.sh | Shebang and Line Endings |
| check-mounts.sh | findmnt -T . | Noexec Mounts and Read-Only Filesystems |
| fix-ownership.sh | ls -l scripts/deploy.sh | Ownership, sudo, and Running as the Right User |
| gate-executables.sh | for f in scripts/*.sh; do | Harden the Pipeline So Bits Survive Shipping |
Key takeaways
Common mistakes to avoid
6 patternsDebugging script contents before checking the bit
Running sudo ./script.sh as a first resort
chmod 777 to 'make it work'
Treating bash script.sh as the permanent fix
Staging executables in /tmp on hardened hosts
Shipping tarballs that strip modes
Interview Questions on This Topic
You get 'Permission denied' running ./deploy.sh. What's your first three commands?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Linux. Mark it forged?
5 min read · try the examples if you haven't