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:3local en lugar de Docker Hub o GitHub Container Registry (localhost:5000) y un certificado local emitido por Caddy paralocalhosten 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:
-
Conéctate por SSH, crea un usuario normal y desactiva el inicio de sesión con contraseña (usa solo claves).
-
Sigue los pasos para Linux de Instalar Docker e instala Docker Engine y el complemento Compose.
-
Abre únicamente los puertos 22 (SSH), 80 y 443 en el cortafuegos:
sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enableTen en cuenta que los puertos publicados con
-pomiten ufw (consulta Prácticas recomendadas de seguridad), así que en el compose.yaml de abajo solo el proxy inverso tieneports. -
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 errorexec format erroren 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ón | Descripció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 ports | No se puede acceder desde fuera ni a la aplicación ni a la base de datos |
| Caddy | Solicita 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/postgresql | Ruta de montaje de PostgreSQL 18. Consulta PostgreSQL |
pg_isready -h 127.0.0.1 | Evita que la base de datos se considere healthy durante su inicialización |
restart: unless-stopped | Se inicia automáticamente si el programa se bloquea o el host se reinicia |
logging | Limita 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 pulldescarga primero todas las imágenes, incluidamyapp:1.0.0, que hemos subido.up -dinicia los servicios según el orden de sus dependencias: primerodbpasa aHealthy, después se iniciaappy, por último,caddy.- En
ps, solo caddy expone los puertos 80 y 443 al exterior. - Al hacer dos solicitudes HTTPS,
visitspasa de 1 a 2 y los datos se guardan en PostgreSQL. (-kse 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 appdescarga solo la nueva imagen deapp. (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 appdetecta que la imagen cambió y vuelve a crear solo el contenedorapp;dbycaddyno se ven afectados, y el sitio solo deja de estar disponible durante aproximadamente 1 segundo.- El valor de
versionen la respuesta pasa a ser1.1.0;visitssigue aumentando desde 3 y los datos de la base de datos se conservan íntegramente. docker compose images appconfirma que ahora se usa la versión1.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 -deleteelimina 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_VERSIONen el servidor y ejecutadocker 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 psdocker --context prod compose up -dusa el archivocompose.yamldel 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
.envson 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 ejecutarup -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