Two ways to start a script

Every script can be started in two ways: hand the file to its script host yourself (bash check-disk.sh, pwsh -File check-disk.ps1), or run the file as if it were a program (./check-disk.sh, ./check-disk.ps1) and let the system find the host. The second way is what makes a script feel like a real command, and it is where the rules on this page come in. Here is a small disk check, run as a program, in both shells:

cat > check-disk.sh <<'X'
#!/bin/bash
# Warn when a usage percentage is above the threshold.
usage=$1
threshold=90
if (( usage > threshold )); then
  echo "WARNING: disk at ${usage}%"
  exit 1
fi
echo "OK: disk at ${usage}%"
X
chmod +x check-disk.sh
./check-disk.sh 72
./check-disk.sh 91
echo "exit status: $?"
OK: disk at 72%
WARNING: disk at 91%
exit status: 1
@'
param([int]$Usage)
if ($Usage -gt 90) {
  "WARNING: disk at $Usage%"
  exit 1
}
"OK: disk at $Usage%"
'@ | Set-Content check-disk.ps1

./check-disk.ps1 -Usage 72
./check-disk.ps1 -Usage 91
"LASTEXITCODE: $LASTEXITCODE"
"The calling script carries on."
OK: disk at 72%
WARNING: disk at 91%
LASTEXITCODE: 1
The calling script carries on.

Both scripts report a full disk with exit code 1. The Bash script reads its argument as $1; the PowerShell script declares a named, typed parameter with param([int]$Usage) (more on Functions and Parameters). The examples create their script files with a here document or a here-string so that each one runs on its own; in practice you write them in an editor.

Bash: you type ./check-disk.sh 91, the kernel checks execute permission and reads the shebang, starts /bin/bash with the file, and exit 1 comes back to the caller as $?. PowerShell: you type ./check-disk.ps1 -Usage 91, PowerShell checks the .ps1 extension and, on Windows, the execution policy, runs the script, and exit 1 comes back as $LASTEXITCODE. Bash ./check-disk.sh 91 kernel: execute bit? read #! line /bin/bash runs the file exit 1 → $? no x bit: 126 · no interpreter: 127 PowerShell ./check-disk.ps1 -Usage 91 .ps1? execution policy (Windows) PowerShell runs the file exit 1 → $LASTEXIT… blocked by policy: script does not start
Starting a script as a program. The checks in the second box are where most "my script won't run" tickets come from.

The shebang line

The first line of a Bash script, #!/bin/bash, is the shebang: when you run the file as a program, the Linux kernel reads it and starts that interpreter with the file as its argument. Two forms are common:

  • #!/bin/bash names the interpreter's path directly. On Linux servers, that is where Bash lives.
  • #!/usr/bin/env bash lets env search PATH for bash. Use it for scripts that must also run on macOS or FreeBSD, where a current Bash is installed somewhere else (see Cross-Platform Scripting).

The shebang decides which shell runs the script, so it must match the language the script is written in. Starting a Bash script with sh runs it in a different shell (on Debian and Ubuntu, dash), and the result can be worse than an error message:

cat > report.sh <<'X'
#!/bin/bash
if [[ -n $1 ]]; then echo "Report for $1"; fi
X
sh report.sh web01
echo "exit status: $?"
bash report.sh web01
report.sh: 2: [[: not found
exit status: 0
Report for web01

dash does not know [[, treats it as a missing command inside the if condition, skips the report and still ends with exit status 0. A scheduled job would report success while doing nothing. Typing sh in front of a script overrules its shebang; run Bash scripts with bash or ./.

PowerShell scripts need no shebang on Windows: the .ps1 extension tells PowerShell what the file is, and PowerShell runs only .ps1 files as scripts (on Windows, pwsh -File rejects other extensions). On Linux and macOS you can add #!/usr/bin/env pwsh as the first line of a .ps1 file, so that ./check-disk.ps1 also works from a Bash prompt.

Execute permission

On Linux, a file runs as a program only if it has execute permission: the x bit in its permissions, which tells the kernel that this user may start the file as a program (see Linux permissions). A new file normally does not have it, so the first run of a fresh script fails with status 126, "found but not executable":

printf '#!/bin/bash\necho "Checking backups"\n' > backup-check.sh
stat -c '%A %n' backup-check.sh
./backup-check.sh
echo "exit status: $?"
bash backup-check.sh
chmod u+x backup-check.sh
stat -c '%A %n' backup-check.sh
./backup-check.sh
-rw-r--r-- backup-check.sh
example.sh: line 3: ./backup-check.sh: Permission denied
exit status: 126
Checking backups
-rwxr--r-- backup-check.sh
Checking backups

bash backup-check.sh works without the x bit, because then Bash is the program being started and the script is only a file it reads. That is also why execute permission is a convenience, not a protection: anyone who can read a script can run it with bash.

chmod u+x gives execute permission to the owner only; chmod +x gives it to the owner, the group and all other users. For shared scripts in /usr/local/bin, 755 (owner may write, everyone may read and run) is the usual mode (see chmod in practice).

Windows has no execute bit. Whether a .ps1 file may run is decided by the execution policy, further down this page.

PATH and the ./ prefix

When you type a bare command name, the shell searches only the folders listed in PATH, and the current folder is deliberately not one of them (see How PATH finds a command). That is why you type ./check-disk.sh: the path tells the shell exactly which file you mean. PowerShell follows the same rule, on Windows too:

'"Checking backups"' | Set-Content backup-check.ps1
backup-check.ps1
backup-check.ps1: /home/amara/scripts/example.ps1:2
Line |
   2 |  backup-check.ps1
     |  ~~~~~~~~~~~~~~~~
     | The term 'backup-check.ps1' is not recognized as a name of a cmdlet,
     | function, script file, or executable program. Check the spelling of the
     | name, or if a path was included, verify that the path is correct and try
     | again.

The file is right there, but without ./ (or .\ on Windows) PowerShell does not look in the current folder. To run a script from anywhere by name, put it in a folder that is in PATH. On Linux, ~/.local/bin or ~/bin for yourself and /usr/local/bin for everyone are the usual places; a script installed as a command usually drops the .sh extension:

mkdir -p bin
printf '#!/bin/bash\necho "OK: disk at ${1}%%"\n' > bin/check-disk
chmod +x bin/check-disk
check-disk 72
echo "exit status: $?"
PATH="$PWD/bin:$PATH"
check-disk 72
command -v check-disk | sed "s|$HOME|~|"
example.sh: line 4: check-disk: command not found
exit status: 127
OK: disk at 72%
~/bin/check-disk

Line endings

Windows editors end lines with two characters, carriage return and line feed (CRLF); Linux expects only the line feed (LF). A Bash script saved with Windows line endings breaks in two confusing ways:

printf '#!/bin/bash\r\necho "Rotating logs"\r\ncd /tmp\r\n' > rotate.sh
chmod +x rotate.sh
./rotate.sh
echo "exit status: $?"
bash rotate.sh
sed -i 's/\r$//' rotate.sh
./rotate.sh
example.sh: line 3: ./rotate.sh: cannot execute: required file not found
exit status: 127
Rotating logs
rotate.sh: line 3: cd: $'/tmp\r': No such file or directory
Rotating logs

Run as a program, the kernel looks for an interpreter called /bin/bash followed by an invisible carriage return, which does not exist. Run with bash, every line ends in \r, so cd looks for a folder called /tmp\r. sed -i 's/\r$//' (or the dos2unix tool) converts the file. To stop it from happening again, set your editor to LF for .sh files, and in a Git repository add the line *.sh text eol=lf to .gitattributes. PowerShell itself accepts both line endings, but a #!/usr/bin/env pwsh line on Linux needs LF, just like Bash.

Exit codes back to the caller

A script ends with an exit status that tells whoever started it (you, another script, a scheduler) whether it worked: 0 for success, anything else for failure. In Bash, exit 1 sets it and the caller reads it in $?. In PowerShell, exit 1 ends the script and the caller finds the number in $LASTEXITCODE, the same variable that holds the exit code of the last native program:

@'
param([int]$Usage)
if ($Usage -gt 90) { "WARNING: disk at $Usage%"; exit 1 }
"OK: disk at $Usage%"
'@ | Set-Content check-disk.ps1
pwsh -NoProfile -File ./check-disk.ps1 -Usage 95
"Exit code seen by the caller: $LASTEXITCODE"
bash -c 'exit 3'
"bash ended with: $LASTEXITCODE"
WARNING: disk at 95%
Exit code seen by the caller: 1
bash ended with: 3

pwsh -File is how a scheduler, a CI job or a Bash script starts a PowerShell script. Its exit code follows simple rules: exit N gives N, a script stopped by an unhandled terminating error gives 1, and a script that reaches its end gives 0, even if some commands in it reported errors along the way. If failures must be visible to the caller, end with an explicit exit 1 (see Error Handling and Conditions, Tests and Exit Codes).

Sourcing and dot sourcing

Running a script starts it in its own context: whatever variables and functions it creates are gone when it ends. Sometimes you want the opposite, for example a shared settings file or a library of functions that several scripts use. Dot sourcing runs a script file inside the current session instead, so its variables and functions remain available afterward. Bash writes it as source file or . file, PowerShell as a dot, a space and the path:

cat > settings.sh <<'X'
log_dir="/var/log/kestrel"
retention_days=14
X
bash settings.sh
echo "After bash:   retention_days='$retention_days'"
source settings.sh
echo "After source: retention_days='$retention_days', log_dir='$log_dir'"
After bash:   retention_days=''
After source: retention_days='14', log_dir='/var/log/kestrel'
@'
$LogDir = '/var/log/kestrel'
function Get-RetentionDays { 14 }
'@ | Set-Content settings.ps1

& ./settings.ps1
"After & :      LogDir='$LogDir'"
. ./settings.ps1
"After . :      LogDir='$LogDir', retention $(Get-RetentionDays) days"
After & :      LogDir=''
After . :      LogDir='/var/log/kestrel', retention 14 days

&, the call operator, runs the script in a child scope, just like ./settings.ps1; the space after the dot in . ./settings.ps1 is what makes it dot sourcing. For larger libraries, PowerShell has modules (.psm1 files loaded with Import-Module), which control what they export instead of dumping everything into your session.

Execution policy

The execution policy is a PowerShell setting on Windows that decides which scripts may run: none, only signed ones, or local ones freely and downloaded ones only when signed. The basics, including the advice never to lower it because a web page says so, are on Using the Windows Command Line. The policies:

Policy Scripts that run
Restricted None; only interactive commands. Default for Windows PowerShell 5.1 on Windows client editions (PowerShell 7 on Windows defaults to RemoteSigned).
AllSigned Only scripts signed by a trusted publisher, local ones included.
RemoteSigned Local scripts freely; scripts that came from the internet only when signed. Default on Windows Server.
Unrestricted All, with a warning before running a downloaded script.
Bypass All, without warnings or prompts.

A policy can be set at several scopes, and the first one that is set wins, in this order: MachinePolicy and UserPolicy (both from Group Policy), Process (this session only), CurrentUser, LocalMachine. If none is set, Windows clients behave as Restricted (Windows PowerShell 5.1; PowerShell 7 sets RemoteSigned at LocalMachine) and Windows Server as RemoteSigned. A blocked script fails before its first line runs:

PS C:\Scripts> .\check-disk.ps1 -Usage 72
.\check-disk.ps1 : File C:\Scripts\check-disk.ps1 cannot be loaded because running scripts is
disabled on this system. For more information, see about_Execution_Policies at
https:/go.microsoft.com/fwlink/?LinkID=135170.

For an administrator's own account, the sensible fix is RemoteSigned for the current user only, which needs no administrator rights. Check the effective policy per scope first; in an organization, a Group Policy setting overrides yours anyway:

Get-ExecutionPolicy -List
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
        Scope ExecutionPolicy
        ----- ---------------
MachinePolicy       Undefined
   UserPolicy       Undefined
      Process       Undefined
  CurrentUser    RemoteSigned
 LocalMachine       Undefined

Microsoft's own documentation is clear about what this is: the execution policy is not a security boundary but defense in depth. It stops a user from running a script by accident, such as one opened from an email; it does not stop anyone determined, because anyone can start pwsh -ExecutionPolicy Bypass -File … or paste the script's contents into a prompt. Real protection comes from application control (such as App Control for Business or AppLocker), least privilege and signed code.

On Linux and macOS, PowerShell ignores execution policies altogether: Get-ExecutionPolicy always returns Unrestricted (in practice it behaves like Bypass), and Set-ExecutionPolicy only reports that the operation is not supported. That is what the runnable examples on this site see:

Get-ExecutionPolicy
Unrestricted

Bash has no execution policy. The nearest thing on Linux is the noexec mount option, which some administrators put on /tmp or home folders: no file there can be run as a program. And just like the execution policy, it is no hard boundary: bash script.sh still reads the file.

The Mark of the Web

How does RemoteSigned know a script came from the internet? Through the Mark of the Web: a hidden note that Windows browsers and mail clients attach to files they download, stored on NTFS in an alternate data stream called Zone.Identifier. PowerShell, Office and SmartScreen all read it to treat downloaded files with extra suspicion.

Get-Content .\Get-MeterReport.ps1 -Stream Zone.Identifier
[ZoneTransfer]
ZoneId=3
HostUrl=https://downloads.example.com/tools/Get-MeterReport.ps1

ZoneId=3 means the Internet zone. Once you have read the script and decided to trust it, Unblock-File removes the mark:

Unblock-File -Path .\Get-MeterReport.ps1

Not every download tool sets the mark: files fetched with curl.exe, Invoke-WebRequest or Invoke-RestMethod may arrive without one, and then RemoteSigned treats them as local. Do not rely on the mark as your only check; the rule is the same as in Bash: read a downloaded script before you run it (see the risk of piping curl into bash).

Script signing

Script signing adds a digital signature to a PowerShell script, made with a code-signing certificate, so that PowerShell can check who published the script and that nobody has changed it since. AllSigned requires a valid signature on every script; RemoteSigned requires one on downloaded scripts. Signing is done on Windows with Set-AuthenticodeSignature, which appends the signature to the end of the file as a comment block:

$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert
Set-AuthenticodeSignature -FilePath .\check-disk.ps1 -Certificate $cert `
  -TimestampServer 'http://timestamp.example.com'
Get-AuthenticodeSignature .\check-disk.ps1 | Select-Object Status, StatusMessage
Status StatusMessage
------ -------------
 Valid Signature verified.

Any change to the script after signing, even one space, breaks the signature (status HashMismatch), so signing belongs at the end of the release process, not on a file you are still editing. The time stamp keeps the signature valid after the certificate expires. In organizations, the code-signing certificate comes from the internal certificate authority (see Internal PKI) and its private key is guarded as carefully as an administrator password: whoever holds it can make any script look trusted.

Bash has no built-in signing. Linux protects scripts by other means: file permissions (only root can change what is in /usr/local/bin), signed packages from the package manager, and checksums or GPG signatures published next to a download.

Side by side

Task Bash PowerShell
Name the interpreter #!/bin/bash or #!/usr/bin/env bash .ps1 extension (plus #!/usr/bin/env pwsh on Linux)
Allow it to run chmod u+x script.sh Execution policy (Windows); Unblock-File after review
Run from the current folder ./script.sh ./script.ps1 or .\script.ps1
Run through the host bash script.sh pwsh -NoProfile -File script.ps1
Run in the current session source script.sh . ./script.ps1
Report failure exit 1, read with $? exit 1, read with $LASTEXITCODE
Prove who wrote it Package signatures, checksums Set-AuthenticodeSignature, AllSigned