Saltar al contenido
Infrastructure 2024-12-29 15 min de lectura

Cómo Migrar un Proyecto Docker de Local a un VPS

Tener un proyecto funcionando con Docker en tu máquina es solo la mitad del camino: el reto llega al migrar el proyecto Docker de local a un VPS en producción. Aunque Docker existe precisamente para que «funcione igual en todos lados», la migración tiene decisiones que no se resuelven solas: cómo llevar la imagen al servidor, cómo mover los datos de la base sin perderlos, cómo evitar que el certificado SSL choque con los puertos de los contenedores y qué cosas de tu docker-compose.yml de desarrollo hay que cambiar antes de exponerlo a internet. Esta guía recorre el proceso completo con Docker Compose V2, la única versión con soporte hoy.

Cómo Migrar un Proyecto Docker de Local a un VPS
#Infrastructure#Docker#VPS#DevOps
T
Equipo Terranode
Editorial

Qué necesitas antes de empezar

  • Un VPS con Ubuntu 24.04 o Debian 13 y al menos 2 GB de RAM.
  • Acceso SSH al servidor con permisos de sudo.
  • Docker y el plugin Compose V2 en tu máquina local, con el proyecto probado y funcionando.
  • Un dominio con registro A apuntando a la IP del VPS, si vas a usar HTTPS.
  • Si el proyecto tiene base de datos, un plan explícito para migrar los datos: la imagen de la aplicación no los lleva dentro.

Un apunte de terminología antes de empezar: una imagen es la plantilla de solo lectura con tu aplicación y sus dependencias; un contenedor es una instancia en ejecución de esa imagen; y un volumen es el almacenamiento persistente que sobrevive a la destrucción del contenedor. Confundir contenedor con volumen es el origen de la mayoría de pérdidas de datos en despliegues Docker.

Paso 1: Elegir cómo llevar la imagen al servidor

Hay tres formas de llevar una imagen Docker a producción, y la del archivo .tar es la menos práctica de las tres para un flujo repetido. Elegir bien desde el principio determina cuánto tarda cada despliegue posterior.

MétodoCómo funcionaCuándo convieneInconveniente
Registro (Docker Hub, GHCR)docker push local, docker pull en el VPSDespliegues frecuentes, equiposRequiere cuenta; repos privados pueden costar
Reconstruir en el VPSgit clone y docker compose buildProyectos con Dockerfile ligeroConsume CPU y RAM del servidor
Archivo .tardocker save y scpServidor sin internet o registro vetadoTransferencias grandes y lentas cada vez

Si vas a desplegar más de dos veces, el registro es la opción que ahorra más tiempo a la larga. Esta guía usa el archivo .tar porque es el escenario que más dudas genera, e indica en cada paso la alternativa equivalente.

Paso 2: Exportar la imagen desde tu entorno local

docker save empaqueta una imagen completa, con todas sus capas, en un único archivo que puedes copiar al servidor. Identifica primero la imagen exacta, incluyendo su etiqueta.

bash
docker images
docker save -o mi_proyecto.tar mi_imagen:1.0.0

Usa etiquetas con número de versión (1.0.0) en lugar de latest. Con latest no hay forma de saber qué versión está corriendo en el servidor ni de volver a la anterior si el despliegue sale mal.

Para reducir el tamaño de la transferencia, comprime el archivo:

bash
docker save mi_imagen:1.0.0 | gzip > mi_proyecto.tar.gz

La alternativa con registro, más limpia si la tienes disponible:

bash
docker tag mi_imagen:1.0.0 tu_usuario/mi_proyecto:1.0.0
docker push tu_usuario/mi_proyecto:1.0.0

Paso 3: Exportar la base de datos

La imagen de la aplicación no contiene los datos: viven en un volumen y hay que exportarlos aparte con un volcado SQL. Este es el paso que más se olvida y el que provoca despliegues «exitosos» con la base vacía.

Genera el volcado directamente desde el host, sin entrar al contenedor:

bash
docker exec nombre_contenedor_db \
  mysqldump -u root -p'tu_password' --single-transaction --routines \
  nombre_base_datos > backup.sql

Las opciones importan: --single-transaction toma una instantánea consistente sin bloquear las tablas InnoDB, y --routines incluye los procedimientos y funciones almacenados, que un volcado normal deja fuera.

Verifica que el archivo tiene contenido real antes de confiar en él:

bash
ls -lh backup.sql
tail -5 backup.sql

Un volcado correcto termina con la línea -- Dump completed. Si no está, el volcado se cortó a medias y restaurarlo dejaría la base incompleta.

Paso 4: Preparar el VPS con Docker y Compose V2

Instala Docker desde el repositorio oficial, no desde el paquete docker.io de Ubuntu. El paquete de la distribución va por detrás en versiones y, sobre todo, no incluye el plugin Compose V2, que es el que necesitas.

bash
sudo apt update && sudo apt upgrade -y

# Repositorio oficial de Docker
sudo apt install ca-certificates curl -y
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin -y

Comprueba que ambos componentes están y que Compose es V2:

bash
docker --version
docker compose version

La salida de docker compose version debe mostrar v2.x. Si en tu servidor solo funciona docker-compose con guion, tienes instalada la versión V1, un binario de Python que dejó de mantenerse y que Docker retiró de su catálogo. Reemplázalo por el plugin.

Crea un usuario sin privilegios y añádelo al grupo docker:

bash
sudo adduser deploy
sudo usermod -aG docker deploy
su - deploy

Ten presente lo que implica ese grupo: pertenecer a docker equivale a tener acceso root en la práctica, porque desde un contenedor se puede montar el sistema de archivos del host. No añadas al grupo a nadie en quien no confiarías con sudo sin contraseña.

Configura el firewall, permitiendo SSH antes de activarlo:

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

Un aviso que cuesta caro descubrir tarde: Docker escribe sus propias reglas en el kernel y puede saltarse UFW. Un contenedor publicado con -p 3306:3306 queda accesible desde internet aunque UFW muestre ese puerto bloqueado. La forma segura de exponer un servicio solo al propio servidor es publicarlo en loopback: -p 127.0.0.1:3306:3306.

Paso 5: Transferir los archivos al VPS

scp sirve para archivos pequeños; rsync es mejor para todo lo demás porque reanuda transferencias interrumpidas. Una imagen de varios gigabytes por una conexión doméstica es exactamente el caso donde esa diferencia importa.

bash
rsync -avz --progress mi_proyecto.tar.gz deploy@ip_del_vps:/home/deploy/
rsync -avz --progress backup.sql deploy@ip_del_vps:/home/deploy/

Si prefieres scp para archivos pequeños:

bash
scp docker-compose.yml deploy@ip_del_vps:/home/deploy/

No transfieras el archivo .env de desarrollo al servidor. Sus contraseñas suelen ser triviales y sus valores apuntan a servicios locales. Crea uno nuevo en el VPS con credenciales de producción.

Paso 6: Cargar la imagen en el VPS

docker load reconstruye la imagen en el servidor a partir del archivo transferido, con el mismo nombre y etiqueta que tenía en local.

bash
gunzip -c mi_proyecto.tar.gz | docker load
docker images

Confirma que aparece con el nombre y la etiqueta esperados. Si el docker-compose.yml referencia mi_imagen:1.0.0 y la imagen cargada es mi_imagen:latest, Compose intentará descargarla de Docker Hub y fallará con un error de imagen no encontrada.

Paso 7: Escribir el docker-compose.yml de producción

El archivo Compose de desarrollo no sirve tal cual en producción: hay que quitar el montaje del código fuente, añadir políticas de reinicio, no exponer la base de datos al exterior y usar una versión de MySQL con soporte. Estas son las diferencias que separan un despliegue estable de uno que se cae en el primer reinicio del servidor.

yaml
services:
  app:
    image: mi_imagen:1.0.0
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    environment:
      APP_ENV: production
      APP_DEBUG: "false"
      DB_HOST: db
      DB_DATABASE: ${DB_DATABASE}
      DB_USERNAME: ${DB_USERNAME}
      DB_PASSWORD: ${DB_PASSWORD}
    volumes:
      - app_storage:/var/www/html/storage
    depends_on:
      db:
        condition: service_healthy

  db:
    image: mysql:8.4
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db_data:
  app_storage:

Repasemos las decisiones, porque cada una corrige un error habitual:

  • No hay línea version:. El elemento de nivel superior version quedó obsoleto en la Compose Specification y genera un aviso; Compose siempre usa el esquema más reciente. Se elimina y ya.
  • image: mysql:8.4 y no mysql:5.7. MySQL 5.7 pasó a soporte sostenido de Oracle el 25 de octubre de 2023 y no recibe parches de seguridad; 8.4 es la versión LTS actual, con soporte premier hasta abril de 2029.
  • ports: "127.0.0.1:8080:80". El contenedor solo es accesible desde el propio servidor; el tráfico público llega a través del proxy reverso del host. Publicar en 0.0.0.0 expone el contenedor directamente a internet, saltándose UFW.
  • La base de datos no publica ningún puerto. Compose crea una red interna donde app alcanza a db por su nombre de servicio. Exponer el 3306 hacia fuera no aporta nada y sí atrae escaneos.
  • restart: unless-stopped. Sin esta línea, los contenedores no vuelven a levantarse tras reiniciar el servidor. Es la causa más común de «funcionaba y de repente el sitio está caído».
  • Volumen db_data. Sin él, los datos viven en la capa efímera del contenedor y desaparecen al recrearlo.
  • No se monta .:/var/www/html. Ese montaje es útil en desarrollo para ver cambios al instante, pero en producción tapa el código que va dentro de la imagen con lo que haya en el directorio del servidor, que normalmente no es lo que esperas.
  • healthcheck y depends_on: condition: service_healthy. depends_on a secas solo espera a que el contenedor arranque, no a que MySQL acepte conexiones; sin la comprobación de salud, la aplicación arranca antes que la base y falla.

Las credenciales van en un archivo .env junto al docker-compose.yml, que Compose lee automáticamente:

bash
cat > .env <<'EOF'
DB_DATABASE=mi_app
DB_USERNAME=mi_app_user
DB_PASSWORD=una_contraseña_larga_y_unica
DB_ROOT_PASSWORD=otra_contraseña_distinta
EOF
chmod 600 .env

Valida el archivo antes de levantar nada. Compose resuelve las variables y muestra la configuración final, que es la mejor forma de detectar indentaciones mal puestas o variables sin definir:

bash
docker compose config

Paso 8: Levantar los contenedores y restaurar los datos

Arranca primero solo la base de datos, restaura el volcado y después levanta la aplicación. Si levantas todo a la vez, la aplicación puede ejecutar sus migraciones sobre una base vacía y chocar luego con los datos importados.

bash
docker compose up -d db
docker compose ps

Espera a que el estado sea healthy y restaura:

bash
docker compose exec -T db mysql -u root -p"$DB_ROOT_PASSWORD" mi_app < backup.sql

La opción -T desactiva la asignación de pseudo-terminal, y es imprescindible: sin ella, la redirección del archivo SQL no llega al comando y la restauración se queda esperando en silencio.

Comprueba que los datos están ahí antes de continuar:

bash
docker compose exec db mysql -u root -p"$DB_ROOT_PASSWORD" \
  -e "USE mi_app; SHOW TABLES;"

Ahora sí, levanta el resto:

bash
docker compose up -d
docker compose logs -f

Paso 9: Configurar HTTPS sin pelear con los puertos

La forma más limpia de dar HTTPS a contenedores es poner Nginx en el propio host como proxy reverso y dejar que Certbot gestione ahí el certificado. Así el contenedor no necesita saber nada de TLS, y las renovaciones no exigen detener nada.

Instala Nginx y Certbot en el host:

bash
sudo apt install nginx certbot python3-certbot-nginx -y

Crea el sitio que apunta al contenedor:

bash
sudo nano /etc/nginx/sites-available/mi_app
nginx
server {
    listen 80;
    server_name tu_dominio.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}
bash
sudo ln -s /etc/nginx/sites-available/mi_app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d tu_dominio.com

Certbot deja programada la renovación automática mediante un temporizador de systemd; no hace falta añadir ninguna tarea de cron a mano. Compruébalo:

bash
systemctl list-timers | grep certbot
sudo certbot renew --dry-run

La alternativa certbot certonly --standalone que aparece en muchos tutoriales exige que el puerto 80 esté libre, lo que obliga a detener los contenedores en cada renovación: un corte de servicio programado cada pocas semanas. Con Nginx en el host, ese problema desaparece. Si te interesa el detalle de esta capa intermedia, lo desarrollamos en la guía de reverse proxy con Nginx.

Paso 10: Verificar el despliegue

Un despliegue no está terminado hasta que compruebas cuatro cosas: que la aplicación responde, que los datos están, que el certificado es válido y que todo sobrevive a un reinicio. El cuarto punto es el que más se salta y el que más disgustos da.

bash
# 1. La aplicación responde
curl -I https://tu_dominio.com

# 2. Estado y salud de los contenedores
docker compose ps

# 3. Los volúmenes existen y tienen datos
docker volume ls
docker compose exec db mysql -u root -p -e "USE mi_app; SELECT COUNT(*) FROM usuarios;"

# 4. La prueba de fuego
sudo reboot

Al volver a conectar, docker compose ps debe mostrar todos los contenedores en ejecución sin que hayas hecho nada. Si no arrancan, falta restart: unless-stopped en el archivo Compose.

Para el certificado, comprueba la configuración TLS con el analizador público de SSL Labs, que señala cadenas incompletas y protocolos obsoletos que el navegador no siempre reporta.

Problemas frecuentes en la migración

SíntomaCausa habitualSolución
Aviso «version is obsolete»Elemento version: en el YAMLEliminar esa línea
docker-compose: command not foundEl servidor solo tiene el plugin V2Usar docker compose con espacio
Los datos desaparecen al recrearFalta un volumen con nombre para la baseDeclarar db_data:/var/lib/mysql
La app arranca antes que la basedepends_on sin healthcheckAñadir condition: service_healthy
La restauración se queda colgadaFalta -T en docker compose execdocker compose exec -T db mysql ...
Puerto accesible pese a bloquearlo en UFWDocker escribe reglas propias en el kernelPublicar en 127.0.0.1:puerto:puerto
Nada arranca tras reiniciar el VPSFalta la política de reiniciorestart: unless-stopped
Imagen no encontrada al hacer upLa etiqueta no coincide con la cargadaComparar docker images con el YAML
El código de la imagen no se aplicaMontaje .:/var/www/html tapando la imagenQuitar ese volumen en producción

Migrar un proyecto Docker de local a producción se reduce a cinco decisiones: cómo llevas la imagen (registro, reconstrucción o .tar), cómo mueves los datos (volcado SQL, nunca confiando en que la imagen los lleve), cómo adaptas el archivo Compose a producción, cómo resuelves el SSL sin pelear por el puerto 80, y cómo verificas que todo sobrevive a un reinicio. Los tres fallos que más veces hemos visto son el volumen de base de datos ausente, que borra los datos en el primer docker compose down; la falta de restart: unless-stopped, que deja el sitio caído tras cualquier reinicio del servidor; y publicar puertos en 0.0.0.0 creyendo que UFW los protege, cuando Docker se salta esas reglas. Si aún estás decidiendo el servidor donde alojar los contenedores, ten en cuenta que Docker necesita acceso root y por tanto un VPS o un servidor dedicado; lo comparamos en hosting compartido vs VPS vs dedicado. Si tienes dudas durante la implementación, nuestro equipo de soporte técnico está disponible 24/7 para ayudarte.

¿Listo para llevar tu sitio al siguiente nivel?

Hosting con LiteSpeed, NVMe y soporte 24/7 desde $3/mes.

Ver planes de hosting

Preguntas frecuentes