32blogby StudioMitsu

Guía Práctica de Shell Scripting: Scripts Bash Robustos

Aprende a escribir shell scripts fiables con bash desde cero. Shebang, variables, condicionales, bucles, funciones, manejo de errores con set -euo pipefail y patrones de automatización reales.

15 min read
Contenido

Un shell script es un archivo de texto con una secuencia de comandos que el shell ejecuta en orden. Si escribes los mismos comandos todos los días en la terminal, es hora de meterlos en un script.

En resumen: los shell scripts automatizan tareas repetitivas de terminal. Empieza cada script con #!/usr/bin/env bash y set -euo pipefail, usa funciones para organizar la lógica y siempre pon las variables entre comillas. Con eso cubres el 80% de lo que necesitas para scripts fiables.

Por qué los Shell Scripts siguen siendo relevantes

Existe Python. Existe Go. ¿Por qué alguien escribiría un shell script en 2026?

Porque los shell scripts operan a nivel del sistema operativo con cero dependencias. No hay que instalar un runtime ni configurar un gestor de paquetes. Si la máquina tiene terminal, puede ejecutar tu script. Eso es una ventaja enorme para:

  • Provisionar servidores — crear usuarios, instalar paquetes, configurar el firewall en un VPS nuevo
  • Pipelines CI/CD — el pegamento entre pasos de build, runners de tests y targets de deploy
  • Cron jobs — rotación de logs, backups de base de datos, tareas de limpieza a las 3 AM
  • Herramientas de desarrollo — scripts de setup de proyecto, git hooks, bootstrapping del entorno local

Yo uso un shell script para las verificaciones pre-deploy de 32blog. Valida el build, ejecuta lints y revisa links rotos antes de hacer push a Vercel. ¿Podría escribirlo en TypeScript? Claro. Pero el script tiene 40 líneas, cero dependencias y no he tenido que tocarlo en meses.

La regla de oro: si tu tarea es "ejecutar comandos en secuencia con algo de lógica," usa un shell script. Si necesitas estructuras de datos, clientes HTTP o recuperación de errores compleja, usa un lenguaje de programación real.

Anatomía de un Script: Shebang, Permisos y Ejecución

Todo script empieza igual. Este es el script mínimo viable:

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

echo "Hello from $(hostname) at $(date)"

La línea shebang

La primera línea #!/usr/bin/env bash le dice al sistema operativo qué intérprete usar. Puede que veas #!/bin/bash en scripts más antiguos, pero #!/usr/bin/env bash es más portable — encuentra bash donde sea que esté instalado en el sistema en lugar de hardcodear una ruta.

La red de seguridad: set -euo pipefail

Esta línea previene toda una clase de bugs:

bash
set -euo pipefail

Esto es lo que hace cada flag:

FlagEfecto
-e (errexit)Sale inmediatamente si cualquier comando falla (código de salida distinto de cero)
-u (nounset)Trata las variables no definidas como errores en vez de cadenas vacías
-o pipefailUn pipeline falla si cualquier comando en él falla, no solo el último

Sin estos flags, un script sigue ejecutándose alegremente después de errores. Una vez tuve un script de backup donde el comando tar falló silenciosamente pero el paso de "borrar backups antiguos" se ejecutó igual. Perdí una semana de datos. set -euo pipefail lo habría detenido en la primera línea.

Permisos de archivo y ejecución

Los scripts necesitan permisos de ejecución:

bash
# Dar permisos de ejecución
chmod +x deploy-check.sh

# Ejecutar
./deploy-check.sh

También puedes ejecutar un script sin permisos de ejecución llamando al intérprete directamente:

bash
bash deploy-check.sh

Pero el enfoque con chmod +x es el estándar — te permite ejecutar el script como cualquier otro comando, y la línea shebang asegura que se use el intérprete correcto. Para más detalles sobre permisos, consulta la guía de chmod/chown.

Nombres de archivo

No hay requisito estricto, pero .sh es la convención. Algunos equipos no usan extensión para scripts que viven en $PATH. La línea shebang, no la extensión, determina cómo se ejecuta el script.

Variables, Argumentos y Entrada de Usuario

Lo básico de variables

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

# Asignación — sin espacios alrededor del signo igual
project_name="32blog"
build_dir="./dist"
max_retries=3

# Uso — siempre pon las variables entre comillas
echo "Building ${project_name} into ${build_dir}"

La sintaxis ${} es opcional para casos simples ($project_name también funciona), pero es más segura — ${project_name}_backup es inequívoco, mientras que $project_name_backup busca una variable llamada project_name_backup.

Siempre pon las variables entre comillas dobles. Esta es la fuente más común de bugs en shell scripts:

bash
# MAL — se rompe si el nombre tiene espacios
file=mi reporte.txt
cat $file  # intenta hacer cat de "mi" y "reporte.txt" por separado

# BIEN — maneja cualquier nombre de archivo
file="mi reporte.txt"
cat "$file"

Sustitución de comandos

Captura la salida de un comando en una variable:

bash
current_date=$(date +%Y-%m-%d)
git_branch=$(git rev-parse --abbrev-ref HEAD)
file_count=$(find . -name "*.mdx" | wc -l)

echo "Branch ${git_branch} tiene ${file_count} archivos MDX al ${current_date}"

Usa $() en vez de backticks. Se pueden anidar y son más legibles:

bash
# Moderno — claro y anidable
files=$(find "$(pwd)" -name "*.log")

# Legacy — evita esto
files=`find \`pwd\` -name "*.log"`

Argumentos del script

Se acceden mediante parámetros posicionales:

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

# $0 = nombre del script, $1 = primer arg, $2 = segundo arg
# $# = número de argumentos, $@ = todos los argumentos

if [[ $# -lt 1 ]]; then
    echo "Uso: $0 <environment>" >&2
    exit 1
fi

environment="$1"
echo "Deploying to ${environment}"

Para scripts con múltiples opciones, usa un parser con case:

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

verbose=false
output_dir="./build"

while [[ $# -gt 0 ]]; do
    case "$1" in
        -v|--verbose)
            verbose=true
            shift
            ;;
        -o|--output)
            output_dir="$2"
            shift 2
            ;;
        -h|--help)
            echo "Uso: $0 [-v] [-o dir]"
            exit 0
            ;;
        *)
            echo "Opción desconocida: $1" >&2
            exit 1
            ;;
    esac
done

echo "Verbose: ${verbose}, Output: ${output_dir}"

Leer entrada del usuario

bash
read -rp "Nombre del proyecto: " project_name
echo "Creando proyecto: ${project_name}"

El flag -r evita la interpretación de backslashes (casi siempre lo quieres). El flag -p muestra un prompt.

Flujo de Control: Condicionales y Bucles

Sentencias if

Bash tiene dos sintaxis de test: [ ] (POSIX) y [[ ]] (específico de Bash). Usa [[ ]] — maneja word splitting y globbing de forma más segura:

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

file="content/cli/es/cli-shell-script.mdx"

# Tests de archivo
if [[ -f "$file" ]]; then
    echo "El archivo existe"
fi

if [[ ! -d "./dist" ]]; then
    echo "Directorio de build no existe, creándolo..."
    mkdir -p ./dist
fi

# Comparación de strings
environment="${1:-}"
if [[ "$environment" == "production" ]]; then
    echo "Deploy a producción — ejecutando checks extra"
elif [[ "$environment" == "staging" ]]; then
    echo "Deploy a staging"
else
    echo "Entorno desconocido: ${environment}" >&2
    exit 1
fi

# Comparación numérica
file_count=$(find . -name "*.mdx" | wc -l)
if [[ "$file_count" -gt 100 ]]; then
    echo "Eso son muchos artículos"
fi

Operadores de test comunes:

OperadorSignificado
-f fileEl archivo existe y es un archivo regular
-d dirEl directorio existe
-z "$var"La cadena está vacía
-n "$var"La cadena no está vacía
==, !=Igualdad/desigualdad de strings
-eq, -ne, -lt, -gtComparaciones numéricas

Bucles for

bash
# Iterar sobre una lista
for env in staging production; do
    echo "Deploying to ${env}"
done

# Iterar sobre archivos (usa glob, nunca parsees ls)
for file in content/cli/es/*.mdx; do
    echo "Procesando: ${file}"
done

# Iterar sobre salida de un comando
for branch in $(git branch --format='%(refname:short)'); do
    echo "Branch: ${branch}"
done

# Bucle estilo C
for ((i = 1; i <= 5; i++)); do
    echo "Intento ${i}"
done

Bucles while

bash
# Leer un archivo línea por línea
while IFS= read -r line; do
    echo "Línea: ${line}"
done < config.txt

# Procesar salida de comando línea por línea
git log --oneline -10 | while IFS= read -r line; do
    echo "Commit: ${line}"
done

# Esperar a que se cumpla una condición
retries=0
max_retries=5
until curl -sf http://localhost:3000/health > /dev/null 2>&1; do
    ((retries++))
    if [[ "$retries" -ge "$max_retries" ]]; then
        echo "El servidor no arrancó tras ${max_retries} intentos" >&2
        exit 1
    fi
    echo "Esperando al servidor... (intento ${retries}/${max_retries})"
    sleep 2
done
echo "Servidor activo"

Sentencias case

case es el pattern matching de Bash — más limpio que encadenar if/elif para comparar strings:

bash
case "$1" in
    start)
        echo "Iniciando servicio..."
        ;;
    stop)
        echo "Deteniendo servicio..."
        ;;
    restart)
        echo "Reiniciando servicio..."
        ;;
    status)
        echo "Verificando estado..."
        ;;
    *)
        echo "Uso: $0 {start|stop|restart|status}" >&2
        exit 1
        ;;
esac

Funciones y Manejo de Errores

Funciones

Las funciones mantienen los scripts organizados y reutilizables:

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

log_info() {
    echo "[INFO] $(date +%H:%M:%S) $*"
}

log_error() {
    echo "[ERROR] $(date +%H:%M:%S) $*" >&2
}

check_dependency() {
    local cmd="$1"
    if ! command -v "$cmd" &> /dev/null; then
        log_error "${cmd} no está instalado"
        return 1
    fi
    log_info "${cmd} encontrado: $(command -v "$cmd")"
}

# Uso
check_dependency "node"
check_dependency "git"
log_info "Todas las dependencias satisfechas"

Puntos clave:

  • Usa local para variables dentro de funciones para no contaminar el scope global
  • $* expande todos los argumentos como un solo string; "$@" preserva los argumentos individuales
  • Las funciones retornan códigos de salida (0 = éxito, 1-255 = fallo), no valores. Usa echo y sustitución de comandos para "retornar" datos
bash
get_version() {
    local package_json="$1"
    jq -r '.version' "$package_json"
}

version=$(get_version "package.json")
echo "Versión actual: ${version}"

Patrones de manejo de errores

Más allá de set -euo pipefail, estos patrones te ayudan a manejar errores con gracia:

Trap para limpieza:

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

tmpdir=$(mktemp -d)

cleanup() {
    rm -rf "$tmpdir"
    echo "Directorio temporal limpiado"
}

trap cleanup EXIT

# Lógica del script — archivos temporales van en $tmpdir
# cleanup se ejecuta automáticamente al salir, incluso con error
cp important-data.txt "$tmpdir/backup.txt"

Ejecución condicional:

bash
# Ejecutar comando, manejar fallo explícitamente
if ! npm run build; then
    log_error "Build falló"
    exit 1
fi

# Patrón OR — valor por defecto si el comando falla
git_hash=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")

Mensajes de error con número de línea:

bash
trap 'echo "Error en línea $LINENO. Código de salida: $?" >&2' ERR

Patrones de Scripts para el Mundo Real

Patrón 1: Script de setup de proyecto

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

# Setup del entorno local de desarrollo de 32blog
log() { echo "[setup] $*"; }

log "Verificando dependencias..."
for cmd in node npm git; do
    if ! command -v "$cmd" &> /dev/null; then
        echo "Falta: ${cmd}. Instálalo e intenta de nuevo." >&2
        exit 1
    fi
done

node_version=$(node -v | tr -d 'v')
required_version="20"
if [[ "${node_version%%.*}" -lt "$required_version" ]]; then
    echo "Se requiere Node.js ${required_version}+, encontrado ${node_version}" >&2
    exit 1
fi

log "Instalando dependencias..."
npm ci

if [[ ! -f .env.local ]]; then
    log "Creando .env.local desde template..."
    cp .env.example .env.local
    echo "Edita .env.local con tus API keys antes de iniciar el dev server"
fi

log "Setup completo. Ejecuta 'npm run dev' para iniciar."

Patrón 2: Backup con rotación

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

backup_dir="/var/backups/32blog"
source_dir="/var/www/32blog/content"
max_backups=7
timestamp=$(date +%Y%m%d_%H%M%S)
backup_file="${backup_dir}/content_${timestamp}.tar.gz"

mkdir -p "$backup_dir"

echo "Creando backup: ${backup_file}"
tar -czf "$backup_file" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"

backup_size=$(du -h "$backup_file" | cut -f1)
echo "Backup creado: ${backup_size}"

# Rotación — mantener solo los más recientes
backup_count=$(find "$backup_dir" -name "content_*.tar.gz" | wc -l)
if [[ "$backup_count" -gt "$max_backups" ]]; then
    remove_count=$((backup_count - max_backups))
    find "$backup_dir" -name "content_*.tar.gz" -printf '%T@ %p\n' \
        | sort -n \
        | head -n "$remove_count" \
        | cut -d' ' -f2- \
        | xargs rm -f
    echo "Rotación: ${remove_count} backup(s) antiguos eliminados"
fi

Patrón 3: Validación pre-deploy

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

errors=0

check() {
    local description="$1"
    shift
    if "$@" > /dev/null 2>&1; then
        echo "✓ ${description}"
    else
        echo "✗ ${description}" >&2
        ((errors++)) || true
    fi
}

echo "=== Checks pre-deploy ==="

check "Lint pasa" npm run lint
check "Build exitoso" npm run build
check "Sin cambios no commiteados" git diff --quiet
check "En branch main" test "$(git branch --show-current)" = "main"
check "Sincronizado con remote" git diff --quiet origin/main..HEAD

if [[ "$errors" -gt 0 ]]; then
    echo ""
    echo "${errors} check(s) fallaron. Corrígelos antes de deployar." >&2
    exit 1
fi

echo ""
echo "Todos los checks pasaron. Listo para deploy."

Patrón 4: Procesamiento de archivos por lotes

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

# Convertir todas las imágenes PNG a WebP en un directorio
input_dir="${1:-.}"
converted=0
skipped=0

for png in "${input_dir}"/*.png; do
    [[ -f "$png" ]] || continue  # saltar si el glob no matchea

    webp="${png%.png}.webp"

    if [[ -f "$webp" ]] && [[ "$webp" -nt "$png" ]]; then
        ((skipped++))
        continue
    fi

    cwebp -q 80 "$png" -o "$webp" 2>/dev/null
    ((converted++))
    echo "Convertido: $(basename "$png")"
done

echo "Listo: ${converted} convertidos, ${skipped} omitidos (ya actualizados)"

FAQ

¿Debería usar bash o sh para mis scripts?

Usa bash. El sh plano (shell POSIX) no tiene arrays, [[ ]], manipulación de strings ni muchas otras funcionalidades útiles. La única razón para usar sh es en entornos mínimos como imágenes Docker de Alpine donde bash no viene instalado — y aun así, puedes hacer apk add bash. Escribe #!/usr/bin/env bash, no #!/bin/sh.

¿Qué hace exactamente set -euo pipefail?

Activa tres flags de seguridad: -e sale ante cualquier error, -u trata variables no definidas como errores, y -o pipefail hace que los pipelines fallen si cualquier comando en ellos falla. Juntos previenen la clase más común de fallos silenciosos en shell scripts. Ponlo justo después del shebang en cada script.

¿Cómo depuro un shell script?

Agrega set -x para imprimir cada comando antes de ejecutarlo. Para depuración específica, envuelve una sección con set -x y set +x. También puedes ejecutar el script con bash -x script.sh sin modificar el archivo. Para más detalle, PS4='+ ${BASH_SOURCE}:${LINENO}: ' muestra archivo y número de línea.

¿Cuándo usar shell script vs Python?

Los shell scripts son excelentes para operaciones con archivos, orquestación de comandos y tareas del sistema — cualquier cosa que escribirías en una terminal. Cambia a Python cuando necesites estructuras de datos complejas, peticiones HTTP, procesamiento de JSON más allá de lo que jq puede manejar, o manejo de errores con más matices que "salir ante el fallo." Si tu script supera las 200 líneas, probablemente es momento de reescribirlo.

¿Cómo manejo nombres de archivo con espacios?

Siempre pon las variables entre comillas: "$file", no $file. Usa "$@" para reenviar argumentos. Al iterar sobre archivos, usa globs (for f in *.txt) en vez de parsear ls. Con find y xargs, usa -print0 y -0 respectivamente para procesamiento delimitado por null.

¿Cuál es la diferencia entre $@ y $*?

"$@" expande cada argumento como una palabra separada (lo que casi siempre quieres). "$*" expande todos los argumentos como un solo string. En un bucle for, for arg in "$@" itera sobre cada argumento correctamente incluso si contienen espacios. Usa siempre "$@" a menos que específicamente necesites concatenación.

¿Cómo hago un script portable entre Linux y macOS?

Usa #!/usr/bin/env bash para el shebang. Evita flags específicos de GNU (como sed -i '' vs sed -i — difieren entre macOS y Linux). Prueba con las versiones GNU y BSD de las utilidades core. Para máxima portabilidad, limítate a funcionalidades POSIX o indica explícitamente que se requiere bash en tu documentación.

¿Vale la pena usar shellcheck?

Absolutamente. ShellCheck detecta problemas de quoting, trampas comunes y problemas de portabilidad que son fáciles de pasar por alto. Instálalo con tu gestor de paquetes (apt install shellcheck, brew install shellcheck) y ejecútalo en CI. Me ha encontrado bugs en scripts que estaba convencido de que eran correctos — más de una vez.

Conclusión

El shell scripting no es glamuroso, pero es una de esas habilidades con interés compuesto. Cada script que escribes ahorra tiempo la próxima vez que lo necesitas — y la siguiente, y la siguiente.

Lo esencial cabe en tu cabeza: empieza con #!/usr/bin/env bash y set -euo pipefail, pon las variables entre comillas, usa funciones para organizar y limpia con trap. Eso es suficiente para escribir scripts que aguanten en producción.

Si quieres ir más lejos: ejecuta ShellCheck en tus scripts, lee el wiki de Bash Pitfalls para casos extremos y consulta el manual de GNU Bash como referencia completa. La Advanced Bash-Scripting Guide también merece un marcador para inmersiones más profundas.

Combina shell scripts con herramientas cubiertas en otras guías — cron para programación, find y xargs para procesamiento de archivos, sed y awk para transformación de texto — y tienes un kit de automatización potente sin dependencias externas.