Table of Contents
- Start with a Solid Foundation: Script Structure
- Error Handling: Fail Fast and Gracefully
- Input Validation: Sanitize and Validate Arguments
- Security: Protect Against Vulnerabilities
- Logging: Track Every Action
- Environment Management: Isolate and Control Dependencies
- Idempotency: Run Scripts Safely Multiple Times
- Performance: Optimize for Speed
- Testing: Validate Before Deployment
- Conclusion
- References
1. Start with a Solid Foundation: Script Structure
A well-structured script is easier to read, debug, and maintain. Follow these guidelines to set a strong base:
1.1 Use a Consistent Shebang
The shebang (#!) specifies the interpreter. Use #!/usr/bin/env bash instead of #!/bin/bash for portability:
envlocatesbashin the user’sPATH, ensuring compatibility across systems wherebashmay not be in/bin(e.g., macOS, custom Linux setups).
#!/usr/bin/env bash
1.2 Add a Header Comment
Include a header explaining the script’s purpose, author, version, and usage. This helps collaborators (and future you) understand its intent:
#!/usr/bin/env bash
# Purpose: Automate deployment of the Acme Corp web application to a Linux server
# Author: DevOps Team <[email protected]>
# Version: 1.0.0
# Usage: ./deploy.sh <environment> [--force]
# <environment>: prod | staging | dev (required)
# --force: Overwrite existing files (optional)
1.3 Modularize with Functions
Break logic into reusable functions. This avoids repetition and makes testing easier. For example, group “install dependencies” or “configure Nginx” into functions:
# Install required system packages
install_dependencies() {
log_info "Installing dependencies: nginx, docker, jq"
apt-get update && apt-get install -y nginx docker.io jq
}
# Configure Nginx for the application
configure_nginx() {
log_info "Configuring Nginx..."
cp ./nginx.conf /etc/nginx/sites-available/acme-app
ln -s /etc/nginx/sites-available/acme-app /etc/nginx/sites-enabled/
systemctl restart nginx
}
2. Error Handling: Fail Fast and Gracefully
Deployments must fail early and clearly to avoid partial or broken states. Use Bash’s error-handling flags to enforce strictness:
2.1 Enable Strict Mode
Add these flags at the top of your script to catch common mistakes:
set -e: Exit immediately if any command fails.set -u: Treat undefined variables as errors (avoids silent failures from typos).set -o pipefail: Make pipelines fail if any command in the pipeline fails (e.g.,grepincurl ... | grep ...).
#!/usr/bin/env bash
set -euo pipefail # Strict mode: exit on error, undefined var, or pipeline failure
2.2 Clean Up with trap
Use trap to run cleanup commands (e.g., stop services, delete temp files) if the script exits unexpectedly (e.g., due to Ctrl+C or an error):
# Define cleanup function
cleanup() {
log_error "Deployment failed. Cleaning up..."
docker stop acme-app || true # Ignore error if container isn't running
rm -rf /tmp/acme-deploy # Remove temporary files
}
# Trap EXIT (normal or error) to run cleanup
trap cleanup EXIT
3. Input Validation: Sanitize and Validate Arguments
Deployments often rely on user input (e.g., environment, version). Validate inputs to ensure they’re valid and safe.
3.1 Check for Required Arguments
If your script requires arguments (e.g., staging or prod), verify they’re provided:
# Check if at least 1 argument (environment) is provided
if [ $# -lt 1 ]; then
log_error "Error: Missing environment argument."
log_info "Usage: ./deploy.sh <environment> [--force]"
exit 1
fi
ENV="$1"
3.2 Validate Argument Values
Ensure inputs match expected values (e.g., prod/staging). Reject invalid inputs early:
# Validate environment is one of prod/staging/dev
case "$ENV" in
prod|staging|dev)
log_info "Deploying to $ENV environment"
;;
*)
log_error "Error: Invalid environment '$ENV'. Must be prod, staging, or dev."
exit 1
;;
esac
3.3 Parse Options with getopts
For complex scripts with flags (e.g., --force, --debug), use getopts to parse options cleanly:
FORCE_DEPLOY=false
# Parse options (e.g., -f for --force)
while getopts "f" opt; do
case "$opt" in
f) FORCE_DEPLOY=true ;;
\?)
log_error "Invalid option: -$OPTARG"
exit 1
;;
esac
done
# Shift past parsed options to access positional arguments
shift $((OPTIND -1))
ENV="$1" # Now $1 is the environment (after options)
4. Security: Protect Against Vulnerabilities
Deployment scripts run with elevated privileges, making security critical. Avoid these common pitfalls:
4.1 Avoid curl | bash
Never pipe untrusted remote scripts directly into bash—this executes code without review. If you must use remote scripts, download them first, inspect, then run:
# UNSAFE: curl https://acme.com/install.sh | bash
# SAFER: Download, verify, then run
curl -fsSL -o install.sh https://acme.com/install.sh
sha256sum -c install.sh.sha256 # Verify checksum
bash install.sh
4.2 Sanitize User Inputs
Untrusted inputs (e.g., from CLI args or config files) can lead to command injection. Sanitize variables used in subshells:
# UNSAFE: User input directly in a command
USER_PROVIDED_PATH="$1"
tar -xvf "$USER_PROVIDED_PATH" # If $USER_PROVIDED_PATH is "malicious.tar; rm -rf /", this is dangerous!
# SAFER: Sanitize by restricting to allowed characters (e.g., letters, numbers, hyphens)
if [[ ! "$USER_PROVIDED_PATH" =~ ^[a-zA-Z0-9_-]+\.tar$ ]]; then
log_error "Invalid filename: $USER_PROVIDED_PATH"
exit 1
fi
tar -xvf "$USER_PROVIDED_PATH"
4.3 Minimize sudo Usage
Limit sudo to only necessary commands. Avoid running the entire script with sudo—elevate privileges per-command:
# GOOD: Use sudo only for commands that need it
sudo apt-get update # Requires root
git clone https://github.com/acme/app.git # No sudo needed
4.4 Handle Secrets Securely
Never hardcode secrets (API keys, passwords) in scripts. Use environment variables or secure vaults (e.g., HashiCorp Vault):
# BAD: Hardcoded secret
# API_KEY="supersecret123"
# GOOD: Read from environment variable
if [ -z "${API_KEY:-}" ]; then
log_error "Error: API_KEY environment variable not set."
exit 1
fi
curl -H "Authorization: Bearer $API_KEY" https://api.acme.com/deploy
5. Logging: Track Every Action
Debugging failed deployments is impossible without clear logs. Replace echo with structured logging:
5.1 Log with Timestamps and Levels
Create logging functions to include timestamps (for auditing) and severity levels (e.g., INFO, ERROR):
# Logging functions
log_info() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] [INFO] $*"
}
log_error() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2 # Redirect to stderr
}
5.2 Redirect Output to a Log File
Save all output to a log file for later review. Use tee to log to both the console and a file:
LOG_FILE="/var/log/acme-deploy-$(date +'%Y%m%d').log"
# Redirect stdout/stderr to log file and console
exec > >(tee -a "$LOG_FILE") 2>&1
log_info "Deployment started. Logging to $LOG_FILE"
6. Environment Management: Isolate and Control Dependencies
Ensure scripts behave consistently across environments by managing paths, variables, and dependencies explicitly.
6.1 Use Absolute Paths
Avoid relative paths (e.g., ./config.ini), which break if the script runs from a different directory. Use absolute paths or $SCRIPT_DIR:
# Get the directory of the current script (works even if run from another folder)
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)
# Use absolute path to config file
CONFIG_FILE="$SCRIPT_DIR/configs/$ENV.ini"
6.2 Manage Variables with .env Files (Carefully)
Store environment-specific variables (e.g., DB_HOST) in a .env file, but never commit it to version control. Load it at runtime:
# Load .env file if it exists (for local development)
if [ -f ".env.$ENV" ]; then
log_info "Loading environment variables from .env.$ENV"
# shellcheck source=/dev/null # Suppress shellcheck warning for untrusted file
source ".env.$ENV"
else
log_warn ".env.$ENV not found. Using system environment variables."
fi
7. Idempotency: Run Scripts Safely Multiple Times
A script is idempotent if running it twice has the same effect as running it once. This prevents failures from accidental reruns.
7.1 Check Before Acting
Avoid redundant actions (e.g., installing a package that’s already present):
# Install Docker only if not already installed
if ! command -v docker &>/dev/null; then
log_info "Docker not found. Installing Docker..."
apt-get update && apt-get install -y docker.io
else
log_info "Docker is already installed. Skipping."
fi
7.2 Avoid Overwriting Existing Files
Use cp -n (no-clobber) or conditionals to preserve existing files unless --force is specified:
# Copy config file only if it doesn't exist (or --force is set)
DEST_CONFIG="/etc/nginx/sites-available/acme-app"
SRC_CONFIG="$SCRIPT_DIR/nginx/$ENV.conf"
if [ "$FORCE_DEPLOY" = true ] || [ ! -f "$DEST_CONFIG" ]; then
log_info "Copying Nginx config to $DEST_CONFIG"
cp "$SRC_CONFIG" "$DEST_CONFIG"
else
log_info "Nginx config already exists. Use --force to overwrite."
fi
8. Performance: Optimize for Speed
Slow deployments delay releases. Optimize scripts to run faster:
8.1 Avoid Unnecessary Commands
Skip redundant steps like apt-get update on every run. Cache package lists or check timestamps:
# Run apt-get update only if lists are older than 1 hour
if [ ! -f /var/lib/apt/periodic/update-success-stamp ] || \
[ $(find /var/lib/apt/periodic/update-success-stamp -mmin +60) ]; then
log_info "Updating package lists (older than 1 hour)..."
apt-get update
else
log_info "Package lists are up-to-date. Skipping apt-get update."
fi
8.2 Use Efficient Loops
Replace slow for loops with tools like find and xargs for parallel execution:
# SLOW: for loop over files
# for file in ./assets/*; do gzip "$file"; done
# FAST: Parallel gzip with xargs (4 processes at a time)
find ./assets -type f -print0 | xargs -0 -P 4 gzip # -P 4 = 4 parallel jobs
9. Testing: Validate Before Deployment
Even small scripts need testing to avoid breaking production.
9.1 Lint with shellcheck
Use shellcheck (a static analysis tool) to catch syntax errors and bad practices:
# Install shellcheck: apt-get install shellcheck
shellcheck ./deploy.sh # Fix errors before running!
9.2 Test in Isolated Environments
Test scripts in a container (e.g., Docker) that mimics production:
# Build a test container and run the script
docker run -v "$(pwd):/app" --rm ubuntu:22.04 bash -c "/app/deploy.sh staging"
9.3 Unit Testing with bats
For critical scripts, use bats (Bash Automated Testing System) to write unit tests:
#!/usr/bin/env bats
@test "deploy.sh rejects invalid environment" {
run ./deploy.sh invalid-env
[ "$status" -eq 1 ]
[ "$output" = *"Invalid environment 'invalid-env'"* ]
}
10. Conclusion
Bash scripts are powerful tools for Linux deployments, but they require discipline to avoid chaos. By following these practices—strict error handling, input validation, security, logging, and idempotency—you’ll build scripts that are reliable, maintainable, and safe.
Remember: The goal isn’t perfection, but consistency. Start with a few key practices (e.g., strict mode and logging) and iterate. Your future DevOps self will thank you!