Contenido

Tutorial de despliegue con Docker en un servidor en la nube (VPS): publica una aplicación web

En esta sección reuniremos lo que has aprendido hasta ahora: desplegaremos en un servidor Linux en la nube (VPS) un sitio web administrado con Docker Compose.

El resultado de esta sección simula el proceso completo en una misma máquina: usamos un registry:3 local en lugar de Docker Hub o GitHub Container Registry (localhost:5000) y un certificado local emitido por Caddy para localhost en lugar del certificado de un dominio real. Para un despliegue real, solo tienes que sustituir el nombre de la imagen y el dominio por los tuyos.

Flujo general

Equipo local o CI ──docker build / push──▶ Registry ──docker compose pull──▶ Servidor en la nube

El código se compila como imagen en tu equipo o en CI y se publica en un Registry. El servidor solo descarga y ejecuta la imagen: no compila en el servidor. Así, el servidor no necesita el código fuente ni herramientas de compilación, y el resultado es idéntico en cada despliegue. Para volver a una versión anterior, basta con cambiar al tag anterior.

1. Preparar el servidor

Puedes usar un servidor Linux de cualquier proveedor de servicios en la nube. Estos pasos usan Ubuntu como ejemplo:

  1. Conéctate por SSH, crea un usuario normal y desactiva el inicio de sesión con contraseña (usa solo claves).

  2. Sigue los pasos para Linux de Instalar Docker e instala Docker Engine y el complemento Compose.

  3. Abre únicamente los puertos 22 (SSH), 80 y 443 en el cortafuegos:

    sudo ufw allow OpenSSH
    sudo ufw allow 80,443/tcp
    sudo ufw enable

    Ten en cuenta que los puertos publicados con -p omiten ufw (consulta Prácticas recomendadas de seguridad), así que en el compose.yaml de abajo solo el proxy inverso tiene ports.

  4. Configura Docker para que se inicie automáticamente al arrancar: sudo systemctl enable docker.

2. Aplicación

El ejemplo es un sitio pequeño con Node.js + PostgreSQL. Registra una visita con cada solicitud y devuelve la versión actual y el número de visitas.

myapp/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore

server.js:

const express = require("express");
const { Pool } = require("pg");

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const version = process.env.APP_VERSION || "dev";

app.get("/", async (req, res) => {
  await pool.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())");
  await pool.query("INSERT INTO visits DEFAULT VALUES");
  const { rows } = await pool.query("SELECT count(*)::int AS visits FROM visits");
  res.json({ app: "myapp", version, visits: rows[0].visits });
});

const server = app.listen(3000, () => console.log(`myapp ${version} listening on port 3000`));
process.on("SIGTERM", () => server.close(() => pool.end().then(() => process.exit(0))));

Las dependencias de package.json son express y pg. En el Dockerfile (consulta Aplicación Node.js para más detalles sobre la sintaxis), al final pasamos el número de versión mediante ARG y lo guardamos en ENV, para que la aplicación pueda mostrar su propia versión:

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "server.js"]

3. Construir y publicar la imagen

En tu equipo o en CI, usa una etiqueta con un número de versión fijo:

$ docker build --build-arg APP_VERSION=1.0.0 -t localhost:5000/myapp:1.0.0 .

[+] Building 6.7s (10/10) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 308B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 63B                                                                                                                 0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          2.4s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5                                                        0.1s
 => => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0                                                        2.1s
 => => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1                                                        0.1s
 => => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444                                                        0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 36.48kB                                                                                                             0.0s
 => [2/5] WORKDIR /app                                                                                                                           0.1s
 => [3/5] COPY package.json package-lock.json ./                                                                                                 0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                                       3.5s
 => [5/5] COPY --chown=node:node . .                                                                                                             0.1s
 => exporting to image                                                                                                                           0.6s
 => => exporting layers                                                                                                                          0.3s
 => => exporting manifest sha256:a0bd86a3b62021adb05a5e710c5a59f9e6287c8c84016ba22f1923bc4aaeeabf                                                0.0s
 => => exporting config sha256:7f75124e75541f256217e31a844e8c9b687434955d053e47e5dae571aa3944c6                                                  0.0s
 => => exporting attestation manifest sha256:9d6688e6e287f9a0b3d3a95d23765cde8496c89bf6dd0c7fd7a191ac58abe9db                                    0.0s
 => => exporting manifest list sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522                                           0.0s
 => => naming to localhost:5000/myapp:1.0.0                                                                                                      0.0s
 => => unpacking to localhost:5000/myapp:1.0.0                                                                                                   0.3s

$ docker push localhost:5000/myapp:1.0.0
The push refers to repository [localhost:5000/myapp]
44136fa355b3: Pushed
2297ea3ee753: Pushed
7ce7dcbafb7f: Pushed
fffb74b09a22: Pushed
e2de96513ba9: Pushed
1d73dbdb2af1: Pushed
bd9735416a50: Pushed
02f40d53b06f: Pushed
22ac32835411: Pushed
3c504d5a797b: Pushed
1.0.0: digest: sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 size: 856

En la práctica, el nombre de la imagen combina la dirección del Registry con tu cuenta, por ejemplo, yourname/myapp:1.0.0 en Docker Hub o ghcr.io/yourname/myapp:1.0.0 en GitHub. Primero ejecuta docker login (consulta Publicar en Docker Hub).

Si compilas en un Mac con Apple Silicon y despliegas en un host amd64 convencional, recuerda añadir --platform linux/amd64; de lo contrario, aparecerá el error exec format error en el servidor. Consulta Imágenes multiplataforma.

4. Archivos en el servidor

Crea una carpeta en el servidor, por ejemplo, /srv/myapp. Solo necesitas tres archivos; no necesitas el código fuente:

/srv/myapp/
├── compose.yaml
├── .env
└── Caddyfile

compose.yaml:

services:
  caddy:
    image: caddy:2
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - app
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

  app:
    image: localhost:5000/myapp:${APP_VERSION}
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

  db:
    image: postgres:18
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
      interval: 5s
      retries: 10
    restart: unless-stopped
    logging:
      options: { max-size: "10m", max-file: "3" }

volumes:
  caddy_data:
  caddy_config:
  pgdata:

.env:

APP_VERSION=1.0.0
DB_PASSWORD=change-this-to-a-long-random-password

Caddyfile (en un despliegue real, sustituye localhost por tu dominio, por ejemplo, example.com):

localhost {
    reverse_proxy app:3000
}

Puntos clave:

ConfiguraciónDescripción
image: ...:${APP_VERSION}.env controla el número de versión; para actualizar o revertir, basta con cambiar esta línea
Solo caddy tiene portsNo se puede acceder desde fuera ni a la aplicación ni a la base de datos
CaddySolicita y renueva automáticamente los certificados HTTPS, y redirige HTTP a HTTPS. Consulta la última sección de Nginx y el proxy inverso
pgdata:/var/lib/postgresqlRuta de montaje de PostgreSQL 18. Consulta PostgreSQL
pg_isready -h 127.0.0.1Evita que la base de datos se considere healthy durante su inicialización
restart: unless-stoppedSe inicia automáticamente si el programa se bloquea o el host se reinicia
loggingLimita el tamaño de los registros para evitar que llenen el disco. Consulta Liberar espacio en disco

El archivo .env contiene una contraseña. Configura sus permisos: chmod 600 .env.

5. Iniciar

$ ls -a
.
..
.env
Caddyfile
compose.yaml

$ docker compose pull
[+] pull 35/35
 ✔ Image caddy:2                    Pulled                                                                                                        2.8s
 ✔ Image localhost:5000/myapp:1.0.0 Pulled                                                                                                        0.3s
 ✔ Image postgres:18                Pulled                                                                                                       12.2s

$ docker compose up -d
[+] up 7/7
 ✔ Network srv-myapp_default     Created                                                                                                          0.0s
 ✔ Volume srv-myapp_caddy_config Created                                                                                                          0.0s
 ✔ Volume srv-myapp_caddy_data   Created                                                                                                          0.0s
 ✔ Volume srv-myapp_pgdata       Created                                                                                                          0.0s
 ✔ Container srv-myapp-db-1      Healthy                                                                                                          6.0s
 ✔ Container srv-myapp-app-1     Started                                                                                                          6.2s
 ✔ Container srv-myapp-caddy-1   Started                                                                                                          6.7s

$ docker compose ps
NAME                IMAGE                        COMMAND                  SERVICE   CREATED          STATUS                   PORTS
srv-myapp-app-1     localhost:5000/myapp:1.0.0   "docker-entrypoint.s…"   app       10 seconds ago   Up 4 seconds             3000/tcp
srv-myapp-caddy-1   caddy:2                      "caddy run --config …"   caddy     10 seconds ago   Up 3 seconds             0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
srv-myapp-db-1      postgres:18                  "docker-entrypoint.s…"   db        10 seconds ago   Up 9 seconds (healthy)   5432/tcp

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":1}

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":2}

$ docker compose logs app
app-1  | myapp 1.0.0 listening on port 3000
  • docker compose pull descarga primero todas las imágenes, incluida myapp:1.0.0, que hemos subido.
  • up -d inicia los servicios según el orden de sus dependencias: primero db pasa a Healthy, después se inicia app y, por último, caddy.
  • En ps, solo caddy expone los puertos 80 y 443 al exterior.
  • Al hacer dos solicitudes HTTPS, visits pasa de 1 a 2 y los datos se guardan en PostgreSQL. (-k se debe a que aquí usamos un certificado local; en un dominio de producción se usa un certificado de Let's Encrypt y no hace falta -k.)

En un despliegue de producción, abre https://tu-dominio para comprobar que el sitio funciona y usa docker compose logs -f app para seguir los registros.

6. Actualizar la versión

Crea y publica la nueva versión en tu máquina local:

$ docker build -q --build-arg APP_VERSION=1.1.0 -t localhost:5000/myapp:1.1.0 .
sha256:aac108fcd63549926e767b781e0b9aa25a86e2f2cd449710a41b5e8d0accda16

$ docker push -q localhost:5000/myapp:1.1.0
localhost:5000/myapp:1.1.0

En el servidor, cambia APP_VERSION en .env y luego descarga y vuelve a crear solo app:

$ grep APP_VERSION .env
APP_VERSION=1.1.0

$ docker compose pull app
[+] pull 1/1
 ✔ Image localhost:5000/myapp:1.1.0 Pulled                                                                                                        0.1s

$ docker compose up -d app
[+] up 2/2
 ✔ Container srv-myapp-db-1  Healthy                                                                                                              0.7s
 ✔ Container srv-myapp-app-1 Started                                                                                                              1.0s

$ curl -sk https://localhost/
{"app":"myapp","version":"1.1.0","visits":3}

$ docker compose images app
CONTAINER           REPOSITORY             TAG                 PLATFORM            IMAGE ID            SIZE                CREATED
srv-myapp-app-1     localhost:5000/myapp   1.1.0               linux/amd64         aac108fcd635        251MB               44 seconds ago
  • pull app descarga solo la nueva imagen de app. (Aquí, como la simulación se hace en la misma máquina, la imagen ya está localmente y solo tardó 0.1 segundos.)
  • up -d app detecta que la imagen cambió y vuelve a crear solo el contenedor app; db y caddy no se ven afectados, y el sitio solo deja de estar disponible durante aproximadamente 1 segundo.
  • El valor de version en la respuesta pasa a ser 1.1.0; visits sigue aumentando desde 3 y los datos de la base de datos se conservan íntegramente.
  • docker compose images app confirma que ahora se usa la versión 1.1.0.

Revertir la versión

Si la nueva versión tiene problemas, cambia APP_VERSION de nuevo a 1.0.0 y ejecuta up -d app:

$ grep APP_VERSION .env
APP_VERSION=1.0.0

$ docker compose up -d app
[+] up 2/2
 ✔ Container srv-myapp-db-1  Healthy                                                                                                              0.7s
 ✔ Container srv-myapp-app-1 Started                                                                                                              0.9s

$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":4}

Con un solo comando vuelves a la versión anterior, y la imagen anterior sigue en el servidor, así que no hace falta descargarla de nuevo. Por eso debes usar etiquetas de versión fijas en lugar de latest: con latest, no puedes saber con certeza qué versión se está ejecutando en el servidor ni volver explícitamente a la anterior.

Los cambios en la estructura de la base de datos (migraciones) normalmente no se pueden revertir automáticamente. Antes de publicar una nueva versión, haz una copia de seguridad de la base de datos y procura que los cambios en la base de datos de la nueva versión sean compatibles con la versión anterior del programa.

7. Copias de seguridad

Como mínimo, haz una copia de seguridad diaria de la base de datos y guarda el archivo en otra máquina. Crea backup.sh en /srv/myapp:

#!/bin/sh
set -e
cd "$(dirname "$0")"
mkdir -p backup
docker compose exec -T db pg_dump -U app app | gzip > backup/app-$(date +%F).sql.gz
find backup -name 'app-*.sql.gz' -mtime +14 -delete
$ ./backup.sh && ls -lh backup
total 4K
-rw-r--r--    1 root     root         872 Oct 13 01:17 app-2026-10-13.sql.gz

$ gunzip -c backup/app-*.sql.gz | grep -A 6 "^COPY public.visits"
COPY public.visits (id, at) FROM stdin;
1	2026-10-13 01:16:44.277875+00
2	2026-10-13 01:16:44.390504+00
3	2026-10-13 01:16:57.763022+00
4	2026-10-13 01:17:02.44854+00
\.
  • El archivo de copia de seguridad lleva la fecha en el nombre; find ... -mtime +14 -delete elimina las copias de seguridad de hace más de 14 días.
  • Descomprime el archivo para comprobar su contenido: están los 4 registros de la tabla visits.

Configura los permisos con chmod +x backup.sh y usa crontab -e para ejecutarlo cada día a las 3 de la madrugada:

0 3 * * * /srv/myapp/backup.sh

Si las copias se quedan en la misma máquina, también desaparecerán si esta falla. Por eso, usa herramientas como rsync o rclone para copiar backup/ a otra máquina o al almacenamiento en la nube. Consulta el método de restauración en Copias de seguridad y restauración de PostgreSQL y prueba periódicamente la restauración en la práctica.

8. Automatización (opcional)

Actualizar mediante SSH manualmente pronto se vuelve tedioso. Algunas formas habituales de automatizarlo:

  • CI/CD: compila y publica la imagen en un sistema de CI como GitHub Actions; luego, mediante SSH, cambia APP_VERSION en el servidor y ejecuta docker compose pull app && docker compose up -d app.

  • Docker context: opera directamente desde tu máquina local el Docker del servidor remoto, sin iniciar sesión primero en el servidor:

    docker context create prod --docker "host=ssh://user@your-server"
    docker --context prod ps

    docker --context prod compose up -d usa el archivo compose.yaml del directorio local y lo ejecuta en el servidor remoto.

Lista de comprobación antes de publicar

  • SSH solo permite el inicio de sesión con clave; el firewall solo abre los puertos 22, 80 y 443
  • Solo el proxy inverso tiene ports; la base de datos no está expuesta al exterior
  • Usa una etiqueta de imagen con versión fija, no latest
  • Todos los servicios tienen restart: unless-stopped
  • Se ha configurado un límite para el tamaño de los registros
  • La comprobación de estado está escrita correctamente (pg_isready -h 127.0.0.1)
  • Los permisos de .env son 600 y el archivo no se ha enviado a Git
  • La base de datos se guarda automáticamente cada día en otro lugar y se ha probado la restauración en la práctica
  • Limpia periódicamente las imágenes antiguas (consulta Liberar espacio en disco) y vuelve a compilar periódicamente para obtener actualizaciones de seguridad (consulta Buenas prácticas de seguridad)

Resumen

  • Compila la imagen localmente o en CI, publícala en un Registry y deja que el servidor solo la descargue y la ejecute.
  • Guarda el número de versión en .env; para actualizar o revertir, basta con cambiar una línea y ejecutar up -d.
  • Expón solo el proxy inverso al exterior y escribe correctamente la comprobación de estado de la base de datos.
  • Haz copias de seguridad automáticas cada día en otra máquina y prueba periódicamente la restauración.

Ejercicios

Ejercicio 1: después de revertir APP_VERSION de 1.1.0 a 1.0.0 en el servidor, ¿siguen estando los datos que escribió la nueva versión en la base de datos?

Respuesta

Siguen estando. La reversión solo cambió la imagen de app; el Volume de la base de datos no cambió. En las pruebas de este capítulo, visits siguió aumentando. Sin embargo, si la nueva versión modificó la estructura de las tablas, es posible que el programa anterior no pueda usarla, así que haz una copia de seguridad antes de publicar la nueva versión.

Preguntas frecuentes

¿Es obligatorio usar un Registry? ¿No se puede ejecutar docker build en el servidor?

Se puede, pero el servidor necesitará el código fuente y las herramientas de compilación, y cada despliegue podría producir un resultado distinto. Al desplegar mediante un Registry, el servidor solo necesita descargar una imagen ya probada, y volver a una versión anterior es sencillo.

¿Qué tipo de servidor en la nube debería usar?

Cualquier servidor Linux que permita instalar Docker Engine sirve. Para un sitio pequeño, normalmente bastan 1~2 GB de memoria; puedes ampliar esa cantidad si la base de datos crece.

¿Se puede automatizar el despliegue?

Sí. Puedes usar GitHub Actions para compilar y publicar la imagen y conectarte por SSH al servidor para actualizarlo cuando se cree un tag de versión. Consulta [Compilación automática con GitHub Actions](/es/docker/ci-cd/).

Actualizado 14 de octubre de 2026