Un contenedor Docker no se "mueve" de un servidor a otro: lo que se migra es lo que lo define (el archivo de Compose, las variables de entorno y la imagen) y los datos que guarda en volúmenes. En este tutorial migrarás una aplicación gestionada con Docker Compose, con volúmenes con nombre y directorios montados, desde un servidor antiguo a uno nuevo con Ubuntu 24.04. Al terminar, la aplicación funcionará en el destino con los mismos datos y podrás apagar el origen.
Requisitos previos
- El servidor de origen con Docker y el plugin de Docker Compose (
docker compose). - Un servidor de destino con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con Docker Engine y Docker Compose instalados desde el repositorio oficial de Docker.
- Un usuario no root con
sudoen ambos servidores, miembro del grupodocker, y acceso SSH por clave desde el origen al destino. - Espacio libre en disco en ambos servidores para al menos una copia comprimida de los volúmenes.
En esta guía, your_user es tu usuario, new_server_ip la IP del destino, /opt/myapp el directorio del proyecto de Compose y myapp el nombre del proyecto. Sustitúyelos por tus valores.
Notasi la aplicación incluye una base de datos, además de copiar su volumen conviene hacer un volcado lógico (
pg_dump,mysqldump) antes de empezar. Si la copia del volumen falla por cualquier motivo, podrás restaurar desde el volcado.
Paso 1: Inventariar lo que hay que migrar
En el origen, lista los proyectos de Compose y sus contenedores:
docker compose ls
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
NAME STATUS CONFIG FILES
myapp running(3) /opt/myapp/compose.yaml
Para cada contenedor, revisa qué datos persistentes usa. Este comando muestra el tipo de montaje (volume o bind), su origen y dónde se monta:
docker inspect --format '{{.Name}}{{range .Mounts}}
{{.Type}} {{if .Name}}{{.Name}}{{else}}{{.Source}}{{end}} -> {{.Destination}}{{end}}' $(docker ps -q)
/myapp-db-1
volume myapp_dbdata -> /var/lib/postgresql/data
/myapp-web-1
bind /opt/myapp/uploads -> /app/uploads
Hay dos tipos de datos que migrar:
- Volúmenes con nombre (
myapp_dbdata): Compose les añade el nombre del proyecto como prefijo. Viven en/var/lib/docker/volumes/y se copian con un contenedor auxiliar. - Montajes de directorio (
/opt/myapp/uploads): son directorios normales del servidor y se copian conrsync.
Anota también qué imágenes se construyen localmente (build: en el archivo de Compose) y cuáles vienen de un registro (image:). Las segundas se descargarán solas en el destino.
Comprueba la arquitectura de las imágenes. Si el origen es x86_64 (amd64) y el destino es ARM (arm64), o al revés, tendrás que reconstruir las imágenes locales en el destino en lugar de copiarlas:
docker image inspect --format '{{.RepoTags}} {{.Architecture}}' $(docker images -q)
Paso 2: Copiar el proyecto de Compose
El directorio del proyecto contiene compose.yaml (o docker-compose.yml), el archivo .env con las variables y cualquier archivo de configuración montado. En el destino, crea el directorio y dale permisos a tu usuario:
sudo mkdir -p /opt/myapp
sudo chown your_user:your_user /opt/myapp
Desde el origen, copia el proyecto. Esta primera copia incluye los directorios montados como uploads, y se puede hacer con la aplicación en marcha:
rsync -aHAX --info=progress2 /opt/myapp/ your_user@new_server_ip:/opt/myapp/
Si algún archivo del proyecto pertenece a root o a otro usuario (algo habitual en directorios donde escribe un contenedor), ejecuta rsync con sudo en ambos lados. En el destino, permite antes a tu usuario ejecutar rsync con sudo sin contraseña creando /etc/sudoers.d/rsync-migracion con la línea your_user ALL=(root) NOPASSWD: /usr/bin/rsync, y lanza desde el origen:
sudo rsync -aHAX --numeric-ids --info=progress2 \
-e "ssh -i /home/your_user/.ssh/id_ed25519" \
--rsync-path="sudo rsync" \
/opt/myapp/ your_user@new_server_ip:/opt/myapp/
--numeric-ids conserva los UID y GID originales, que son los que esperan los procesos de dentro de los contenedores.
En el destino, valida el archivo de Compose. Si no hay errores, el comando muestra la configuración completa ya interpretada:
cd /opt/myapp
docker compose config --quiet && echo "Compose OK"
Paso 3: Transferir las imágenes construidas localmente
Las imágenes que vienen de un registro (Docker Hub, GHCR o uno privado) no hace falta copiarlas: docker compose las descargará en el destino. Si usas un registro privado, inicia sesión en el destino con docker login.
Las imágenes construidas en el origen tienes dos formas de llevarlas: reconstruirlas en el destino con docker compose build, si el código fuente está en el directorio del proyecto, o transferirlas tal cual. Para transferir una imagen sin crear archivos intermedios, envía la salida de docker save por SSH directamente a docker load en el destino:
docker save myapp-web:latest | gzip | ssh your_user@new_server_ip 'gunzip | docker load'
Loaded image: myapp-web:latest
Comprueba en el destino que la imagen está disponible:
docker image ls myapp-web
Nota
docker saveguarda la imagen con todas sus capas y etiquetas. No confundas condocker export, que vuelca solo el sistema de archivos de un contenedor y pierde el historial, los puertos expuestos, el comando de inicio y las variables de entorno de la imagen. Para migrar, usa siempre imágenes, no contenedores exportados.
Paso 4: Crear los volúmenes vacíos en el destino
Para restaurar los datos, primero deben existir los volúmenes con el nombre exacto que usará Compose. En el destino, crea los volúmenes, las redes y los contenedores del proyecto sin arrancarlos:
cd /opt/myapp
docker compose up --no-start
Comprueba que los volúmenes existen con el mismo nombre que en el origen:
docker volume ls --filter name=myapp_
DRIVER VOLUME NAME
local myapp_dbdata
Paso 5: Detener la aplicación en el origen y copiar los volúmenes
Copiar el volumen de una base de datos mientras está escribiendo produce una copia inconsistente, así que este paso requiere parar la aplicación. Es la ventana de corte: todo lo anterior se ha hecho con el servicio funcionando.
En el origen, detén los contenedores sin eliminarlos, para poder volver a arrancarlos si algo falla:
cd /opt/myapp
docker compose stop
Copia cada volumen con nombre al destino. Un contenedor temporal de Alpine monta el volumen en modo solo lectura y lo empaqueta con tar; la salida viaja por SSH a otro contenedor temporal en el destino que la desempaqueta dentro del volumen vacío:
docker run --rm -v myapp_dbdata:/data:ro alpine tar -C /data -czf - . \
| ssh your_user@new_server_ip 'docker run --rm -i -v myapp_dbdata:/data alpine tar -C /data -xzf -'
tar conserva propietarios y permisos al ejecutarse como root dentro del contenedor. Repite el comando para cada volumen de la lista del paso 1.
Si subiste archivos a directorios montados después de la primera copia, sincroniza de nuevo el proyecto. --delete elimina en el destino lo que se borró en el origen:
rsync -aHAX --delete --info=progress2 /opt/myapp/ your_user@new_server_ip:/opt/myapp/
Usa la variante con sudo del paso 2 si la necesitaste entonces.
Compara el tamaño de los datos en ambos servidores para detectar una copia incompleta:
docker run --rm -v myapp_dbdata:/data:ro alpine du -s /data
Ejecuta el mismo comando en el origen y en el destino; los valores deben coincidir o ser muy parecidos.
Paso 6: Arrancar la aplicación en el destino
En el destino, arranca el proyecto:
cd /opt/myapp
docker compose up -d
Comprueba que todos los servicios están en marcha y, si tienen healthcheck, que aparecen como healthy:
docker compose ps
NAME IMAGE SERVICE STATUS
myapp-db-1 postgres:16 db Up 20 seconds (healthy)
myapp-web-1 myapp-web:latest web Up 18 seconds
Revisa los logs en busca de errores de permisos o de conexión con la base de datos:
docker compose logs --tail 50
Prueba la aplicación contra la IP nueva antes de tocar los DNS. Si publica el puerto 80, por ejemplo:
curl -I -H "Host: your_domain" http://new_server_ip
Una respuesta HTTP/1.1 200 OK (o la redirección que devuelva normalmente tu aplicación) confirma que funciona. Comprueba también que ves los datos esperados: usuarios, archivos subidos, registros recientes.
Asegúrate de que el cortafuegos del destino permite los puertos publicados, por ejemplo con sudo ufw allow 80,443/tcp. Ten en cuenta que Docker añade sus propias reglas de iptables para los puertos publicados, por lo que UFW no bloquea los puertos que publiques en todas las interfaces (0.0.0.0). Si un servicio solo debe ser accesible localmente, publícalo en 127.0.0.1 en el archivo de Compose.
Paso 7: Cambiar el DNS y retirar el origen
Cuando la aplicación funcione en el destino, apunta los registros A y AAAA de tu dominio a new_server_ip. Si bajaste el TTL a 300 segundos el día anterior, el cambio se propagará en pocos minutos. Verifícalo:
dig +short A your_domain @1.1.1.1
Si la aplicación obtiene certificados TLS automáticamente (Caddy, Traefik o un contenedor de Certbot), el destino podrá renovarlos en cuanto el DNS apunte a él. Si copiaste el volumen con los certificados, no habrá ninguna interrupción de HTTPS.
Mantén el origen con los contenedores detenidos durante unos días. Si necesitas volver atrás, basta con revertir el DNS y ejecutar docker compose start en el origen. Cuando estés seguro, elimínalos allí con:
docker compose down
Este comando no borra los volúmenes con nombre; para eliminarlos también añade --volumes, solo cuando ya no necesites los datos del origen. Elimina también el archivo /etc/sudoers.d/rsync-migracion del destino si lo creaste.
Solución de problemas
Permission denieden los logs tras arrancar: los archivos de un directorio montado cambiaron de propietario al copiarlos sinsudoni--numeric-ids. Repite la copia con la variante consudodel paso 2.exec format erroral arrancar un contenedor: la imagen es de otra arquitectura. Reconstrúyela en el destino condocker compose build.- La base de datos arranca vacía: el volumen del destino no se llama igual que el del origen, normalmente porque el directorio del proyecto tiene otro nombre y Compose usó otro prefijo. Comprueba
docker volume lsy fija el nombre del proyecto conname: myappal principio decompose.yaml. - PostgreSQL o MySQL no arrancan tras la restauración: la imagen del destino es de otra versión mayor que la del origen. Usa exactamente la misma etiqueta de imagen y actualiza la versión después, por separado.
Conclusión
Has migrado una aplicación Docker Compose completa: el proyecto y los directorios montados con rsync, las imágenes locales con docker save y docker load, y los volúmenes con tar dentro de contenedores temporales, con una única ventana de corte para la copia final. Como siguientes pasos, publica tus imágenes en un registro para que la próxima migración sea un simple docker compose pull, y programa copias de seguridad periódicas de los volúmenes del servidor nuevo.
