Cómo ejecutar Node.js y Python en Docker en VPS: guía práctica
Aprende a ejecutar Node.js y Python en Docker en una VPS Linux: Dockerfile, docker compose, volúmenes, redes aisladas y despliegue reproducible.
Ejecutar aplicaciones Node.js y Python en Docker en una VPS resuelve tres problemas que aparecen en cualquier despliegue no trivial: dependencias del sistema que chocan entre proyectos, versión de runtime fija por host y entornos que “funcionan en mi máquina” pero explotan en producción. Con Docker, cada aplicación carga su propia versión de Node, Python, librerías del sistema y configuración — aisladas en contenedores reproducibles.
Esta guía cubre el flujo completo: instalar Docker y Docker Compose en la VPS, escribir Dockerfiles compactos para Node.js (Express/Fastify) y Python (FastAPI/Flask), orquestar con docker compose, definir redes aisladas para la comunicación entre servicios, persistir datos con volúmenes y validar que todo sube automáticamente tras un reboot. El foco está en patrones reales de producción — no ejemplos “hello world” que no escalan.
Tiempo estimado de ejecución: 35-45 minutos para leer, aplicar y validar en una VPS limpia con Ubuntu 24.04 LTS. Si ya tienes Docker instalado, salta directamente a la sección de los Dockerfiles.
Prerrequisitos
Necesitas una VPS Linux con Ubuntu 24.04 LTS (o Debian 12+), acceso SSH como usuario con sudo, y al menos 2 GB de RAM libres para ejecutar Node y Python en paralelo. Ten también el código de la aplicación versionado en Git (GitHub, GitLab, o repo privado) — vamos a clonarlo dentro del build de la imagen.
Ubuntu 24.04 LTS 2 GB 27.x o superior v2 (plugin oficial) Instalando Docker Engine en la VPS
La instalación estándar vía apt install docker.io trae una versión desactualizada
del Ubuntu Universe. El camino correcto es usar el repositorio oficial de Docker,
que entrega releases actuales y el plugin docker compose v2 integrado.
Actualiza el sistema e instala dependencias:
sudo apt update
sudo apt install -y ca-certificates curl gnupg lsb-releaseEstas dependencias son prerrequisito para añadir el repositorio con clave
GPG verificada — no saltes este paso, sino apt rechaza el repo.
Añade la clave GPG oficial de Docker:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpgConfigura el repositorio e instala Docker Engine + plugin Compose:
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-pluginAñade tu usuario al grupo docker para no necesitar sudo:
sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-worldEl hello-world confirma que el daemon está corriendo y tu usuario tiene
permiso. Si aparece “Hello from Docker!”, todo está correcto.
Dockerfile para aplicación Node.js
Para Node.js, el patrón recomendado es multi-stage build: una etapa instala dependencias y compila (si es TypeScript), otra etapa final solo carga el runtime y los artefactos listos. Resultado: imagen final 5-10x más pequeña.
En el directorio raíz de tu proyecto Node, crea el archivo Dockerfile:
# Stage 1: build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
# Stage 2: runtime
FROM node:22-alpine
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodeapp -u 1001
COPY --from=builder --chown=nodeapp:nodejs /app /app
USER nodeapp
EXPOSE 3000
CMD ["node", "server.js"]La imagen node:22-alpine pesa ~45 MB frente a ~380 MB de node:22. El usuario
no root (nodeapp) es defense-in-depth — si la aplicación se ve comprometida,
el atacante no tiene root dentro del contenedor.
Crea un .dockerignore para reducir el contexto de build:
node_modules
npm-debug.log
.git
.env
.env.*
dist
coverage
*.mdSin esto, Docker copia el node_modules local (que puede tener 500 MB+) hacia
dentro del contexto, dejando el build lento y la imagen inflada.
Dockerfile para aplicación Python
Para Python, el patrón es parecido: imagen base slim, virtualenv aislado o
instalación directa, y usuario no root. FastAPI con Uvicorn es el ejemplo
moderno; para Flask o Django el esqueleto es idéntico, solo cambiando el CMD.
Crea el Dockerfile en el directorio del proyecto Python:
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
RUN useradd -m -u 1001 pyapp
COPY --from=builder /root/.local /home/pyapp/.local
COPY --chown=pyapp:pyapp . .
USER pyapp
ENV PATH=/home/pyapp/.local/bin:$PATH
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]La flag PYTHONUNBUFFERED=1 fuerza a stdout/stderr a salir sin buffer — sin
esto, los logs quedan atrapados en memoria y solo aparecen cuando el proceso muere,
lo que dificulta el debug en producción.
El tag python:3.12 descarga la imagen completa con 1 GB+. Usa python:3.12-slim
(150 MB) o python:3.12-alpine (50 MB). Alpine a veces rompe paquetes que
dependen de glibc (numpy, pandas con extensiones C) — en ese caso, quédate con
slim.
Orquestando con Docker Compose
En producción real, raramente ejecutas un único contenedor. El escenario típico es
Node + Python + base de datos + Redis, todos coordinados. El docker compose resuelve esto con un archivo declarativo.
En la raíz del directorio que contiene ambas aplicaciones, crea
docker-compose.yml:
services:
api-node:
build: ./node-app
container_name: api-node
restart: unless-stopped
networks:
- backend
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://app:senha@db:5432/appdb
depends_on:
- db
api-python:
build: ./python-app
container_name: api-python
restart: unless-stopped
networks:
- backend
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://app:senha@db:5432/appdb
depends_on:
- db
db:
image: postgres:16-alpine
container_name: postgres-db
restart: unless-stopped
networks:
- backend
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=senha
- POSTGRES_DB=appdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
backend:
driver: bridge
volumes:
postgres-data:Las dos aplicaciones comparten la red backend y resuelven la base de datos por el
nombre db (DNS interno de Docker). Los volúmenes nombrados persisten datos de
base entre docker compose down y up.
Levanta el stack completo:
docker compose up -d --build
docker compose ps
docker compose logs -f api-nodeLa flag -d ejecuta en background, --build reconstruye imágenes si los
Dockerfiles cambiaron. logs -f sigue el output en tiempo real — Ctrl+C sale
del follow sin parar el contenedor.
El docker-compose.yml de arriba tiene contraseña hardcoded para simplificar. En
producción, usa un archivo .env junto al compose con variables (ej:
POSTGRES_PASSWORD=...) y referéncialas como ${POSTGRES_PASSWORD}. Añade
.env al .gitignore.
Verificación
Confirma que todo subió y está accesible.
Testea los endpoints HTTP de la VPS:
curl -i http://localhost:3000/health
curl -i http://localhost:8000/healthCada uno debe retornar HTTP/1.1 200 OK con payload de tu aplicación. Si
retorna Connection refused, el contenedor no está escuchando en la puerta
esperada — revisa docker compose logs <servicio>.
Valida que todo sube solo tras reboot:
sudo rebootTras reconectar vía SSH (espera 30-60s), ejecuta docker compose ps. Los
contenedores deben estar Up debido al restart: unless-stopped. Si
no, el servicio docker.service puede no estar habilitado en el boot — corrígelo
con sudo systemctl enable docker.
Resolución de problemas
”Cannot connect to the Docker daemon”
Significa que tu usuario no está en el grupo docker o la sesión SSH no
recargó el grupo. Sal y entra de nuevo en SSH, o ejecuta newgrp docker.
Contenedor reinicia en loop (equivalente a CrashLoopBackOff)
Ejecuta docker compose logs <servicio> para ver el error real. Causas más
comunes: variable de entorno faltante, base de datos no está lista cuando la app
intenta conectar (usa depends_on + healthcheck), o puerto ya en uso en el
host. sudo ss -tlnp | grep <puerto> muestra quién lo ocupa.
Build lento o disco lleno
Imágenes antiguas y contenedores parados se acumulan. Limpia con docker system prune -a --volumes. Cuidado: elimina TODO lo que no está en uso, incluyendo
volúmenes no referenciados.
Próximos pasos
Con Docker funcionando, vale la pena profundizar en tres frentes:
- Reverse proxy con TLS: pon Caddy o Nginx delante de los contenedores para tener HTTPS automático con Let’s Encrypt y dominio propio.
- Health checks en el compose: añade
healthcheck:en los servicios para quedepends_onespere a que la base de datos esté lista, no solo “iniciada”. - CI/CD con GitHub Actions: automatiza build + push de la imagen al Docker Hub o GHCR y deploy vía SSH en la VPS.
Si estás llevando esto a producción, una VPS Hostini ya viene con kernel actualizado y soporte nativo a cgroups v2 — lo que evita incompatibilidades con versiones recientes de Docker y mejora el aislamiento de recursos entre contenedores.
Preguntas frecuentes
¿Puedo ejecutar Node.js y Python en el mismo contenedor?
Técnicamente sí, pero es un anti-patrón. Cada contenedor debe tener un único proceso principal (PID 1). Lo correcto es dos contenedores separados en la misma red Docker, comunicándose mediante DNS interno (el nombre del servicio resuelve a la IP del contenedor).
¿Cuál es la diferencia entre COPY y ADD en el Dockerfile?
COPY copia archivos locales dentro de la imagen — es lo que casi siempre quieres. ADD hace lo mismo pero también acepta URL HTTP y extrae automáticamente archivos .tar. Por previsibilidad, usa COPY salvo cuando necesites explícitamente el auto-extract.
¿Por qué mi aplicación Node no accede a localhost:5432 del Postgres en otro contenedor?
Dentro del contenedor, 'localhost' apunta al propio contenedor, no al host. Usa el nombre del servicio definido en el docker-compose.yml (ej: postgres:5432) o el nombre del contenedor. Se resuelven mediante el DNS interno de Docker.
¿Cómo hago hot-reload del código sin reconstruir la imagen?
Monta el directorio del código como volumen bind en el docker-compose.yml (ej: ./src:/app/src) y usa nodemon (Node) o uvicorn --reload (Python). En producción esto no es recomendable — solo para desarrollo local.
¿Necesito root para instalar paquetes dentro del contenedor?
No en producción. Crea un usuario no-root en el Dockerfile (USER node o USER 1001) y ejecuta el proceso con él. Si un paquete necesita root para instalarse, hazlo antes del USER — instala como root y luego cambia al usuario no privilegiado para el runtime.
Los logs del contenedor llenan el disco. ¿Cómo lo soluciono?
Configura log rotation en /etc/docker/daemon.json con driver json-file, max-size y max-file. Ejemplo: max-size=10m, max-file=3 limita 30 MB por contenedor. Reinicia el daemon Docker y los nuevos contenedores respetan el límite.