Desplegar una aplicación Express en DigitalOcean
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Tienes una aplicación Express que corre bien con node server.js en tu computadora, y una cuenta de DigitalOcean. Lo que falta es todo lo que hay entre esas dos cosas: un servidor que se mantenga activo después de cerrar tu terminal, una base de datos Postgres real en lugar de un objeto en memoria, un contenedor que reinicie el proceso si se cae, y un certificado para que la aplicación responda en https:// en lugar de una dirección IP simple. Nada de eso es difícil por sí solo, pero cada pieza tiene su propia manera pequeña de salir mal.
Esta guía construye todo con Docker Compose: un contenedor para Postgres, uno para la aplicación Express, y uno para Caddy que maneja el HTTPS. La aplicación en sí es pequeña a propósito: dos rutas, una de ellas conectada a un Pool real de pg, para que haya una conexión real a una base de datos que configurar, y no solo una respuesta estática. Al final tendrás un droplet sirviendo la aplicación en tu propio dominio, con una forma repetible de lanzar nuevas versiones y respaldar la base de datos. Luego vemos el atajo: subir el mismo repositorio a Railyard y dejar que lea package.json por ti.
Lo que necesitas
- Un droplet de DigitalOcean. El plan Basic de 1 vCPU / 2 GB de RAM corre Postgres, el proceso de Node y Caddy juntos sin problema, y cuesta actualmente $12 USD/mes; el plan de 1 GB ($6 USD/mes) funciona para una aplicación pequeña sin mucha concurrencia, pero deja poco margen una vez que Postgres también está corriendo.
- Un nombre de dominio, con acceso para agregar un registro
A. - Un repositorio de GitHub (o cualquier otro proveedor de Git) con tu proyecto Express.
- Una cuenta de DigitalOcean con la facturación configurada, y una llave SSH agregada a tu cuenta.
- Docker instalado localmente si quieres validar el archivo Compose y el Caddyfile antes de copiarlos al servidor, que es lo que hace esta guía.
Paso 1: crear el droplet
Desde el panel de DigitalOcean, crea un nuevo droplet: elige la imagen Ubuntu 24.04 LTS, el plan Basic de 2 GB, una región cercana a tus usuarios, y tu llave SSH. Una vez que esté corriendo, anota su dirección IP pública y apunta el registro A de tu dominio hacia ella:
myapp.example.com. A 203.0.113.20
La propagación de DNS puede tardar desde unos minutos hasta algunas horas. Puedes seguir configurando el servidor mientras tanto; Caddy solo necesita que el registro DNS esté correcto para cuando intente emitir un certificado.
Conéctate por SSH como root e instala Docker:
ssh root@203.0.113.20
curl -fsSL https://get.docker.com | sh
apt-get install -y docker-compose-plugin
Verifica que funcionó:
docker compose version
Deberías ver una cadena de versión para el plugin de Compose, no un error de “command not found”.
Paso 2: escribir la aplicación Express
Express 5.3.0 es la versión actual en npm de Express 5, la versión mayor actual, y pg 8.23.1 es la versión actual en npm de node-postgres, el driver estándar para hablar con Postgres desde Node sin un ORM. Inicializa el proyecto e instala ambos:
npm init -y
npm install express@^5.3.0 pg@^8.23.1
package.json debería terminar con estos campos, además de lo que npm init haya llenado:
{
"name": "myapp",
"version": "1.0.0",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.3.0",
"pg": "^8.23.1"
}
}
Ahora server.js. Esta aplicación tiene dos rutas: un chequeo de salud simple, y una que consulta Postgres a través de un pool de conexiones que lee DATABASE_URL del entorno.
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
app.get("/", (req, res) => {
res.json({ status: "ok" });
});
app.get("/widgets", async (req, res) => {
const result = await pool.query(
"SELECT id, name, created_at FROM widgets ORDER BY id DESC LIMIT 20"
);
res.json(result.rows);
});
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "internal error" });
});
const port = process.env.PORT || 3000;
app.listen(port, "0.0.0.0", () => {
console.log(`listening on ${port}`);
});
Un par de cosas que vale la pena notar. app.listen se enlaza a 0.0.0.0, no al localhost por defecto, lo cual importa una vez que la aplicación corre dentro de un contenedor: un proceso que escucha solo en localhost dentro de un contenedor es inalcanzable desde fuera de él, incluso con el puerto publicado. La aplicación lee process.env.PORT, con 3000 como valor de respaldo para desarrollo local, pero la configuración del droplet más abajo define PORT explícitamente, así que ese respaldo nunca se usa realmente ahí.
La ruta /widgets no tiene try/catch alrededor de la llamada await pool.query(...), y eso es deliberado: Express 5 cambió cómo maneja los errores lanzados dentro de manejadores de rutas async. En Express 4, una promesa rechazada dentro de un manejador async tenía que atraparse y pasarse a next(err) manualmente, o la solicitud quedaba colgada sin ninguna respuesta. Express 5 hace esto automáticamente: una promesa rechazada de un manejador async se reenvía al middleware de manejo de errores de la misma forma que lo haría un throw síncrono. Por eso el manejador de errores al final de este archivo (el de cuatro argumentos) basta para atrapar una consulta fallida sin envolver cada cuerpo de ruta en try/catch.
También necesitarás una tabla para que esa consulta devuelva algo. Una vez que Postgres esté corriendo (Paso 4 más abajo), créala:
docker compose exec -T db psql -U myapp -d myapp -c "CREATE TABLE widgets (id serial PRIMARY KEY, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now());"
Paso 3: escribir el Dockerfile
FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:24-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
USER node
EXPOSE 3000
CMD ["node", "server.js"]
node:24-alpine es una versión LTS actual de Node.js y una imagen base pequeña. La etapa deps ejecuta npm ci --omit=dev solo contra el archivo de bloqueo, así que la instalación de dependencias queda en caché por separado de copiar el resto del código fuente; un cambio en server.js no obliga a que npm ci se vuelva a ejecutar. La etapa runner copia el node_modules instalado desde deps, luego el código fuente de la aplicación, y corre como node, el usuario sin privilegios de root que ya viene en la imagen oficial de Node, en lugar de root. EXPOSE 3000 documenta el puerto en el que la aplicación escucha dentro del contenedor; no publica nada por sí mismo, eso lo manejan Caddy y Compose en el siguiente paso.
Paso 4: escribir docker-compose.yml
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
restart: unless-stopped
env_file: .env
depends_on:
db:
condition: service_healthy
expose:
- "3000"
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- web
volumes:
postgres_data:
caddy_data:
caddy_config:
Solo Caddy publica puertos hacia el exterior; web y db quedan alcanzables únicamente desde contenedores en la misma red de Compose, lo que es una cosa menos expuesta a internet por accidente. web depende de que db esté saludable, no solo iniciado, para que la aplicación no intente conectarse antes de que Postgres realmente acepte conexiones.
Validé este archivo localmente antes de usarlo:
docker compose -f /tmp/compose-check/docker-compose.yml config
Se analizó limpiamente sin advertencias una vez que POSTGRES_PASSWORD estuvo definido en el entorno (Compose advierte, pero igual resuelve, cuando una variable interpolada como ${POSTGRES_PASSWORD} no está definida al momento del config; en el droplet viene de .env, configurado en el Paso 6).
Paso 5: escribir el Caddyfile
myapp.example.com {
reverse_proxy web:3000
}
Caddy solicita y renueva automáticamente un certificado de Let’s Encrypt para myapp.example.com la primera vez que arranca, siempre que el DNS del dominio ya apunte a este servidor y los puertos 80 y 443 lo alcancen. Todo se redirige al proceso Express en el puerto 3000, el mismo puerto que el Dockerfile expone y en el que la aplicación escucha.
Validé este Caddyfile con el propio validador de Caddy antes de copiarlo al droplet:
docker run --rm -v /tmp/compose-check/Caddyfile:/etc/caddy/Caddyfile caddy:2 caddy validate --config /etc/caddy/Caddyfile
Imprimió “Valid configuration” sin errores.
Paso 6: configurar tus variables de entorno y secretos
Crea un archivo .env junto a docker-compose.yml, mantenlo fuera del control de versiones, y restringe sus permisos:
POSTGRES_DB=myapp
POSTGRES_USER=myapp
POSTGRES_PASSWORD=una-contrasena-larga-y-aleatoria
DATABASE_URL=postgres://myapp:una-contrasena-larga-y-aleatoria@db:5432/myapp
PORT=3000
Genera la contraseña con openssl rand -base64 32 en lugar de escribir algo memorable, y ajusta los permisos del archivo para que solo root pueda leerlo:
chmod 600 .env
Nota que DATABASE_URL apunta a db, el nombre del servicio en docker-compose.yml, no a localhost. Los contenedores en la misma red de Compose se resuelven entre sí por nombre de servicio; localhost dentro del contenedor web se refiere al propio contenedor web, que no tiene Postgres corriendo en él.
Paso 7: abrir el firewall y desplegar
ufw allow 22
ufw allow 80
ufw allow 443
ufw enable
Luego, desde el directorio con tu docker-compose.yml:
docker compose up -d --build
docker compose logs -f caddy
Deberías ver a Caddy reportar que obtuvo un certificado para tu dominio, y luego docker compose logs -f web debería mostrar listening on 3000 sin errores de conexión. Visita https://myapp.example.com en un navegador: deberías ver {"status":"ok"} desde la ruta raíz, y https://myapp.example.com/widgets debería devolver un arreglo vacío (o las filas que insertaste en el Paso 2) en lugar de un error 500.
Paso 8: respaldos y actualizaciones
Respalda Postgres con un dump programado, escrito fuera del contenedor para que un volumen perdido no se lleve el respaldo con él:
docker compose exec -T db pg_dump -U myapp myapp > backup-$(date +%F).sql
Pon eso en una tarea cron y copia el resultado fuera del droplet (DigitalOcean Spaces, S3, o simplemente scp a otra máquina). Para lanzar una nueva versión, trae tu último código, reconstruye, y vuelve a levantar el contenedor web; Postgres y Caddy no necesitan reiniciarse:
git pull
docker compose up -d --build web
El atajo: hazlo con Railyard
Todo lo anterior (el Dockerfile, el usuario sin privilegios de root, DATABASE_URL, el proxy Caddy y el certificado) es lo que Railyard configura automáticamente cuando lo apuntas al mismo repositorio. Según sus propias reglas de detección: el gestor de paquetes se elige según el archivo de bloqueo presente en el repositorio (package-lock.json, pnpm-lock.yaml, yarn.lock, o bun.lockb), y el script start en package.json corre como el proceso web una vez que el build termina; si el repositorio trae su propio Dockerfile, ese se construye y corre directamente. El contenedor escucha en el $PORT que Railyard asigna, la misma variable de entorno que el server.js de esta guía ya lee. Un script npm run worker, si existe en package.json, se ofrece como proceso worker en segundo plano junto al proceso web. Una dependencia pg en package.json basta para que Railyard aprovisione automáticamente una base de datos Postgres administrada, con DATABASE_URL inyectado como variable de entorno, sin ningún addon que agregar a mano ni Dockerfile requerido para un repositorio que no trae uno. Consulta la documentación de Node.js para las reglas exactas de detección.
Para desplegar de esta forma: crea un droplet o servidor desde Railyard (DigitalOcean es uno de los proveedores soportados), luego pega la URL de Git de tu repositorio, o agrega esto a tu README para un despliegue en un clic:
[](https://app.railyard.run/deploy?repo=https://github.com/you/your-express-app)
El plan gratuito de Railyard cubre un servidor más un servidor de base de datos y tres aplicaciones sin tarjeta requerida. Pro es $19/mes por equipo, y Team es $49/mes por equipo para límites más altos y más colaboradores.
La ruta manual de arriba sigue siendo la que vale la pena conocer. Es lo que Railyard hace por debajo, y entenderla significa que nunca te quedarás atascado si algo necesita un cambio que el panel todavía no expone.
Solución de problemas
“Port is already allocated” al correr docker compose up. Algo más ya está enlazado al puerto 80 o 443, usualmente un nginx por defecto instalado por una imagen de DigitalOcean de un clic. Deténlo y desactívalo: systemctl stop nginx && systemctl disable nginx, luego vuelve a intentarlo.
Conexión a Postgres rechazada, o ECONNREFUSED en los logs de la aplicación. Revisa DATABASE_URL en .env: debe apuntar a db, el nombre del servicio en Compose, no a localhost. localhost dentro del contenedor web se refiere al contenedor web, que no tiene Postgres corriendo en él.
Permiso denegado al escribir un archivo, o el contenedor se cierra de inmediato con un error EACCES. El contenedor corre como el usuario sin privilegios de root node del Paso 3. Si tu aplicación escribe en un directorio que no le pertenece (un archivo de log, una carpeta de subidas incluida en la imagen), crea ese directorio con los permisos correctos en el Dockerfile antes de cambiar a USER node, o escribe en un volumen montado en su lugar.
El HTTPS nunca se activa, o Caddy registra errores de certificado. Revisa que el registro A del dominio realmente resuelva al droplet (dig myapp.example.com) y que los puertos 80 y 443 estén abiertos tanto en el firewall del droplet como en el firewall de nube de DigitalOcean si agregaste uno. Let’s Encrypt valida primero por HTTP simple, así que el puerto 80 tiene que ser alcanzable aunque el sitio final sea solo HTTPS.
La aplicación se cae o el droplet se siente lento bajo tráfico real. En un droplet de 1 GB, Postgres, el proceso de Node y Caddy compiten todos por la misma memoria, y un proceso de Node bajo carga concurrente puede hacer crecer su heap rápidamente. Observa docker stats bajo carga, agrega un archivo swap como red de seguridad, o sube al droplet de 2 GB si el tráfico es real.
Preguntas frecuentes
¿Necesito Docker, o puedo correr esto con un gestor de procesos y una instalación de Node a nivel del sistema? Docker Compose mantiene Postgres, la aplicación y Caddy aislados y fáciles de mover entre servidores, pero pm2 o systemd corriendo node server.js contra un Postgres instalado a nivel del sistema funcionan igual; la llamada app.listen y la configuración de Caddy de arriba no cambian de ninguna de las dos formas.
¿Qué cambió realmente entre Express 4 y 5 que importa aquí? El cambio relevante para esta guía es el reenvío automático de promesas rechazadas desde manejadores de rutas async hacia el middleware de manejo de errores, que es por qué /widgets arriba no tiene try/catch alrededor de su await. Express 5 también eliminó algunas APIs obsoletas desde hace tiempo y elevó la versión mínima de Node.js soportada, pero esta guía no depende de nada más que haya cambiado.
¿Puedo usar un ORM como Prisma o Sequelize en lugar de pg directo? Sí. Cualquiera de los dos lee DATABASE_URL de la misma forma que pg.Pool aquí, y el Dockerfile, el archivo Compose y el Caddyfile no cambian; solo cambian server.js y la dependencia en package.json. Esta guía usa pg directo para mantener el ejemplo pequeño y evitar un paso de build para un archivo de esquema.
¿Cómo agrego un worker en segundo plano? Agrega un servicio worker a docker-compose.yml, construido desde la misma imagen y el mismo archivo .env, con un command que corra algo distinto a server.js, por ejemplo node worker.js. Puede usar el mismo pg.Pool y DATABASE_URL que el proceso web, o agregar Redis como otro contenedor si la cola de tareas lo necesita.
¿De verdad alcanza el droplet de 1 GB? Para una aplicación pequeña sin mucho tráfico concurrente, sí, el nivel de $6/mes funciona bien. Una vez que Postgres, el proceso de Node y Caddy corren todos juntos bajo carga real, el plan de 2 GB a $12/mes da suficiente margen para que un pico de tráfico no se convierta en una caída por falta de memoria.
Una aplicación Express en un droplet no tiene nada de frágil una vez que las piezas están bien conectadas, y la mayor parte de lo que hay aquí no es específico de Express: el mismo contenedor de Postgres, el mismo proxy Caddy y la misma tarea cron de respaldo funcionan también para la siguiente aplicación Node que pongas en este servidor.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.