Módulo 4: Permisos, procesos y entorno
6. Variables de entorno y el PATH
Descripción
Al terminar esta lección vas a poder diagnosticar la enorme mayoría de los command not found sin reinstalar nada, explicar por qué una variable que ves con echo $VAR a veces desaparece dentro de un script y otras veces no, y usar which, type y command -v para responder una pregunta que parece trivial y no lo es: cuando escribes python3 en la terminal, ¿cuál de los tres o cuatro python3 instalados en tu máquina se ejecuta realmente?
Esto no es curiosidad de sistema operativo. El día que un compañero te diga "en mi máquina funciona" y en la tuya no, la causa casi siempre vive en uno de dos lugares: una variable de entorno que él tiene definida y tú no, o un PATH que en su máquina encuentra primero una versión distinta del mismo binario. El día que un pipeline de integración continua falle con command not found para un comando que corriste hace un minuto en tu propia terminal, la causa es la misma: el entorno de esa otra shell no es una copia del tuyo, aunque el programa exista en algún lugar del disco.
Conexión con el módulo: la lección anterior te mostró que un proceso hijo hereda de su proceso padre una relación de parentesco. Hoy vas a ver exactamente qué hereda de ese padre: una copia congelada de su entorno, tomada en el instante exacto en que nace. Todavía no vas a tocar los archivos que hacen persistente un cambio de PATH entre una terminal y otra —.bashrc, .zshrc—; eso es exactamente el tema de la siguiente lección. Hoy el objetivo es entender qué es una variable de entorno, qué es el PATH, y cómo diagnosticar con precisión cuál binario se ejecuta cuando hay más de uno con el mismo nombre.
Una nota en tu escritorio no es lo mismo que una copia en la carpeta que le entregas a alguien
Imagina que trabajas en una oficina y tienes notas pegadas en tu propio escritorio: recordatorios, datos que solo te sirven a ti mientras estás sentado ahí. Cuando mandas a un asistente a hacer un encargo, no le entregas tu escritorio completo — le entregas una carpeta con fotocopias de algunas notas puntuales, las que decidiste que necesita para esa tarea. El asistente sale con esa carpeta, y mientras trabaja, ni ve ni puede modificar las notas que se quedaron pegadas en tu escritorio. Y hay algo más sutil todavía: si después de que el asistente ya salió pegas una nota nueva en tu escritorio, él nunca se entera — trabaja con la carpeta que tenía en la mano al momento de salir, no con una versión actualizada en tiempo real.
Tu shell hace exactamente esto cada vez que ejecutas un programa. python3 script.py, git status, cualquier comando: la shell lanza un proceso hijo, y ese hijo recibe una copia de ciertas variables en el instante en que nace. Las variables que decidiste "fotocopiar y entregar" son las variables de entorno. Las que se quedan pegadas en tu escritorio, visibles solo para ti mientras tu shell siga viva, son variables de shell.
- Variable de shell: existe solo dentro del proceso de tu shell actual (bash, zsh, la que uses). La creas con
NAME=value—sin espacios alrededor del=— y desaparece cuando cierras esa shell. Ningún proceso hijo la ve. - Variable de entorno: una variable de shell a la que le agregaste el atributo de exportación. Cualquier proceso que la shell lance a partir de ese momento recibe una copia de su valor.
exportes exactamente la frontera entre las dos.export NAMEtoma una variable de shell que ya existe y la marca para exportar;export NAME=valuehace las dos cosas —asignar y marcar— en un solo paso.
Dentro de la misma shell, $NAME funciona igual para ambas: el símbolo $ no distingue si algo está exportado o no. La diferencia solo se nota cuando el valor tiene que cruzar la frontera hacia un proceso hijo: un script, un programa, una subshell.
Ejemplo trabajado
# Variable de shell: existe solo en esta shell
GREETING="hello from the parent shell"
echo "Dentro de esta shell: $GREETING"
# Un proceso hijo (una subshell nueva) todavía no la ve
bash -c 'echo "Dentro del hijo: ${GREETING:-(no existe)}"'
# export marca la frontera: ahora sí es una variable de entorno
export GREETING
bash -c 'echo "Dentro del hijo: ${GREETING:-(no existe)}"'
# env y printenv solo listan variables exportadas, no variables de shell
env | grep GREETING
printenv GREETING
# unset borra la variable completa: valor y atributo de export
unset GREETING
printenv GREETING
echo "código de salida: $?"
Qué esperar:
Dentro de esta shell: hello from the parent shell
Dentro del hijo: (no existe)
Dentro del hijo: hello from the parent shell
GREETING=hello from the parent shell
hello from the parent shell
código de salida: 1
Léelo línea por línea. La primera subshell no encuentra GREETING porque todavía no había cruzado la frontera del export: era una nota pegada a tu escritorio, no una fotocopia en ninguna carpeta. Después del export, la segunda subshell sí la recibe, porque nace cuando la variable ya tenía el atributo de exportación. env (sin argumentos, lista todo el entorno) y printenv GREETING (pregunta por una variable puntual) solo conocen variables exportadas — por eso son la herramienta correcta para confirmar si algo realmente cruzó la frontera, en vez de confiar solo en echo $VAR, que no distingue nada. Y unset no vacía el valor: borra la variable entera, incluido el atributo de export — por eso printenv después del unset no imprime nada y devuelve el código de salida 1, el código estándar para "no encontrado".
El PATH: la única razón por la que python3 encuentra a python3
Cuando escribes el nombre de un comando y presionas Enter, la shell no tiene una base de datos de "dónde está instalado cada programa". Tiene una sola variable de entorno, PATH, con una lista ordenada de carpetas, y hace exactamente lo que harías tú si buscaras un documento en un archivero con varios cajones etiquetados en un orden fijo: abre el primer cajón, busca el nombre exacto que escribiste. Si está, se detiene ahí mismo — ni siquiera revisa los cajones siguientes, aunque también tuvieran un archivo con ese nombre. Si no está, pasa al segundo cajón, y así sucesivamente. Si recorre todos los cajones y no encuentra nada, ahí es cuando ves command not found.
Formalmente: PATH es una variable de entorno cuyo valor es una lista de rutas absolutas separadas por dos puntos (:). La shell la recorre de izquierda a derecha y ejecuta el primer archivo ejecutable que encuentre con el nombre exacto que escribiste.
Ejemplo trabajado
echo "$PATH"
Qué esperar (el valor real llega en una sola línea; aquí se muestra un directorio por línea para que el orden se lea con claridad):
/opt/homebrew/bin
/usr/local/bin
/usr/bin
/bin
/usr/sbin
/sbin
Supongamos que tienes Python instalado dos veces: la copia que trae el sistema en /usr/bin/python3, y otra que instalaste con un gestor de paquetes en /opt/homebrew/bin/python3. Ambas existen al mismo tiempo en el disco. La pregunta "¿cuál se ejecuta?" no depende de cuál instalaste más reciente ni de cuál es "la buena" — depende exclusivamente del orden de PATH.
which python3
type -a python3
command -v python3
Qué esperar:
$ which python3
/opt/homebrew/bin/python3
$ type -a python3
python3 is /opt/homebrew/bin/python3
python3 is /usr/bin/python3
$ command -v python3
/opt/homebrew/bin/python3
which te da solo el primer resultado —el que realmente se ejecuta si escribes python3 ahora mismo— porque hace la misma búsqueda que hace la shell: se detiene en el primer cajón con coincidencia. type -a es más honesto: te muestra todas las coincidencias en todo el PATH, en orden, para que veas exactamente qué versión quedó "tapada" por otra que aparece antes en la lista. command -v hace, para efectos prácticos, lo mismo que which, pero es un builtin de la propia shell definido por el estándar POSIX —no un programa externo separado, como suele ser which— y por eso es la opción recomendada dentro de scripts: existe garantizada en cualquier shell compatible con POSIX, reconoce alias y funciones de shell además de archivos en disco, y no depende de que which esté instalado en esa máquina.
type sin -a resuelve además un caso especial: comandos como cd o export no son archivos en ningún cajón del PATH — son builtins de la propia shell. which cd en muchos sistemas simplemente no encuentra nada, mientras que type cd responde correctamente cd is a shell builtin. Cuando dudes si algo que ejecutas es un programa en disco, un alias que tú mismo definiste hace meses, o una función de shell, type es el comando que no te va a mentir.
Variables comunes que ya tenías, aunque nunca las hayas mirado
Desde que abriste tu primera terminal, tu shell ya traía un puñado de variables de entorno configuradas — nadie te pidió que las crearas, existen porque el sistema y los programas que usas las necesitan para funcionar.
printenv HOME USER SHELL EDITOR LANG TERM
Qué esperar (valores de ejemplo — los tuyos van a variar según tu sistema y tu configuración):
/home/dev
dev
/bin/zsh
vim
en_US.UTF-8
xterm-256color
HOME: tu directorio personal. Es lo que usacdsin argumentos, y lo que expande el símbolo~cada vez que lo escribes en una ruta.USER: tu nombre de usuario según el sistema. En algunos contenedores mínimos puede no estar definida — no asumas que siempre existe si escribes un script que corre en cualquier entorno.SHELL: la ruta de tu shell de inicio de sesión por defecto. Ojo con un matiz que confunde a muchos:$SHELLno siempre es la shell en la que estás parado ahora mismo, sino la que el sistema tiene configurada como predeterminada para tu usuario — puedes estar dentro de bash con$SHELLapuntando a zsh si abriste bash manualmente.EDITOR: qué editor de texto invocan otros programas cuando necesitan que escribas algo —git commitsin-m,crontab -e. Si no está definida, muchos de esos programas fallan o caen a un editor por defecto que puede no ser el que esperas.LANG: el idioma y formato regional que usan los programas para mensajes, ordenamiento alfabético y formato de fecha.TERM: qué tipo de terminal cree el sistema que estás usando, y por lo tanto qué capacidades (colores, caracteres especiales) puede asumir un programa comolessovimal dibujar en pantalla.
Secretos y credenciales: por qué nunca van en el historial ni en el código
Tarde o temprano vas a necesitar que tu programa use una clave de API o una contraseña de base de datos. Las dos rutas rápidas que tienta usar son también las dos formas más comunes de filtrar un secreto real: escribirlo directamente como texto dentro del código fuente, o escribirlo directamente en la terminal con algo como export API_KEY=sk-live-....
La primera ruta lo deja atrapado en el historial de git para siempre, aunque lo borres en el siguiente commit — la versión con el secreto sigue viva en el historial del repositorio, accesible para cualquiera que tenga o consiga acceso a él. La segunda ruta lo deja escrito, en texto plano, en el archivo de historial de comandos que tu shell guarda automáticamente en disco — un archivo que sobrevive mucho después de que cierres esa sesión. La siguiente lección te muestra exactamente cómo funciona ese historial y cómo evitar que un secreto quede grabado ahí; por ahora, la regla operativa es simple: ningún secreto real se escribe nunca en una línea de comando ni se hardcodea en el código fuente.
La convención de la industria para esto es un archivo .env: texto plano, en la raíz del proyecto, con líneas NAME=value. No es magia de la shell — nada lo carga automáticamente por sí solo. Lo leen herramientas específicas (bibliotecas como python-dotenv, docker-compose, frameworks de Node) o tú mismo, exportando su contenido a tu sesión actual cuando lo necesitas.
Ejemplo trabajado
cat > .env <<'EOF'
DATABASE_URL=postgresql://app:supersecret@localhost:5432/appdb
API_KEY=sk-live-not-a-real-key-0000000000
EOF
chmod 600 .env
ls -l .env
Qué esperar:
-rw------- 1 dev dev 88 Jul 21 09:20 .env
chmod 600 recorta los permisos exactamente como los viste en la lección 3: lectura y escritura para el propietario, y cero permisos para el grupo y para otros. Sin ese chmod, un archivo .env recién creado suele heredar permisos como -rw-r--r--, que dejan el secreto legible para cualquier otra cuenta del mismo sistema — el criterio de menor privilegio aplicado a un archivo, no a un usuario.
Para llevar ese contenido a tu shell actual como variables de entorno de verdad:
set -a
source .env
set +a
printenv API_KEY
Qué esperar:
sk-live-not-a-real-key-0000000000
set -a le dice a la shell que marque para exportación automática toda variable que se cree de aquí en adelante; source .env ejecuta las líneas del archivo en tu shell actual, como si las hubieras tecleado tú; set +a apaga ese modo automático apenas termina. El archivo .env, además, nunca se commitea — se agrega a .gitignore antes del primer commit del proyecto, no después.
Errores comunes
"Si echo $VAR me muestra el valor, ya es una variable de entorno" (conceptual). Qué pasa: el estudiante define VAR=algo, corre echo $VAR, ve el valor, y concluye que export es un paso decorativo que no cambia nada real. Después escribe un script o un programa que depende de esa variable y el programa no la encuentra. Por qué ocurre: dentro de la misma shell, $VAR se expande igual exista o no el atributo de export — la diferencia solo existe para procesos hijos, y un simple echo nunca lanza uno. Cómo detectarlo: si un valor "desaparece" apenas lo usas dentro de un script, una subshell (bash -c '...') o cualquier programa externo, esa es la señal exacta. Cómo corregirlo: verifica con env | grep VAR o printenv VAR en vez de echo $VAR — esas dos solo listan lo que realmente está exportado — y si falta, agrégale export.
Sobrescribir el PATH en vez de agregarle una carpeta (conceptual). Qué pasa: para agregar una carpeta de scripts personales, el estudiante escribe export PATH=/home/dev/scripts. Segundos después, casi todos los comandos —ls, git, cat— empiezan a fallar con command not found. Por qué ocurre: esa línea no agrega nada: reemplaza el valor completo de PATH. La única carpeta que la shell va a revisar de ahí en adelante es /home/dev/scripts; todas las que traía el sistema —/usr/bin, /bin, /usr/local/bin— quedan fuera de la búsqueda, aunque los binarios sigan exactamente donde estaban en el disco. Cómo detectarlo: casi cualquier comando deja de encontrarse inmediatamente después de una línea así, y abrir una terminal nueva soluciona el problema por sí solo —porque esa terminal nueva recarga su PATH desde cero, sin heredar tu error—, lo que confirma que el daño vivía solo en esa sesión de shell. Cómo corregirlo: siempre conserva el valor existente al modificar PATH: export PATH="/home/dev/scripts:$PATH" antepone tu carpeta sin perder ninguna de las anteriores.
"Instalé una versión nueva, pero la terminal sigue corriendo la vieja" (troubleshooting). Qué pasa: el estudiante instala una versión más nueva de una herramienta —con un gestor de versiones, con un paquete nuevo— en una carpeta que debería tener prioridad, pero al ejecutar el comando sigue corriendo el binario anterior. Por qué ocurre: hay dos causas distintas y hay que descartarlas en orden. La primera es simplemente el orden de PATH: la carpeta nueva puede estar configurada después de la vieja en la lista, así que la vieja sigue ganando. La segunda, más sutil, es que bash (y de forma equivalente zsh) recuerda en una tabla interna la ubicación de cada comando que ya ejecutaste en esta sesión, para no tener que recorrer todo el PATH cada vez — si instalaste el programa nuevo después de haber corrido ya el viejo en esta misma shell, bash sigue usando la ubicación que memorizó, aunque el PATH esté perfectamente ordenado. Cómo detectarlo y corregirlo: corre type -a nombre-del-comando para ver todas las coincidencias en el orden real de tu PATH — si la que esperas ya aparece primero en esa lista y aun así se sigue ejecutando la otra, el problema es la tabla cacheada, no el orden. En bash se corrige con hash -r, que la fuerza a olvidar todo lo memorizado y volver a buscar desde cero; en zsh el comando equivalente es rehash. Abrir una terminal nueva también resuelve el problema, porque esa tabla nunca sobrevive entre sesiones.
Ejercicios
1. Antes de correr nada, predice la salida completa de este script, línea por línea, y explica por qué cada línea es lo que es:
COLOR="blue"
bash -c 'echo "Color visto por el hijo: ${COLOR:-sin valor}"'
export COLOR
bash -c 'echo "Color visto por el hijo: ${COLOR:-sin valor}"'
unset COLOR
bash -c 'echo "Color visto por el hijo: ${COLOR:-sin valor}"'
Ver solución
Color visto por el hijo: sin valor
Color visto por el hijo: blue
Color visto por el hijo: sin valor
La primera subshell nace antes de que exista cualquier export, así que COLOR todavía es solo una variable de shell — el hijo no la hereda y cae al valor por defecto (sin valor). La segunda subshell nace después del export, así que recibe una copia de COLOR en el instante de nacer, y la imprime. La tercera subshell nace después del unset, que no vació el valor sino que borró la variable entera —incluido el atributo de export— así que vuelve a caer al valor por defecto, exactamente como al principio. Esto funciona porque cada subshell recibe una copia congelada del entorno del momento exacto en que se lanza, nunca una vista en vivo de lo que pase después en la shell padre.
2. Tu PATH es /home/dev/.local/bin:/usr/local/bin:/usr/bin:/bin. En el disco existen /usr/local/bin/node y /usr/bin/node, pero no hay ningún node en /home/dev/.local/bin. ¿Qué binario se ejecuta cuando escribes node, y qué imprimiría type -a node?
Ver solución
Se ejecuta /usr/local/bin/node. La shell recorre PATH de izquierda a derecha: primero revisa /home/dev/.local/bin, no encuentra nada ahí y sigue; la siguiente carpeta, /usr/local/bin, sí tiene un node, así que se detiene ahí mismo — nunca llega a revisar /usr/bin, aunque ese directorio también tenga una coincidencia. type -a node mostraría las dos, en ese mismo orden:
node is /usr/local/bin/node
node is /usr/bin/node
Esto funciona porque type -a no se detiene en el primer resultado como lo hace la búsqueda real de la shell — te muestra todas las coincidencias del PATH completo, para que puedas ver cuál gana y cuál queda tapada.
3. Un compañero pega esta línea en su terminal para agregar su carpeta de scripts personales:
export PATH=/home/dev/scripts
Inmediatamente después, casi todos los comandos —incluido ls— fallan con command not found. ¿Qué pasó, y cómo lo arreglarías tanto para desbloquear esa terminal ahora mismo como para hacerlo bien la próxima vez?
Ver solución
Esa línea no agrega una carpeta: reemplaza el valor completo de PATH. A partir de ese momento la shell solo busca comandos dentro de /home/dev/scripts; todas las carpetas que antes tenía —/usr/bin, /bin, /usr/local/bin— quedaron fuera de la búsqueda, así que hasta ls deja de encontrarse, aunque el binario siga exactamente en el mismo lugar del disco. Para desbloquear esa terminal ahora mismo, la forma más simple es cerrarla y abrir una nueva: una terminal nueva recarga PATH desde cero en sus propios archivos de arranque, sin heredar el error que se tecleó a mano en la sesión anterior. La forma correcta de agregar una carpeta es siempre conservando el valor existente: export PATH="/home/dev/scripts:$PATH" antepone la carpeta nueva sin perder ninguna de las anteriores. Esto funciona porque PATH es una sola variable con una lista completa adentro — modificarla bien significa siempre partir del valor que ya tenía ($PATH) y añadir algo, nunca reemplazarlo entero.
4. Ves esto al listar un archivo de tu proyecto:
$ ls -l .env
-rw-r--r-- 1 dev dev 64 Jul 21 09:20 .env
Ese archivo guarda la contraseña de una base de datos. ¿Qué tienen de mal estos permisos, y qué único comando los corrige?
Ver solución
-rw-r--r-- da permiso de lectura tanto al grupo como a "otros" —la r en la quinta y en la octava posición—, lo que significa que cualquier otra cuenta del mismo sistema puede abrir el archivo y leer la contraseña en texto plano, aunque solo el propietario debería necesitar tocarlo. El comando que lo corrige es chmod 600 .env, que deja lectura y escritura solo para el propietario y quita todo permiso al grupo y a otros. Esto funciona porque es el mismo modelo rwx/octal de la lección 3, aplicado aquí a un archivo que guarda un secreto en vez de código — el criterio de menor privilegio no distingue entre los dos casos.
Resumen y siguiente paso
Hoy viste que export es la frontera exacta entre una variable de shell —visible solo dentro de tu propia sesión— y una variable de entorno, que un proceso hijo recibe como una copia congelada en el instante en que nace, no como una vista en vivo de lo que pase después. Viste que PATH no es más que una lista ordenada de carpetas que la shell recorre de izquierda a derecha, deteniéndose en la primera coincidencia, y que command not found casi siempre significa "no está en ninguna carpeta de esta lista" o "quedó tapado por otra versión que aparece antes". Viste which, type y command -v como tres formas distintas de diagnosticar cuál binario gana cuando hay más de uno instalado, y viste que un secreto real solo pertenece a un archivo .env con permisos 600, nunca al código fuente ni a una línea de comando escrita a mano.
Antes de avanzar deberías poder: explicar de memoria, sin mirar apuntes, la diferencia entre variable de shell y variable de entorno; leer tu propio $PATH y decir con seguridad qué carpeta gana si dos tienen instalado un binario con el mismo nombre; usar type -a para diagnosticar por qué sigue corriendo una versión "vieja" de un programa después de instalar una nueva; y crear un .env de prueba con permisos 600.
Lo que aprendiste hoy —que cada shell nueva arranca con su propio PATH y su propio entorno, y que un cambio que tecleas a mano vive solo mientras esa sesión siga abierta— es exactamente el problema que resuelve la siguiente lección: por qué "en una terminal funciona y en otra no" casi siempre significa que una terminal cargó un archivo de arranque —.bashrc, .zshrc, .bash_profile— que la otra no cargó, y cómo hacer que un cambio de PATH sobreviva más allá de la sesión en la que lo escribiste.
Recursos
- Bourne Shell Variables — Bash Reference Manual (GNU) — definición oficial de
PATH,HOMEy el resto de las variables heredadas del shell Bourne. - Bourne Shell Builtins — Bash Reference Manual (GNU) — comportamiento exacto de
hash,typeycommanddocumentado por el propio proyecto Bash. - environ(7) — Linux manual page — cómo un proceso hijo hereda su entorno y qué variables estándar existen.
- command — Shell & Utilities, POSIX.1-2017 (The Open Group) — especificación portátil de
command -vfrente awhich. - The Twelve-Factor App — Config — por qué la configuración, incluidos los secretos, pertenece al entorno y no al código fuente.
- Secrets Management Cheat Sheet — OWASP Cheat Sheet Series — buenas prácticas para manejar credenciales y evitar que terminen en el código o en el historial.