# Cómo desplegar una app Node.js con Docker y una base de datos, sin administrar un servidor

Un acortador de enlaces con un Dockerfile, subido y en producción con base de datos en 42 segundos, sin aprovisionar un servidor. Medido paso a paso, incluidas las cuatro cosas que salieron mal.

URL: https://www.back4app.com/blog/es/como-desplegar-una-app-nodejs-con-docker-y-base-de-datos
Publicado: 2026-10-30 · Probado: 2026-09-10
Probado en: Containers: Back4app, plan Free, 0,25 vCPU, 256 MB · Backend: Back4app, USA East · Local: Node 25, Express 5
Publisher: Back4app Engineering

Tu app corre en `localhost`. Tiene un Dockerfile. Y cada página de hosting que abres responde a la pregunta "¿dónde corre mi código?", pero no a la que de verdad te quita el sueño: "¿dónde viven mis datos y quién parchea ese servidor?"

Este post trata el caso corriente: una app web en Node.js, Express sirviendo una página y dos rutas, que necesita un sitio donde correr y un sitio donde guardar sus datos. Una **plataforma de containers** ejecuta tu código. Un **backend** te da la base de datos, las reglas y la autenticación que no escribiste. La mayoría de las apps necesitan las dos cosas; pocas plataformas ofrecen las dos.

La misma forma tiene otros tres posts, uno por variante: una [API en Python con FastAPI](https://www.back4app.com/blog/deploy-python-fastapi-dockerfile-database), una [app Next.js renderizada en el servidor](https://www.back4app.com/blog/deploy-nextjs-app-github-backend) y un [worker que nunca debe dormirse](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

Construimos un acortador de enlaces (42 líneas de Express, un Dockerfile de 8 líneas), hicimos push y lo tuvimos guardando datos detrás de HTTPS sin un servidor que administrar. El primer deploy tardó **42 segundos** del clic al `200`. Los redeploys tardaron entre 14 y 33 segundos. Todo lo que sigue sale de esa ejecución, del 10 de septiembre de 2026, incluidas las partes que fallaron.

**Consigue el código funcionando:** el repositorio companion está en [github.com/templates-back4app/link-shortener](https://github.com/templates-back4app/link-shortener) — `server.js`, `Dockerfile`, `cloud/main.js` y `deploy-check.sh`, que verifica un despliegue de punta a punta. Clónalo, dale una estrella o abre un issue cuando algo se desvíe.

## ¿Qué incluye "sin administrar un servidor"?

Dilo en voz alta y suena a una sola cosa. Son cinco. Una plataforma solo puede afirmarlo con honestidad si cubre la lista entera:

| Lo que necesitas | Por qué no es opcional | Dónde está en este post |
|---|---|---|
| Tu código en ejecución | La parte obvia | Container, sección 03 |
| HTTPS en una URL pública | Los navegadores y las tiendas de apps rechazan el HTTP plano | Container, automático |
| Una base de datos que no administras | Los datos sobreviven al container | Backend, sección 04 |
| Validación y autenticación fuera del container | El container es desechable; las reglas no | Backend, sección 05 |
| Redeploy en cada cambio, sin downtime | Si no, vuelves al SSH | Sección 06 |

La mayoría de los hosts de containers se quedan en las dos primeras: ejecutan el Dockerfile y te dan una URL. La base de datos es un servicio aparte, facturado aparte, y la autenticación la escribes tú.

Back4app es la que probamos porque ofrece la segunda mitad como producto: una **app de backend** con base de datos gestionada, autenticación, REST y Cloud Code, con la que una **app de container** habla por la red. Dos apps, una cuenta, y el resto de este post es el recorrido medido por las dos.

## ¿Cómo es el acortador de enlaces, archivo por archivo?

Un acortador de enlaces: la app más pequeña que ejercita las dos mitades. El container sirve una página y dos rutas: `POST /shorten` crea un objeto `Link` en el backend, `GET /:code` lo busca y redirige. El backend es el dueño de los datos y de las reglas.

![Arquitectura: el navegador llama a tu container en b4a.run; el container lee y escribe objetos Link en el backend de Back4app a través del SDK; un hook beforeSave de Cloud Code valida cada Link antes de que llegue a la base de datos gestionada](/blog/blog-assets/deploy-node-app-dockerfile-back4app/architecture-diagram.svg)

Cuatro archivos importan. El Dockerfile completo:

```dockerfile
# Stack: Docker | Node.js 22 (alpine) | File: Dockerfile
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "server.js"]
```

El container lee su configuración del entorno; nada más va horneado en la imagen:

```javascript
// Stack: Node.js 22.x | Express 5.x + Parse JS SDK 8.x | File: server.js (excerpt)
const { PORT = 8080, PARSE_APP_ID, PARSE_JS_KEY,
        PARSE_SERVER_URL = "https://parseapi.back4app.com" } = process.env;

Parse.initialize(PARSE_APP_ID, PARSE_JS_KEY);
Parse.serverURL = PARSE_SERVER_URL;

app.post("/shorten", async (req, res) => {
  const link = new Parse.Object("Link");
  link.set("url", String(req.body.url ?? ""));
  const saved = await link.save(); // the backend validates and assigns the code
  const short = `https://${req.get("host")}/${saved.get("code")}`;
  res.status(201).json({ code: saved.get("code"), short, url: saved.get("url") });
});
```

Necesitas una cuenta gratuita de Back4app, una cuenta de GitHub (el container se despliega desde un repositorio) y `curl`. Sin Docker en tu máquina: todo este post se produjo en un Mac sin Docker instalado, porque la imagen la construye la plataforma.

## ¿Cómo se despliega un Dockerfile de Node.js desde GitHub?

Antes tienen que existir dos cosas: el backend con el que hablará el container y un repositorio de GitHub que la plataforma pueda leer. Hicimos primero el backend para tener sus claves listas.

**Backend.** Dashboard → **New App → Build your Backend**, ponle nombre, **Create**. Ochenta segundos después, la página *Overview* muestra el App ID y, en el desplegable *Keys*, la **JavaScript key**: los dos valores que necesita el container.

![La página Overview del backend link-shortener: el App ID y el desplegable Keys a la izquierda, Parse Server 7.5.2 y MongoDB 3.6 a la derecha](/blog/blog-assets/deploy-node-app-dockerfile-back4app/03-backend-keys-v2.jpg)

**Repositorio.** Sube los cuatro archivos a GitHub. Después, en el lado de Containers, **New App → Deploy from GitHub → Import GitHub Repo**. Esto abre la página de instalación en GitHub de la app *Back4App Containers*: elige **Only select repositories**, selecciona el repo, **Install & Authorize**. Dieciséis segundos después de ese clic, el repo aparecía listado en la dashboard.

![La pantalla Deploy a web app listando el repositorio link-shortener con un botón Select, después de instalar la GitHub App](/blog/blog-assets/deploy-node-app-dockerfile-back4app/02-repo-picker.jpg)

**Configurar y desplegar.** Selecciona el repo y el formulario pide un nombre, la rama y un método de build: detectó el Dockerfile. Dejamos el puerto vacío (la plataforma lee `EXPOSE`), pusimos el health check en `/healthz` y añadimos dos variables de entorno: `PARSE_APP_ID` y `PARSE_JS_KEY`. Después, **Deploy**.

Hicimos clic a las 11:12:45. El log fue `PREPARING → FETCHING GITHUB REPOSITORY → BUILDING IMAGE → Pushed image → LAUNCHING CONTAINER → CHECKING HEALTH → DEPLOYMENT READY`, y `/healthz` respondió `200` a las 11:13:27.

![El Overview del container tras el primer deploy: estado Available, build Ready en 29 s, el log de despliegue terminando en DEPLOYMENT READY y el panel de configuración mostrando Dockerfile, puerto auto, 2 variables y health check /healthz](/blog/blog-assets/deploy-node-app-dockerfile-back4app/04-container-ready.jpg)

Cómo lo medimos: un cronómetro desde el clic en Deploy hasta el primer `200` de la URL pública, en el plan Free (0,25 vCPU, 256 MB), un deploy el 10 de septiembre de 2026. Los tiempos de redeploy son cuatro pushes separados el mismo día.

Tu tiempo de build se mueve con el tamaño de la imagen y la velocidad del registry; los números de aquí son una referencia, no una promesa.

La URL es `https://linkshortener-<id>.b4a.run`: HTTPS incluido, sin certificado que solicitar. El container gratuito recibe 0,25 vCPU, 256 MB y 100 GB de transferencia.

![La página del acortador de enlaces desplegado, servida desde linkshortener-awjh19o8.b4a.run: un campo de URL y un botón Shorten](/blog/blog-assets/deploy-node-app-dockerfile-back4app/06-live-app.jpg)

## ¿Cómo guardas datos sin una base de datos?

Ya lo hiciste, en el código de arriba: `link.save()` envía el objeto al backend por HTTPS, con el App ID y la clave del entorno. No hay connection string, ni pool, ni proceso de base de datos dentro del container.

Aquí está la misma escritura de dos formas. El container usa el SDK; la forma REST es lo que el SDK envía, y funciona desde cualquier sitio, incluida tu terminal, con tus propias claves:

El primer save desde el container creó la clase `Link` por sí solo: no se definió ningún schema de antemano (Parse Server 7.5.2 en Back4app, septiembre de 2026). Segundos después los objetos eran visibles en el *Database Browser* de la dashboard, con las columnas `url`, `code` y `clicks` inferidas de lo que escribió el container.

![El Database Browser mostrando la clase Link con tres objetos y sus columnas url, clicks y code](/blog/blog-assets/deploy-node-app-dockerfile-back4app/05-database-link.jpg)

Latencia, medida desde un portátil en UTC−3 con 10 muestras cada una: una escritura que pasa **por el container** hasta el backend tardó una mediana de 205 ms de punta a punta; la misma escritura enviada **directamente** al backend tardó 440 ms. El container y el backend están en la misma región (USA East), así que el salto entre ellos es la parte barata del viaje.

## ¿Dónde viven la validación y la generación del código corto?

Todo lo que tiene que sobrevivir a que el container sea reemplazado: las reglas sobre tus datos, las claves y los usuarios. En nuestra primera ejecución el backend aceptó un `Link` cuya `url` era la cadena `not a url`, y no devolvió ningún código corto, porque nadie le había dicho qué es un Link válido. Ese es el momento de añadir el hook.

`cloud/main.js` corre *dentro del backend*, no en el container. Rechaza las URLs malas, pone `clicks` a 0 y asigna un código que ningún otro Link use:

```javascript
// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
const ALPHABET = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");

Parse.Cloud.beforeSave("Link", async (request) => {
  const link = request.object;
  if (!link.isNew()) return; // updates (click counts) pass through untouched

  let url;
  try { url = new URL(link.get("url")); } catch { url = null; }
  if (!url || !["http:", "https:"].includes(url.protocol)) {
    throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "url must be an http(s) URL.");
  }
  link.set("url", url.toString());
  link.set("clicks", 0);

  // assign a short code that no other Link uses
  for (let attempt = 0; attempt < 5; attempt++) {
    const candidate = code();
    const taken = await new Parse.Query("Link").equalTo("code", candidate).first({ useMasterKey: true });
    if (!taken) { link.set("code", candidate); return; }
  }
  throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, "could not allocate a short code.");
});
```

Pégalo en **Cloud Code → cloud/main.js** en la dashboard del backend y **Deploy**. Nuestro hook respondía 21 segundos después del clic. Desde entonces el `POST /shorten` del container devolvió un `code`, y `not a url` volvió como `400 {"code":142,"error":"url must be an http(s) URL."}`; el código del container no cambió en absoluto.

![El editor de Cloud Code en la dashboard del backend con cloud/main.js abierto, mostrando el hook beforeSave](/blog/blog-assets/deploy-node-app-dockerfile-back4app/07-cloud-code.jpg)

La misma regla vale para las claves y los usuarios. El container tiene la clave de *JavaScript*, que es una clave de cliente: puede hacer exactamente lo que podría hacer un navegador. La master key nunca sale del backend, y la clase `User` de Parse, las sesiones y las ACL están ahí para cuando el acortador tenga propietarios.

## ¿Qué pasa cuando vuelves a desplegar?

El auto deploy está desactivado en los containers gratuitos (el interruptor aparece deshabilitado), así que un `git push` solo no cambia nada en la URL en vivo. Hicimos push de un cambio de una línea a las 11:26 y el sitio siguió sirviendo la versión antigua hasta que desplegamos a mano.

**Deploy latest commit** no despliega de inmediato. Primero abre una revisión *Finish your setup*: la plataforma había leído el repo como "Docker / Node.js / Express · Node.js 22 Alpine" y sugería `node server.js` y el puerto 8080. Nada es obligatorio; **Deploy without changes** inicia el build.

![El diálogo Finish your setup: la plataforma leyó el repo como Docker / Node.js / Express en Node.js 22 Alpine, sugiere el comando de inicio node server.js y el puerto 8080, con los botones Deploy without changes y Save and deploy](/blog/blog-assets/deploy-node-app-dockerfile-back4app/08-finish-setup.jpg)

Medimos cuatro redeploys. Un build con caché y un cambio de una línea pasó del clic a la versión nueva respondiendo en **14 s**; los otros tardaron 20, 32 y 20 s. Durante cada uno de ellos, una solicitud cada cinco segundos nunca vio otra cosa que `200`: la lista de deployments muestra el container antiguo como *Destroying* solo después de que el nuevo está *Ready*.

![La lista de Deployments tras varios redeploys: el deployment más reciente Ready en 20 s, el anterior Destroying, los más antiguos Destroyed](/blog/blog-assets/deploy-node-app-dockerfile-back4app/09-deployments.jpg)

Las variables de entorno reciben el mismo trato: cambia una en **Settings → Environment** y la dashboard dice "Settings saved — deploy now with the settings you just saved?". Una rotación de claves es un redeploy, no una sesión de SSH.

![La página de configuración de variables de entorno listando PARSE_APP_ID y PARSE_JS_KEY, con la nota de que un cambio se aplica en el siguiente deploy](/blog/blog-assets/deploy-node-app-dockerfile-back4app/10-env-settings.jpg)

## ¿Qué errores dio el deploy de Node.js y qué los arregló?

Rompimos el despliegue a propósito tres veces. Dos de las tres no se comportaron como suelen prometer las guías de errores.

**`App is not listening on port 8080, but on port 3000 — switching to it (auto-detected)`.** Pusimos `PORT=3000` para que la app ignorara la línea `EXPOSE 8080`. Esperado: un health check fallido. Real: la plataforma sondeó, encontró el 3000, registró la línea de arriba, guardó el 3000 en la configuración de la app e informó `DEPLOYMENT READY` en 20 s. Quitar la variable lo devolvió al 8080 de la misma manera. Aquí, un puerto que no coincide no es un fallo.

![El log de despliegue con las líneas App is not listening on port 8080, but on port 3000 — switching to it (auto-detected) y Port 3000 saved to the app settings for future deploys, seguidas de DEPLOYMENT READY](/blog/blog-assets/deploy-node-app-dockerfile-back4app/11-port-autodetect.jpg)

**`{"error":"unauthorized"}` con HTTP 502 en `/shorten`, pero el deploy decía Ready.** Desplegamos con una `PARSE_JS_KEY` incorrecta. El health check llama a `/healthz`, que nunca toca el backend, así que el container roto pasó y se puso en vivo; el fallo solo apareció en la primera solicitud real. Corrige la clave en Settings → Environment y despliega. Mejor aún: haz que tu health check ejercite el backend.

**Enlaces cortos `http://…` aunque el sitio es HTTPS.** La plataforma termina el TLS y, a septiembre de 2026, no reenvía el esquema, así que el `req.protocol` de Express es `http` incluso con `trust proxy` activado. Construye las URLs públicas con `https://` de forma explícita, como ahora hace `server.js`.

**`Runtime Logs` no muestra nada después de un fallo.** Runtime Logs muestra el stdout y el stderr de tu container, etiquetados con el id del container: las líneas de `console.log` como SYSTEM, las de `console.error` como ERROR. Nuestra primera versión se tragó el error `unauthorized` y no dejó rastro. Registra tus fallos; entonces la página de logs es el primer sitio donde mirar.

![Runtime Logs mostrando link-shortener escuchando en el 8080 y una línea ERROR shorten failed: 142 url must be an http(s) URL., cada una etiquetada con el id del container](/blog/blog-assets/deploy-node-app-dockerfile-back4app/12-runtime-logs.jpg)

**`Website blocked — known phishing site` al abrir tu URL de `b4a.run`.** Nuestra URL recién creada fue bloqueada por Threat Protection de NordVPN en Chrome, mientras `curl` llegaba sin problema. El dominio compartido carga con la reputación de todas las apps que viven en él. Verifica primero con `curl -I` y ponle un dominio propio a cualquier cosa que muestres a usuarios.

**`Nothing here, yet!` después de instalar la GitHub App.** El callback de autorización aterrizó en un navegador que no tenía la sesión iniciada en la dashboard. Desinstala la app en GitHub (Settings → Applications) y repite *Import GitHub Repo* desde la dashboard en el mismo navegador.

**`Success on deploying your changes!` pero Cloud Code no hace nada.** Revisa Logs → System buscando `main.js not found`; haz la edición en el propio editor para que aparezca "Files pending deploy" y despliega otra vez.

## ¿Cuándo es un container la respuesta equivocada?

Según nuestro propio manifiesto, las comparaciones en las que solo gana la plataforma no se publican, así que aquí está la otra columna.

- **Tu app es CRUD y nada más.** Entonces no necesitas ningún container: la capa REST del backend ya es la API. Nuestro [post sobre la REST API](https://www.back4app.com/blog/build-rest-api-nodejs-back4app) construye una en 24 líneas y cero containers.
- **Necesitas un motor de base de datos o una extensión concretos.** El backend es Parse Server sobre MongoDB (Postgres en los planes de pago). Si tu app es PostGIS o ClickHouse, la mitad de datos de este post no aplica.
- **Necesitas que la URL dure más de una hora, auto deploy o más de 0,25 vCPU.** Las tres son funciones de pago aquí a 15 de septiembre de 2026 (los planes Shared empiezan en US$ 5 al mes); consulta la página de precios, y no este post, para los números actuales.
- **Quieres construir las imágenes en local y subirlas.** La vía de imagen acepta solo Docker Hub: nuestra imagen en GitHub Container Registry fue rechazada en el formulario.

Si ninguno de esos casos es el tuyo, la forma del diagrama (un container desechable delante de un backend gestionado) es la que elegiríamos otra vez.

## ¿Hacia dónde llevas el acortador de enlaces desde aquí?

Tienes un container que se vuelve a desplegar en menos de medio minuto, una base de datos que nunca aprovisionaste y una validación que sobrevive a que el container sea reemplazado. Desde aquí:

- **Dales propietarios a los enlaces.** La clase `User` de Parse añade endpoints de creación de cuenta e inicio de sesión sin código en el container ([medido, con restablecimiento de contraseña](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); un `beforeSave` puede estampar el usuario creador en cada Link y una ACL que solo él pueda editar ([cómo se comportan los cerrojos](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to)).
- **Ponle un dominio.** Settings → Domains en el container: la solución al problema de reputación de arriba.
- **Haz honesto el health check.** Haz que `/healthz` ejecute una query barata contra el backend, para que una clave mala haga fallar el deploy y no al primer usuario.

Todo el código de arriba está completo y probado: el [repositorio companion](https://github.com/templates-back4app/link-shortener) incluye `deploy-check.sh`, que ejecuta health → shorten → redirect contra cualquier URL y sale con código distinto de cero si algo falla.
