SSD Nodes Learn Hosting plans →
Guías Matt ConnorPor Matt Connor · Actualizado 2026-08-27

Docker Compose en Ubuntu 24.04 para tu VPS

Instala Docker Engine y Compose v2 en Ubuntu 24.04, crea una pila Miniflux y PostgreSQL y evita que los puertos publicados ignoren las reglas de ufw.

Qué va a construir

Docker Compose es la base de casi todo lo demás en este sitio. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat: todas esas guías empiezan con «escriba este archivo compose», y esta página explica qué significa realmente ese archivo. Instalará Docker Engine y el complemento Compose v2 desde el repositorio apt oficial de Docker en Ubuntu 24.04. Después pondrá en marcha una pila real de dos servicios: Miniflux, un lector RSS pequeño, y PostgreSQL. Esta combinación utiliza todos los patrones de las aplicaciones más grandes: imágenes fijadas, una base de datos con una comprobación de estado, un volumen con nombre, secretos en un archivo .env y un puerto publicado sólo en localhost.

La instalación tarda cinco minutos. El resto de esta guía cubre los problemas que suelen aparecer después: el grupo docker, que equivale a root con otro nombre; los puertos publicados, que pasan directamente por delante de las reglas de ufw; y la única opción de docker compose down que elimina la base de datos sin pedir confirmación.

Requisitos previos: un VPS KVM nuevo con Ubuntu 24.04, un usuario con sudo y al menos un gigabyte de RAM. También puede usar una instalación existente de Docker; la primera sección explica qué debe eliminar.

Instale desde el repositorio de Docker, no desde el de Ubuntu

Antes del primer comando, descarte dos opciones incorrectas. El paquete docker.io propio de Ubuntu funciona, pero queda por detrás de las versiones de Docker y no incluye la disposición de plugins que asumen los demás componentes. El binario independiente docker-compose, el que lleva guion, corresponde a Compose v1: está escrito en Python, dejó de recibir mantenimiento en 2023 y es la causa de que fallen los tutoriales antiguos. Actualmente Compose es docker compose con un espacio, un plugin de la CLI instalado desde el mismo repositorio que el motor.

Si alguno de esos componentes ya está instalado en el sistema, elimínelo primero, incluido docker-compose-v2, el paquete propio de Ubuntu para el plugin, para que todo proceda de un único repositorio:

sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc

Package 'docker.io' is not installed, so not removed es la salida normal en un VPS nuevo. Después, añada el repositorio de Docker e instale los paquetes:

sudo apt update
sudo apt install -y ca-certificates curl
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 -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Compruebe las tres capas:

docker --version
docker compose version
sudo docker run --rm hello-world

Las dos primeras muestran cadenas de versión. Docker Compose version v2.x.x confirma que tiene el plugin y no el binario obsoleto de v1. La ejecución de hello-world debe terminar con Hello from Docker!. El paquete habilita el servicio durante el arranque; systemctl is-enabled docker muestra enabled.

El grupo docker equivale a root; decídalo con toda la información

Ahora mismo, cada comando de docker necesita sudo, porque el socket del daemon en /var/run/docker.sock pertenece a root y al grupo docker. Sin pertenecer a ese grupo, aparece el error de Docker más buscado:

permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

La solución estándar:

sudo usermod -aG docker $USER

La pertenencia al grupo se aplica al iniciar sesión, por lo que el error persiste en el shell actual. Ejecute newgrp docker para esta sesión, o cierre la sesión y vuelva a iniciarla; después, id debería mostrar docker entre sus grupos.

Ahora, la parte que debe quedar clara: pertenecer al grupo docker equivale a tener root en el host. No es un acceso «parecido a root» ni un acceso elevado: es root. Cualquier usuario de ese grupo puede ejecutar docker run --rm -it -v /:/host alpine chroot /host y controlar todo el sistema de archivos sin que se solicite ninguna contraseña. El grupo existe por comodidad, no para contener privilegios.

El modo rootless de Docker es la alternativa real: el propio daemon se ejecuta como el usuario sin privilegios. Tiene costes: los puertos inferiores a 1024 requieren configuración adicional, la red funciona mediante una capa de compatibilidad en el espacio de usuario con una sobrecarga medible y algunas imágenes funcionan mal sin root real. En un VPS con un único administrador, donde la única cuenta de acceso ya tiene sudo, el grupo no cambia nada en la práctica y es lo que presupone toda esta guía; aun así, no lo conceda como si ofreciera menos privilegios que sudo.

Anatomía de un archivo de Compose

Asigne a cada stack su propio directorio. El nombre del directorio se convierte en el nombre del proyecto y sirve como prefijo para los contenedores, las redes y los volúmenes:

sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/miniflux

Cree compose.yml (el nombre moderno; docker-compose.yml todavía funciona). Omita la clave antigua version:. Está obsoleta y Compose muestra una advertencia si la encuentra.

services:
  miniflux:
    image: miniflux/miniflux:2.2.9
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    environment:
      - DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      - RUN_MIGRATIONS=1
      - CREATE_ADMIN=1
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=${ADMIN_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      - POSTGRES_USER=miniflux
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_DB=miniflux
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  db-data:

Cada línea anterior representa una decisión. Analícelas una por una.

Fije las versiones de las imágenes; :latest más una extracción implica una actualización no supervisada

postgres:16-alpine, no postgres:latest. Una etiqueta no fija una versión: :latest vuelve a resolverse con lo último que el mantenedor haya publicado cada vez que hace una extracción. Si lo combina con el procedimiento habitual de actualización que aprenderá a continuación, docker compose pull && docker compose up -d, :latest implica que los saltos de versión principal se aplican cuando los publica el proyecto upstream, no cuando usted decide. Con PostgreSQL no es una posibilidad teórica: un salto inesperado de 16 a 17 deja el contenedor en un ciclo continuo de fallos porque el directorio de datos no es compatible. Las actualizaciones de versión principal de Postgres requieren un volcado y una restauración, no un reinicio.

Fije como mínimo la versión principal (postgres:16-alpine sigue las actualizaciones de parche de 16.x) y fije las aplicaciones a una versión exacta, como miniflux/miniflux:2.2.9. Consulte la página de versiones del proyecto y use la versión vigente cuando escriba el archivo. Así, una actualización se convierte en una edición de una línea que realiza deliberadamente y que queda visible en git diff.

Publique en 127.0.0.1 porque Docker evita ufw

"127.0.0.1:8080:8080", dirección del host, puerto del host y puerto del contenedor. La mayoría de los tutoriales escriben "8080:8080", que es una abreviatura de 0.0.0.0:8080:8080: escucha en todas las interfaces, incluida la pública.

Aquí está el problema, que afecta a casi todo el mundo al menos una vez. Docker publica un puerto escribiendo una regla DNAT que reescribe el destino del paquete a la IP interna del contenedor antes del filtrado. Por tanto, el paquete sigue la ruta FORWARD y nunca pasa por INPUT, donde se encuentran las reglas de ufw. sudo ufw deny 8080 informa de que la operación se realizó correctamente, ufw status muestra que el puerto está denegado y el servicio sigue respondiendo a todo Internet. El firewall no está averiado; Docker lo evita deliberadamente. Por qué Docker evita ufw y cómo filtrar realmente el tráfico de los contenedores explica el mecanismo y la solución DOCKER-USER para los puertos que deben permanecer públicos.

El procedimiento que elimina el problema es sencillo: vincule los puertos publicados a 127.0.0.1, salvo que tenga un motivo concreto para no hacerlo, y coloque un reverse proxy delante de cualquier servicio que deba estar expuesto a Internet. Eso es exactamente lo que configura la guía del reverse proxy Traefik como siguiente paso después de esta página: un contenedor que administra los puertos 80 y 443 y redirige el tráfico al resto de servicios según el nombre de host, con TLS. (¿Viene de una configuración antigua de Traefik v2? La guía de migración de Traefik v2 a v3 explica los cambios de nombres y de reglas).

Compruebe la vinculación después de iniciar el stack: sudo ss -tlnp | grep 8080 debería mostrar 127.0.0.1:8080, no 0.0.0.0:8080 ni *:8080.

Volúmenes con nombre frente a montajes bind

db-data:/var/lib/postgresql/data es un volumen con nombre: Docker crea y administra un directorio en /var/lib/docker/volumes/ y lo monta en el contenedor. La alternativa es un montaje bind, ./data:/var/lib/postgresql/data, que asigna una ruta elegida por usted en el host.

La separación que funciona en la práctica es la siguiente: use volúmenes con nombre para los datos que sólo manipulan los contenedores, especialmente las bases de datos, porque Docker inicializa el volumen con el propietario que espera la imagen y los permisos funcionan directamente. Use montajes bind para los archivos que modifica desde el host: archivos de configuración que edita con un editor de texto, una biblioteca multimedia que sincroniza con rsync y cualquier contenido cuya ruta quiera identificar fácilmente. El fallo clásico de un montaje bind son los propietarios: el contenedor se ejecuta con UID 999, el directorio del host pertenece a UID 1000 y la aplicación termina durante el arranque con permission denied en sus registros. Los volúmenes con nombre hacen que esta clase de error desaparezca en gran medida, a cambio de que los datos residan en una ruta administrada por Docker, como se explica más adelante.

environment y .env: mantenga los secretos fuera de git

${POSTGRES_PASSWORD} no se lee del shell. Compose lo interpola desde un archivo llamado .env situado junto a compose.yml. Créelo:

cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore

Genere valores reales con openssl rand -hex 24. Use hexadecimal, no base64, de forma intencionada: esta contraseña termina dentro de la cadena de conexión DATABASE_URL, y los caracteres /, + y = que produce base64 rompen el análisis de la URL. El fallo aparece como un error de autenticación, no como un error de sintaxis, y puede consumir toda una tarde. La línea .gitignore debe estar presente antes del primer commit: el archivo de Compose se puede publicar y versionar, pero el archivo .env nunca debe publicarse. Un secreto que ha entrado en el historial de git es un secreto que debe rotar. Si inicia el stack sin una variable, Compose muestra una advertencia clara y continúa con una cadena vacía. En el caso de la contraseña de Postgres, esto produce un despliegue defectuoso:

WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.

docker compose config muestra el archivo con todas las interpolaciones aplicadas. Es la forma más rápida de comprobar qué recibirán realmente los contenedores. Recuerde que su salida incluye los secretos.

depends_on no espera nada salvo que añada un healthcheck

Un depends_on: [db] simple sólo controla el orden de inicio: Compose inicia primero Postgres y, un momento después, la aplicación, mientras Postgres todavía tarda varios segundos en aceptar conexiones. La aplicación intenta conectarse a la base de datos, falla y termina o reintenta según la calidad de su implementación.

La versión fiable es la que utiliza el archivo anterior: el servicio db define un healthcheck (Postgres incluye pg_isready precisamente para esto) y la aplicación declara depends_on con condition: service_healthy. Compose inicia la base de datos, comprueba su estado cada 10 segundos y sólo inicia Miniflux cuando la comprobación tiene éxito. Si la base de datos nunca alcanza el estado saludable, por ejemplo debido a una contraseña incorrecta o a un volumen dañado, la aplicación no se inicia y Compose indica qué dependencia falló:

dependency failed to start: container miniflux-db-1 is unhealthy

Ese mensaje le dirige a docker compose logs db, donde se encuentra el error real.

restart: unless-stopped

restart: unless-stopped en ambos servicios hace que los contenedores vuelvan a iniciarse después de un fallo y después de reiniciar el VPS, pero permanezcan detenidos si usted ejecutó deliberadamente docker compose stop. La alternativa always vuelve a iniciar los contenedores incluso después de una detención manual, lo que rara vez es lo que quería. Sin una política de reinicio, un reinicio provocado por una actualización del kernel a las 4 a. m. detiene silenciosamente los servicios hasta que usted lo detecta.

Los comandos diarios

Todo el trabajo diario se reduce a cinco comandos, ejecutados desde el directorio del proyecto.

docker compose up -d        # create and start; idempotent, recreates only what changed
docker compose ps           # status, ports, and health of this project's containers
docker compose logs -f miniflux   # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d   # upgrade to the pinned tags
docker compose down         # stop and remove containers and the network

up -d se puede ejecutar repetidamente sin riesgo: compara el archivo con el estado real y sólo modifica los servicios cuya configuración o imagen haya cambiado. El par de actualización descarga lo que indiquen ahora las etiquetas fijadas: versiones de corrección bajo postgres:16-alpine, y nada para una fijación exacta hasta que la edite, que es precisamente el objetivo. Las imágenes antiguas se acumulan después de las actualizaciones; recupere espacio en disco con docker image prune -f.

Ahora, el comando destructivo, indicado claramente: docker compose down es seguro, porque los contenedores y la red son desechables, y los datos están en el volumen. docker compose down -v también elimina los volúmenes con nombre. Esa es su base de datos: desaparece al instante, sin ninguna solicitud de confirmación y sin posibilidad de deshacerlo. La opción -v existe para desmontar entornos de prueba; en una pila que contiene datos reales, trátela igual que rm -rf. No hay papelera bajo /var/lib/docker/volumes/.

Para abrir un shell puntual dentro de un contenedor en ejecución: docker compose exec db psql -U miniflux le lleva a la base de datos y docker compose exec miniflux sh abre un shell en la aplicación.

Dónde se encuentran realmente los datos

Los volúmenes con nombre reciben el prefijo del proyecto, por lo que db-data en un directorio llamado miniflux se convierte en miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

La salida de inspect incluye la línea relevante:

"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"

Ese directorio es la base de datos, pertenece a root, se encuentra en el sistema de archivos del host y sobrevive a down, las actualizaciones y la reconstrucción de los contenedores. También es exactamente lo que deben incluir las copias de seguridad.

Hacer una copia de seguridad de un volumen con nombre

El patrón estándar consiste en usar un contenedor temporal que monte el volumen como de solo lectura junto a un directorio del host y ejecute tar para copiar los datos:

docker run --rm \
  -v miniflux_db-data:/data:ro \
  -v "$PWD":/backup \
  alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .

No requiere instalaciones ni deja procesos en ejecución. La restauración es la operación inversa: tar xzf en un volumen vacío nuevo, con los mismos montajes invertidos.

Hay una salvedad con las bases de datos: crear un tar de un directorio de datos de Postgres en ejecución puede capturar un estado intermedio de escritura que impida un arranque correcto. Puede docker compose stop durante los segundos que tarde tar o, mejor, crear un volcado lógico, que es coherente por diseño:

docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz

-T desactiva el pseudo-terminal que Compose asigna de forma predeterminada. Enviar la salida del volcado a través de un TTY puede dañarla. Programe una de estas opciones en cron y copie el resultado fuera del VPS. Una copia de seguridad en el mismo disco que los datos que protege es una copia, no una copia de seguridad. La guía de Nextcloud desarrolla una rutina programada completa basada exactamente en estos dos patrones.

Modos de fallo y mensajes que verá

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, todavía no pertenece al grupo docker, o la sesión es anterior a la incorporación al grupo. id muestra sus grupos efectivos; newgrp docker corrige el shell actual. Cerrar sesión y volver a iniciarla corrige todas las sesiones.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, el problema es otro: el daemon está detenido. sudo systemctl status docker y sudo journalctl -u docker -n 50 indican la causa. En un VPS, la causa habitual es que el disco esté lleno. Ejecute primero df -h /var/lib/docker.

Bind for 127.0.0.1:8080 failed: port is already allocated, otro contenedor ya ha publicado ese puerto del host. docker ps muestra cuál es. Lo habitual es que sea un contenedor antiguo de un docker run experimental de hace varias semanas. Si docker ps no muestra nada, un proceso ajeno a Docker ocupa el puerto. sudo ss -tlnp | grep 8080 identifica cuál.

yaml: line 14: did not find expected key, hay un error de indentación en la línea indicada o justo encima. Los archivos de Compose usan YAML: dos espacios de indentación, sólo espacios y ningún carácter de tabulación. Una tabulación en cualquier lugar provoca un error. docker compose config valida el archivo sin iniciar nada. Ejecutarlo después de cada edición es una práctica sencilla y útil.

La sorpresa de ufw no muestra ningún error, lo que la hace peligrosa: el despliegue funciona, ufw status parece correcto y un análisis de puertos desde el exterior encuentra la base de datos igualmente. Vuelva a leer la sección de puertos anterior, compruebe todas las entradas ports: para detectar si falta el prefijo 127.0.0.1: y confirme desde otra máquina con curl http://your-vps-ip:8080. La respuesta que busca es connection refused.

A partir de aquí, la guía de Traefik convierte este stack en varias aplicaciones detrás de un único punto de entrada HTTPS, y qué merece la pena alojar por cuenta propia en 2026 es la lista de servicios que puede desplegar detrás de él. Cuando haya varios de estos stacks en ejecución y cada uno tenga su propio formulario de inicio de sesión, un servidor SSO autoalojado como Authentik los unifica en una sola cuenta detrás del mismo proxy.

Un servidor de juegos, como un servidor de Minecraft en un VPS, es un proyecto inicial de Compose adecuado para practicar. Si prefiere aprender con algo que abra todos los días, openGym, un registro de entrenamientos autoalojado es una pila pequeña fijada a una etiqueta de git en lugar de a una etiqueta de imagen. Requiere TLS delante antes de registrar la primera passkey. Las fotos suelen ser lo primero que se quiere recuperar de la nube de otra persona, y comparar PhotoPrism e Immich permite establecer la RAM mínima y la rutina de copias de seguridad que asumirá antes de asignar un volumen a cualquiera de los dos. Cuando dos servicios ya no parecen suficientes, poner en marcha AFFiNE como un espacio de trabajo al estilo de Notion aplica los mismos patrones con cuatro contenedores. También es una prueba adecuada para comprobar si las etiquetas fijadas, los healthchecks y los volúmenes con nombre anteriores ya se han convertido en un hábito.

FAQ

¿Por qué aparece «permission denied while trying to connect to the Docker daemon socket»?

Tu usuario no pertenece al grupo docker, o se añadió después de iniciar la sesión actual. La pertenencia al grupo sólo se aplica al iniciar sesión. Ejecuta sudo usermod -aG docker $USER y después newgrp docker, o cierra la sesión y vuelve a iniciarla. Confirma el resultado con id. Este grupo concede acceso equivalente a root al host, así que sólo debes añadir usuarios a los que también concederías acceso mediante sudo.

¿docker compose down elimina mis datos?

El comando docker compose down sin opciones no elimina los datos. Quita los contenedores y la red del proyecto; los volúmenes con nombre permanecen y el siguiente up -d vuelve a asociarlos. docker compose down -v es la forma destructiva: elimina los volúmenes con nombre, incluida la base de datos, sin pedir confirmación y sin posibilidad de deshacerlo. No ejecutes -v en un stack con datos reales salvo que tengas una copia de seguridad verificada.

¿Cuál es la diferencia entre docker-compose y docker compose?

docker-compose (con guion) es Compose v1, un binario independiente escrito en Python que llegó al final de su vida útil en 2023 y no debe instalarse en servidores nuevos. docker compose (con espacio) es Compose v2, un plugin escrito en Go para Docker CLI, instalado como docker-compose-plugin desde el repositorio apt de Docker. Los comandos y el formato YAML son prácticamente compatibles. Por tanto, cuando un tutorial antiguo indique docker-compose up, escribe docker compose up.

¿Por qué puedo acceder a mi contenedor Docker desde Internet aunque ufw bloquee el puerto?

Docker publica los puertos mediante reglas DNAT en la cadena PREROUTING de iptables. Los paquetes modificados siguen la ruta FORWARD a través de las cadenas propias de Docker y nunca pasan por la cadena INPUT, donde se aplican las reglas de ufw. Por tanto, ufw deny 8080 no tiene ningún efecto sobre un puerto publicado por un contenedor. Corrígelo en el origen: publica el puerto en 127.0.0.1: y expón los servicios mediante un reverse proxy.

¿Debo usar un volumen con nombre o un bind mount?

Usa volúmenes con nombre para los datos que sólo gestiona el contenedor, especialmente las bases de datos. Docker asigna la propiedad que espera la imagen y los permisos funcionan correctamente. Usa bind mounts para los archivos que también gestionas desde el host: configuraciones que editas, archivos multimedia que cargas y cualquier contenido cuya ruta quieras ver claramente. Si un contenedor falla al iniciar con permission denied en un bind mount, lo primero que debes comprobar es que los UID del host y del contenedor no coincidan.