abelitolll
Note #9Blog

Dockerizar una app Node.js y PostgreSQL para producción

Configurar todo a mano en un VPS funciona, pero es tedioso repetirlo en cada servidor nuevo: instalar Node, Postgres, Nginx, PM2, ajustar cada uno por separado. Docker empaqueta todo eso en contenedores que se levantan igual en cualquier máquina. Este post dockeriza la misma app Node.js + PostgreSQL de la serie manual, para después subirla a un VPS en producción.

Antes que nada, hay un detalle en server.js que hay que cambiar sí o sí para Docker:

app.listen(port, '127.0.0.1', ...)

En la serie manual eso era correcto: la app y Nginx vivían en la misma máquina. Con Docker, cada contenedor es como una máquina separada, así que 127.0.0.1 significaría “solo yo mismo dentro de este contenedor” y Nginx no podría alcanzarla. Lo cambiaremos a 0.0.0.0, y eso no es un retroceso de seguridad: el contenedor no expone ese puerto al exterior, solo a la red interna de Docker. La protección ahora la da Docker en vez de la interfaz de red.

1. Configurar Docker

1.1. Dockerfile

Un Dockerfile es la receta para construir una imagen: un paquete que contiene Linux, Node, las dependencias y el código, todo junto. Un contenedor es esa imagen en ejecución. La imagen es la receta cocinada; el contenedor es el plato servido.

En la raíz del proyecto (junto a server.js), crea un archivo llamado exactamente Dockerfile, sin extensión:

FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

USER node

EXPOSE 3000

CMD ["node", "server.js"]

Línea por línea:

  • FROM: parte de una imagen oficial que ya trae Linux y Node 22. alpine es una versión mínima de Linux, unas 10 veces más liviana.
  • WORKDIR: crea y entra en la carpeta /app dentro de la imagen.
  • COPY package.json* y RUN npm ci: copia solo los archivos de dependencias y las instala. Se hace antes de copiar el código porque Docker guarda cada paso en caché: si cambias el código pero no las dependencias, no vuelve a instalarlas. npm ci instala las versiones exactas del package-lock.json, y --omit=dev deja fuera las de desarrollo.
  • COPY . .: copia el código.
  • USER node: la app corre como usuario sin privilegios — el mismo principio de pasar de root a un usuario normal en el servidor.
  • EXPOSE: documenta que la app usa el puerto 3000.
  • CMD: el comando que se ejecuta al arrancar el contenedor.

1.2. .dockerignore

Cuando Docker construye la imagen, primero copia toda la carpeta del proyecto. El .dockerignore le dice qué dejar fuera, y no es solo por tamaño: es una medida de seguridad.

El riesgo real: el Dockerfile tiene COPY . ., así que sin este archivo copiaría el .env con la contraseña de la base de datos dentro de la imagen. Esa imagen viaja a servidores y registros, y cualquiera con acceso a ella podría extraer el archivo. Es el equivalente a subir credenciales a GitHub.

En la raíz del proyecto, crea un archivo llamado .dockerignore:

node_modules
npm-debug.log
.env
.env.*
.git
.gitignore
Dockerfile
.dockerignore
*.md
.vscode

Por qué cada grupo:

  • node_modules: las dependencias locales se compilaron para tu sistema; dentro de la imagen se instalan de nuevo para Linux con npm ci. Copiarlas rompería la app.
  • .env y .env.*: las credenciales nunca entran en la imagen. Las variables se inyectan al arrancar el contenedor, con Compose.
  • .git: el historial completo puede pesar mucho y no aporta nada dentro de la imagen.
  • El resto: documentación y configuración de editor, innecesarias en producción.

1.3. docker-compose.yml

El Dockerfile construye una imagen. Pero el sistema son varias piezas que deben levantarse juntas, hablarse entre sí y arrancar en cierto orden. Docker Compose es el archivo que describe todo eso.

Dos conceptos antes de ver el archivo:

La red interna. Compose crea una red privada donde los contenedores se ven por su nombre. La app se conectará a la base usando db como si fuera una dirección, en lugar de 127.0.0.1. Esa red no es accesible desde internet.

Los volúmenes. Un contenedor es desechable: si lo borras, se va todo lo que tenía dentro. Un volumen es una carpeta que vive fuera del contenedor y sobrevive. Ahí van los datos de Postgres. Esto es crítico en producción: sin volumen, cada actualización borraría la base de datos.

Por ahora vamos con app y base de datos. Nginx se agrega después, cuando esto funcione.

En la raíz del proyecto, crea docker-compose.yml:

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      PORT: 3000
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:3000:3000"

volumes:
  db_data:

Lo importante de cada parte:

  • image vs build: la base usa una imagen oficial ya hecha; la app se construye con el Dockerfile (build: .).
  • restart: unless-stopped: si el contenedor se cae, Docker lo levanta solo. Esto reemplaza a PM2.
  • environment: las credenciales se inyectan aquí, no viven en la imagen. Los ${...} se leen de un archivo .env.
  • volumes: los datos de Postgres viven en db_data, fuera del contenedor.
  • healthcheck y depends_on con condition: Postgres tarda unos segundos en estar listo. Sin esto, la app arrancaría antes y fallaría al conectarse — un error clásico en producción.
  • ports con 127.0.0.1:: ese prefijo es importante. Publica el puerto solo hacia la máquina, no hacia internet. Sin él, Docker abriría el 3000 al mundo saltándose el firewall de ufw, una trampa conocida de Docker.

Fíjate en que la base de datos no tiene ports: solo es accesible desde la red interna, igual que cuando escuchaba en 127.0.0.1 en el servidor manual.

1.4. El archivo .env

Compose lee automáticamente un archivo llamado .env en la raíz del proyecto y reemplaza los ${...} del docker-compose.yml con esos valores.

Crea .env en la raíz:

POSTGRES_DB=todoapp
POSTGRES_USER=todouser
POSTGRES_PASSWORD=pon_aqui_una_clave_larga

Cambia esa contraseña por una larga y aleatoria. No reutilices contraseñas de otros servidores: es una base nueva, aprovecha para generar una fuerte.

Verifica que esté ignorado. Abre el .gitignore y confirma que contiene .env. Si no está, agrégalo ahora. Esto no es opcional: si ese archivo llega a GitHub, las credenciales quedan expuestas y hay bots que escanean repositorios buscando exactamente eso.

Documenta las variables sin exponerlas. Crea también .env.example, que sí sube a git:

POSTGRES_DB=todoapp
POSTGRES_USER=todouser
POSTGRES_PASSWORD=

Sirve para que cualquiera (tú en el futuro, o un compañero) sepa qué variables hacen falta, sin revelar sus valores. Es práctica estándar.

Un detalle: ya no aparece DATABASE_URL en el .env. Compose la arma sola en el docker-compose.yml, combinando usuario, contraseña y el nombre db. Así la contraseña se escribe en un solo lugar.

1.5. Ajustar server.js

Dos cambios. Abre server.js y cambia la última parte:

app.listen(port, '0.0.0.0', () => {
  console.log(`Servidor escuchando en el puerto ${port}`);
});

Era 127.0.0.1. Dentro de un contenedor, eso significa “solo yo mismo” y ningún otro contenedor podría alcanzar la app. No se pierde seguridad: el puerto sigue cerrado a internet porque en Compose se publicó como 127.0.0.1:3000:3000.

Segundo cambio: borra la primera línea del archivo.

require('dotenv').config();

Ya no hace falta, porque ahora es Docker quien inyecta las variables al contenedor. Y con esto se puede quitar la dependencia:

npm uninstall dotenv

1.6. Primera prueba

En la terminal, dentro de la carpeta del proyecto:

docker compose up --build

La primera vez tardará unos minutos: descarga las imágenes base y construye la propia. Verás mucho texto, con líneas de db y de app mezcladas. Es normal.

Cuando se detenga y veas “Servidor escuchando en el puerto 3000”, abre en el navegador:

http://localhost:3000

Verás el frontend, pero la lista de tareas fallará. Eso es esperado: la base está vacía y la tabla tareas no existe todavía. Esa es la siguiente tarea, y es un tema importante de producción.

Para detener todo: Ctrl+C en esa terminal.

1.7. La tabla, creada automáticamente

Aquí hay una decisión importante de producción. En la serie manual, la tabla se creaba a mano con psql. Eso no sirve ahora, porque rompe justo lo que Docker da: si mañana se levanta esto en otro servidor, la base arrancaría vacía y habría que recordar entrar a crear tablas manualmente. La estructura de la base debe estar en el código, versionada en git.

La imagen de Postgres trae una función para esto: cualquier archivo .sql que se ponga en una carpeta especial se ejecuta automáticamente la primera vez que se crea la base de datos.

Crea una carpeta db en el proyecto, y dentro un archivo init.sql:

CREATE TABLE IF NOT EXISTS tareas (
  id SERIAL PRIMARY KEY,
  titulo TEXT NOT NULL,
  creada_en TIMESTAMP DEFAULT NOW()
);

Fíjate en que ya no se necesitan los GRANT ni los ALTER TABLE OWNER que daban problemas en la serie manual. La razón: ese script se ejecuta conectado como todouser (indicado en el .env que lee el compose), así que la tabla nace siendo suya.

Ahora monta esa carpeta en el contenedor de la base. En docker-compose.yml, dentro del servicio db, agrega la segunda línea en volumes:

    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db:/docker-entrypoint-initdb.d:ro

Esa línea conecta la carpeta local ./db con la carpeta especial dentro del contenedor. El :ro significa solo lectura, para que el contenedor no pueda modificar esos archivos.

Un detalle clave: ese script solo se ejecuta si el volumen está vacío, es decir, la primera vez. Como la base ya se levantó antes, hay que borrar el volumen para que se aplique:

docker compose down -v
docker compose up --build

El -v borra los volúmenes. Cuidado con ese comando en producción: borra todos los datos. Aquí no importa porque la base está vacía.

docker compose down      → apaga (datos a salvo)
docker compose down -v   → apaga y BORRA LOS DATOS

Cuando levante, entra a http://localhost:3000 y prueba crear y eliminar tareas. Debería funcionar completo.

Y algo importante: si ahora haces docker compose down (sin -v) y vuelves a levantar, las tareas seguirán ahí. Ese es el volumen haciendo su trabajo.

2. Configurar Nginx

Ya con la app y la base de datos corriendo en contenedores, con los datos persistiendo, el siguiente paso es agregar Nginx, que es lo que convierte esto en un sistema completo.

2.1. Nginx dentro de Docker

Primero la configuración. Crea una carpeta nginx en el proyecto, y dentro un archivo default.conf:

server {
    listen 80;
    server_name _;

    client_max_body_size 5M;

    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        limit_req zone=mizona burst=20 nodelay;

        proxy_hide_header X-Powered-By;

        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Es casi igual al de la serie manual, con una diferencia clave: proxy_pass http://app:3000 usa el nombre del servicio en vez de localhost. Así funciona la red de Docker.

Por ahora solo el puerto 80 y server_name _, porque esto corre en local. El dominio y el HTTPS se agregan al llegar al servidor.

Ahora el limit_req_zone, que en la serie manual iba en el archivo global de Nginx. Crea nginx/nginx.conf:

user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    sendfile on;
    keepalive_timeout 65;

    limit_req_zone $binary_remote_addr zone=mizona:10m rate=10r/s;

    include /etc/nginx/conf.d/*.conf;
}

Aquí se ve algo importante de Docker: como el contenedor se recrea desde cero cada vez, la configuración completa debe estar en el proyecto, no editada a mano en el servidor.

Ahora agrega el servicio al docker-compose.yml, después de app y antes de volumes:

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

Y un cambio en el servicio app: elimina las dos líneas de ports. Ya no hace falta exponer el 3000, porque ahora solo Nginx habla con la app, por la red interna. La app queda completamente invisible desde fuera.

Queda algo como esto:

services:
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    build: .
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
      PORT: 3000
    depends_on:
      db:
        condition: service_healthy

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

volumes:
  db_data:

2.2. Levanta todo

docker compose down
docker compose up --build

Ahora entra a http://localhost (sin puerto). Y comprueba que http://localhost:3000 ya no responde.