Cuando abres una terminal en Debian, la shell que arranca es bash — Bourne Again Shell. Es la que ves en el prompt, la que interpreta tus comandos interactivos, y la que probablemente tienes en mente cuando escribes un script. Pero hay un detalle que rompe scripts con una frecuencia irritante: /bin/sh no es bash en Debian. Es dash — Debian Almquist Shell — una shell mucho más pequeña y estricta que implementa POSIX pero deliberadamente omite las extensiones propias de bash.
El diseño tiene una lógica clara. dash arranca más rápido que bash y consume menos memoria. Durante el arranque del sistema, cientos de scripts de inicialización se ejecutan bajo /bin/sh; usar dash ahí reduce el tiempo de boot de forma medible. Ese fue exactamente el argumento cuando Debian tomó la decisión: el sistema ganó segundos reales en el arranque. El problema aparece cuando tú escribes #!/bin/sh en tu script pero usas sintaxis que solo existe en bash, porque en ese momento le estás pidiendo a dash que interprete código que no entiende.
Esto te importa en el momento en que escribes tu primer script que “funciona cuando lo pruebo” pero falla en producción, en el servidor de CI, o en el cron job nocturno. El fallo no siempre es obvio: a veces dash ejecuta partes del script sin error y se detiene silenciosamente en la línea problemática, dejándote con un resultado parcial y ningún mensaje útil. Otras veces el error es críptico — dash reporta “sintaxis incorrecta” en una línea que en bash es perfectamente válida.
Lo que se rompe específicamente son las extensiones de bash: los arrays (mi_array=(a b c)), la sintaxis [[ ]] para condiciones avanzadas, la sustitución de procesos (<(comando)), local con modificadores, las expansiones ${variable,,} para conversión de mayúsculas, y varias otras construcciones que bash añade sobre el estándar POSIX. Si tu script usa cualquiera de estas y tiene #!/bin/sh, está roto en Debian aunque tú nunca lo hayas notado porque en tu sesión interactiva bash lo ejecuta correctamente.
#!/usr/bin/env bash
# ┌─────────────────────────────────────────────────────────────┐
# │ Script de ejemplo: backup diario de un directorio │
# │ Requiere bash. Usar #!/bin/bash o #!/usr/bin/env bash │
# └─────────────────────────────────────────────────────────────┘
set -euo pipefail
# -e: abortar si cualquier comando falla
# -u: tratar variables no definidas como error
# -o pipefail: un pipe falla si falla cualquier parte de él
ORIGEN="/home/ana/documentos"
DESTINO="/srv/backups"
FECHA=$(date +%Y-%m-%d)
ARCHIVO="backup_${FECHA}.tar.gz"
# Array de bash — esto explota bajo /bin/sh (dash)
EXCLUIR=(
"*.tmp"
"*.log"
".cache"
)
# Construir los argumentos --exclude dinámicamente desde el array
ARGS_EXCLUDE=()
for patron in "${EXCLUIR[@]}"; do
ARGS_EXCLUDE+=(--exclude="$patron")
done
# [[ ]] es bash, no POSIX — [ ] sería la alternativa portátil
if [[ ! -d "$DESTINO" ]]; then
echo "Error: el directorio de destino no existe: $DESTINO" >&2
exit 1
fi
tar -czf "${DESTINO}/${ARCHIVO}" "${ARGS_EXCLUDE[@]}" "$ORIGEN"
# Expansión de mayúsculas/minúsculas: otra extensión solo de bash
ESTADO="ok"
echo "Backup completado: ${ESTADO^^}" # imprime "OK"
Fíjate en la primera línea: #!/usr/bin/env bash. Esto tiene una ventaja sobre #!/bin/bash: usa env para buscar bash en el PATH, lo que hace el script portable a sistemas donde bash está en una ubicación diferente (FreeBSD, por ejemplo, lo pone en /usr/local/bin/bash). En Debian /bin/bash existe y funciona, pero env bash es una práctica más robusta.
set -euo pipefail en la segunda línea ejecutable no es decorativo. Sin -e, si tar falla a mitad del backup el script continúa y tú nunca te enteras. Sin -u, una variable mal escrita ($DESTNO en lugar de $DESTINO) se expande silenciosamente como cadena vacía, con consecuencias potencialmente desastrosas. Sin -o pipefail, un comando al principio de un pipe puede fallar mientras el script sigue adelante porque el código de retorno del pipe completo es el del último comando.
El array EXCLUIR ilustra exactamente por qué el shebang importa. EXCLUIR=(...) es sintaxis de bash; dash la rechaza con un error de sintaxis. Lo mismo con el bucle for patron in "${EXCLUIR[@]}" — la expansión [@] sobre arrays es bash. Y ${ESTADO^^} para convertir a mayúsculas es bash 4+; en dash simplemente no existe.
La regla práctica es esta: si tu script usa cualquier cosa que no está en la especificación POSIX sh estricta, pon #!/bin/bash. Si escribes un script genuinamente portable — que tiene que correr en Alpine Linux (que usa ash), en FreeBSD, en sistemas embebidos — entonces #!/bin/sh tiene sentido, pero en ese caso tienes que conocer y respetar los límites de POSIX conscientemente, no por accidente.
Para comprobar si un script tiene problemas de portabilidad sin ejecutarlo, instala checkbashisms del paquete devscripts:
sudo apt install devscripts checkbashisms mi_script.sh
checkbashisms analiza estáticamente el script y lista cada construcción específica de bash que encuentra, incluso si el script tiene #!/bin/sh. Es una herramienta de un solo propósito y hace ese propósito muy bien.
Sobre las alternativas: zsh está disponible en Debian (apt install zsh) y es la shell por defecto en macOS desde Catalina. Tiene compatibilidad con bash para uso interactivo y añade autocompletado más potente, corrección ortográfica y temas vía Oh My Zsh. Para scripting, los scripts de zsh usan #!/bin/zsh y tienen sus propias extensiones. fish (apt install fish) prioriza la experiencia interactiva con sugerencias automáticas y sintaxis simplificada, pero su lenguaje de scripting es deliberadamente incompatible con POSIX, así que los scripts fish son fish y nada más.
[Ubuntu]: En Ubuntu, /bin/sh también es dash desde la versión 6.10 (2006), por la misma razón de rendimiento en el arranque. El comportamiento descrito aquí es idéntico.
N° 10