Scripts#

A script is the operator’s fastest path from idea to capability. An executable text file, an interpreter on the shebang line, the right mode bits, and the operator has a tool that fits the target, the constraint, and the timeline that no off-the-shelf binary covers.

The interpreter can be bash, python, perl, ruby, node, lua, or anything else on the box. The mechanics of setting a script up, marking it executable, parsing its flags, and handling its arguments are the same regardless of language. This page covers those mechanics on Linux.

Setting Up#

A script is just a text file. Pick a name, a directory, and an extension (or none). The directory is what makes the script discoverable; $PATH directories let you call the script by name, while ./script runs whatever is in the current directory:

Convention

Notes

./script.sh

Bash; .sh extension is convention, not required

./script.py

Python; .py is convention; editors and tools use it

./deploy

No extension when the script is a CLI tool on $PATH

~/bin/ / ~/.local/bin/

Common per-user script locations

/usr/local/bin/

System-wide local scripts

/etc/cron.daily/

Periodic scripts run by cron / anacron

Make sure the script directory exists; -p creates parents silently and never errors if the path already exists:

$ mkdir -p ~/.local/bin
(no output; ~/.local/bin now exists)

Create an empty script file:

$ touch ~/.local/bin/hello
(no output; an empty file is created)

Open the file in your editor ($EDITOR is the convention for “my preferred editor”):

$ "$EDITOR" ~/.local/bin/hello
(your editor opens; save and exit when done)

Add ~/.local/bin to $PATH so the script is callable by name; the line is appended to ~/.bashrc and takes effect on the next shell:

$ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
(no output)

Format#

The first line of an executable script is the shebang (#!). It tells the kernel which interpreter to launch when the script is run directly.

Without a shebang, the kernel falls back to running the file through the user’s shell, which works for trivial scripts but breaks the moment the script is invoked from a non-shell context (cron, systemd, or another language calling out to it).

Shebang

Effect

#!/bin/bash

Run with /bin/bash; portable to most Linux

#!/usr/bin/env bash

Find bash on $PATH; portable to macOS, BSDs

#!/usr/bin/env python3

Same idea for Python

#!/usr/bin/env -S bash -e

Pass interpreter flags (-S is GNU coreutils 8.30+)

#!/bin/sh

POSIX shell: minimum dependencies, fewest features

#!/usr/bin/env node

Node.js

#!/usr/bin/env ruby

Ruby

#!/usr/bin/env <interp> is the most portable: the kernel runs env to look up the interpreter on $PATH instead of hard-coding a path that may differ between systems.

A typical header for a Bash script:

$ #!/usr/bin/env bash
$ set -euo pipefail
$ IFS=$'\n\t'

The same skeleton in Python:

#!/usr/bin/env python3
"""Deploy a service to one or more hosts."""

from __future__ import annotations
import sys

Permissions#

A script needs the execute bit (+x) to run directly. Without it, the kernel refuses with “Permission denied”; the script can still be run by passing it to the interpreter explicitly.

Mode

Use

755 (rwxr-xr-x)

Public script; everyone can read and run it

750 (rwxr-x---)

Group-only executable

700 (rwx------)

Private to the owner (secrets, ops scripts)

755 + setuid (4755)

Run as the file’s owner; avoid unless required

+x adds the execute bit for owner, group, and other in one shot:

$ chmod +x ~/.local/bin/hello
(no output; verify with ``ls -l``)

Numeric modes are clearer when you want exact bits; 755 is the standard “public executable” mode:

$ chmod 755 ~/.local/bin/hello
(no output)

700 is “owner can do everything; nobody else can even read it”; the right mode for ops scripts that touch credentials:

$ chmod 700 ~/.local/bin/secret
(no output)

Confirm the mode by listing the file in long format:

$ ls -l ~/.local/bin/hello
-rwxr-xr-x 1 operator operator 23 May  1 14:38 /home/operator/.local/bin/hello

For unauthored scripts (a downloaded .sh from the internet), review the source before adding +x. See Permissions for the full mode-bit treatment.

Running#

The kernel uses the shebang line to choose the interpreter when a script is launched directly. The operator has several ways to invoke a script, each one slightly different in how PATH lookup, executable bits, and the calling shell are involved:

Form

What happens

./script.sh

Run directly; needs +x and a shebang

bash script.sh

Pass the script to the interpreter; no +x needed

./deploy

Same as above; no extension required

deploy

Found via $PATH; needs +x and a shebang

source script.sh / .

Run in the current shell; can set its variables

exec ./script.sh

Replace the current shell process with the script

nohup ./script.sh &

Background, detach from the shell, survive logout

Direct run, the most common form; ./ makes the path explicit so the shell does not search $PATH:

$ ./hello
hello world

Pass the script to bash explicitly; useful for debugging or when the file lacks the execute bit:

$ bash ./hello
hello world

Pass a Python script to the interpreter explicitly:

$ python3 hello.py
hello from python

source (or .) runs the script in the current shell, so its variable assignments and function definitions persist after it exits:

$ source ./env.sh
(no output; env vars set by env.sh are now in this shell)

When deploy is on $PATH, you can call it by name like any other command:

$ deploy --env prod web-01
deploying to web-01 in env=prod

nohup detaches the script from the terminal so it survives logout; redirect both streams since the terminal is gone:

$ nohup ./long-job.sh > out.log 2>&1 &
[1] 19105
nohup: ignoring input and redirecting stderr to stdout

Arguments#

Positional arguments arrive as $1, $2, … $0 is the script’s own name. $@ is all arguments as an array; $# is the count. Always quote "$@" because bare $@ re-splits on whitespace.

Variable

Meaning

$0

Script name (or invocation path)

$1$9

First through ninth positional argument

${10}

Tenth onward needs braces

$#

Number of positional arguments

"$@"

All arguments as separate words (the safe form)

"$*"

All arguments as one string (rarely correct)

shift

Drop $1; $2 becomes the new $1, etc.

${1:-default}

Use default if $1 is unset or empty

${1:?need an arg}

Exit with the message if $1 is unset

A skeleton that prints its name, count, every argument, then demonstrates shift and the ${1:?...} form:

$ #!/usr/bin/env bash
$ echo "running: $0"
$ echo "got $# arg(s)"

$ for arg in "$@"; do
    $ echo "  - $arg"
$ done

$ while [[ $# -gt 0 ]]; do
    $ echo "next: $1"
    $ shift
$ done

$ target=${1:?need a target host}

Calling that script with three arguments produces:

running: ./args.sh
got 3 arg(s)
  - alpha
  - bravo
  - charlie
next: alpha
next: bravo
next: charlie
./args.sh: line 12: 1: need a target host

The same idea in Python (sys.argv[0] is the script name, sys.argv[1:] are the arguments):

#!/usr/bin/env python3
import sys

print(f"running: {sys.argv[0]}")
for arg in sys.argv[1:]:
    print(f"  - {arg}")

Calling it with the same three arguments:

running: ./args.py
  - alpha
  - bravo
  - charlie

Options#

For anything beyond a couple of positional arguments, parse flags properly. Bash has two built-in tools, plus the external GNU getopt; most other languages have a flag-parsing library in the standard distribution.

Tool

Use

getopts

Bash builtin; short flags only (-v)

getopt (GNU)

External; long flags too (--verbose)

hand-rolled case

Most readable for small scripts; long + short flags

argparse (Python stdlib)

The standard for Python scripts

flag (Go stdlib)

The standard for Go

clap (Rust crate)

The standard for Rust

The hand-rolled case form is the common Bash idiom; it handles short flags, long flags, -- end-of-flags, and unknown flags:

$ #!/usr/bin/env bash
$ set -euo pipefail

$ env=staging
$ dry_run=false
$ verbose=false

$ while [[ $# -gt 0 ]]; do
    $ case $1 in
        $ -e|--env)
            $ env=$2; shift 2
            $ ;;
        $ --dry-run)
            $ dry_run=true; shift
            $ ;;
        $ -v|--verbose)
            $ verbose=true; shift
            $ ;;
        $ -h|--help)
            $ cat <<EOF
$ Usage: $(basename "$0") [--env ENV] [--dry-run] [-v] HOST
$ EOF
            $ exit 0
            $ ;;
        $ --)
            $ shift; break
            $ ;;
        $ -*)
            $ echo "unknown flag: $1" >&2; exit 2
            $ ;;
        $ *)
            $ break
            $ ;;
    $ esac
$ done

$ host=${1:?need a host}

$ echo "env=$env dry_run=$dry_run verbose=$verbose host=$host"

Calling that script with -e prod --dry-run -v web-01 produces:

env=prod dry_run=true verbose=true host=web-01

The getopts builtin parses short flags only; OPTIND tracks how many positional words have been consumed so the final shift skips them:

$ while getopts ':e:vh' opt; do
    $ case $opt in
        $ e) env=$OPTARG ;;
        $ v) verbose=true ;;
        $ h) usage; exit 0 ;;
        $ :) echo "missing arg for -$OPTARG" >&2; exit 2 ;;
        $ *) echo "unknown -$OPTARG" >&2; exit 2 ;;
    $ esac
$ done
$ shift $((OPTIND - 1))

Python’s argparse (standard library) auto-generates --help output, validates types, and is the conventional choice:

#!/usr/bin/env python3
import argparse

p = argparse.ArgumentParser(description="Deploy a service to a host.")
p.add_argument("host", help="target hostname")
p.add_argument("-e", "--env", default="staging", help="environment")
p.add_argument("--dry-run", action="store_true", help="show actions, don't run")
p.add_argument("-v", "--verbose", action="store_true")
args = p.parse_args()

print(args)

Calling that script with -e prod --dry-run -v web-01 produces:

Namespace(host='web-01', env='prod', dry_run=True, verbose=True)

References#

The commands that show up most in script setup, distribution, and runtime. Each entry links to the per-letter reference page in /refs/tools/ for the detailed flag listing; this table is the per-task index.

Command

Description

mkdir

Create directories (-p creates parents)

touch

Create empty files or update timestamps

chmod

Change file mode bits (+x, 755, 700)

ls

List directory contents (-l for long format)

echo

Print arguments to stdout

bash

The Bourne-Again shell interpreter

source

Run a script in the current shell (alias .)

exec

Replace the current process with another

nohup

Detach a command from the terminal

getopt

GNU long-option parser

env

Run a command with a modified environment

See also:

  • The Terminal: shells, prompts, command anatomy, exit codes

  • Standard I/O: standard I/O, pipes, redirects

  • Permissions: chmod, execute bit, ACLs

  • Processes: jobs, nohup, exec

  • man 1 bash: Bash builtins, getopts, expansion, redirection

  • man 1 getopt: GNU long-option parser

  • man 1p sh: POSIX shell reference

  • man 2 execve: the syscall that runs scripts (handles the shebang)