instalar docker y compose en ubuntu 24.04
Guía para instalar Docker Engine y Compose v2 en Ubuntu 24.04. Aprende a configurar servicios, evitar conflictos con ufw y realizar backups de volúmenes.
Qué vas a construir
Docker Compose es la base de casi todo lo demás en este sitio. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — todas estas guías comienzan con "escribe este archivo compose", y esta es la página que explica el significado real de dicho archivo. Instalarás Docker Engine y el plugin Compose v2 desde el repositorio apt oficial de Docker en Ubuntu 24.04. Luego configurarás un stack real de dos servicios: Miniflux, un lector RSS ligero, y PostgreSQL. Este par utiliza todos los patrones de las aplicaciones más grandes: imágenes con versión fija, una base de datos con healthcheck, un volumen con nombre, secretos en un archivo .env y un puerto publicado solo para localhost.
La instalación tarda cinco minutos. El resto de esta guía cubre los aspectos que causan problemas después: el grupo docker con privilegios de root, los puertos publicados que ignoran las reglas de ufw y el flag de docker compose down que elimina la base de datos sin pedir confirmación.
Requisitos previos: un VPS KVM con Ubuntu 24.04 recién instalado, un usuario con sudo y un gigabyte de RAM o más. Una instalación previa de Docker también es válida; la primera sección explica qué eliminar.
Instalar desde el repo de Docker, no desde el de Ubuntu
Evite dos errores comunes antes de ejecutar el primer comando. El paquete docker.io de Ubuntu funciona, pero tiene versiones desactualizadas respecto a Docker y no sigue la estructura de plugins requerida. El binario independiente docker-compose (con guion) es Compose v1: utiliza Python y llegó al final de su vida útil en 2023; esta es la razón por la que fallan los tutoriales antiguos. Compose actualmente es docker compose (con espacio), funciona como un plugin de CLI y se instala desde el mismo repositorio que el motor.
Si ya tiene instalado alguno de estos componentes, elimínelos primero —incluyendo docker-compose-v2, el paquete de plugins de Ubuntu— para que todo provenga de un único repositorio:
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcPackage 'docker.io' is not installed, so not removed es la salida normal en un VPS limpio. Luego, añada el repositorio de Docker e instale:
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-pluginVerifique las tres capas:
docker --version
docker compose version
sudo docker run --rm hello-worldLas dos primeras muestran cadenas de versión; Docker Compose version v2.x.x confirma que tiene el plugin y no el binario obsoleto v1. El comando hello-world debe terminar con Hello from Docker!. El paquete habilita el servicio al arrancar; systemctl is-enabled docker muestra enabled.
El grupo docker es root — decida con cautela
Actualmente, cada comando docker requiere sudo, porque el socket del daemon en /var/run/docker.sock pertenece a root y al grupo docker. Sin pertenencia al grupo, obtendrá el error de Docker más buscado en Google:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockLa solución estándar:
sudo usermod -aG docker $USERLa pertenencia al grupo se aplica al iniciar sesión, por lo que el error persistirá en su shell actual. Ejecute newgrp docker para esta sesión, o cierre sesión y vuelva a entrar; id debería listar docker en sus grupos.
Ahora la parte honesta, dicha claramente: la pertenencia al grupo docker es root en el host. No es "similar a root", ni "elevada"; es root. Cualquier usuario en ese grupo puede ejecutar docker run --rm -it -v /:/host alpine chroot /host y controlar todo el filesystem, sin necesidad de contraseña. El grupo existe por conveniencia, no por aislamiento.
El modo rootless de Docker es la alternativa real: el daemon se ejecuta como su usuario sin privilegios. Esto implica: los puertos inferiores a 1024 requieren configuración adicional, la red funciona mediante un shim en userspace con una sobrecarga medible, y algunas imágenes fallan sin root real. En un VPS con un único administrador donde el único login ya tiene sudo, el cambio de grupo no altera nada en la práctica, y es lo que asumen todas las guías de aquí; simplemente, nunca otorgue este acceso como si fuera menos que sudo.
Anatomy of a compose file
Give each stack its own directory — the directory name becomes the project name, which prefixes containers, networks, and volumes:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxCreate compose.yml (the modern name; docker-compose.yml still works). Skip the old version: key — it is obsolete and Compose warns if it sees one.
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:Every line above is a decision. Take them one at a time.
Pin image versions — :latest plus a pull is an unattended upgrade
postgres:16-alpine, not postgres:latest. A tag is not frozen: :latest re-resolves to whatever the maintainer most recently pushed, every time you pull. Combine that with the routine upgrade habit you are about to learn — docker compose pull && docker compose up -d — and :latest means major-version jumps land whenever upstream ships them, not when you choose. With PostgreSQL that is not hypothetical: a surprise jump from 16 to 17 leaves the container crash-looping on an incompatible data directory, because Postgres major upgrades require a dump and restore, not a restart.
Pin at least the major version (postgres:16-alpine follows 16.x patch releases), and pin applications to an exact release like miniflux/miniflux:2.2.9 — check the project's releases page and use whatever is current when you write the file. An upgrade then becomes a one-line edit you made on purpose, visible in git diff.
Publish to 127.0.0.1, because Docker walks around ufw
"127.0.0.1:8080:8080" — host address, host port, container port. Most tutorials write "8080:8080", which is shorthand for 0.0.0.0:8080:8080: listening on every interface, including the public one.
Here is the trap, and it bites almost everyone once. Docker publishes a port by writing a DNAT rule that rewrites the packet's destination to the container's internal IP before filtering, so the packet takes the FORWARD path and never touches INPUT, where your ufw rules live. sudo ufw deny 8080 reports success, ufw status shows the port denied, and the service still answers the whole internet. Your firewall is not broken; it is being bypassed by design. Why Docker bypasses ufw, and how to filter container traffic for real walks through the mechanism and the DOCKER-USER fix for ports that must stay public.
The habit that makes the whole problem vanish: bind published ports to 127.0.0.1 unless you have a specific reason not to, and put a reverse proxy in front for anything that should face the world. That is exactly what the Traefik reverse proxy guide builds as the next step after this page — one container that owns ports 80 and 443 and routes to everything else by hostname, with TLS. (Coming from an older Traefik v2 setup? The Traefik v2 to v3 migration guide covers the renames and rule changes.)
Verify the bind after starting the stack: sudo ss -tlnp | grep 8080 should show 127.0.0.1:8080, not 0.0.0.0:8080 or *:8080.
Named volumes vs bind mounts
db-data:/var/lib/postgresql/data is a named volume: Docker creates and manages a directory under /var/lib/docker/volumes/ and mounts it into the container. The alternative is a bind mount, ./data:/var/lib/postgresql/data, which maps a path you chose on the host.
The split that holds up in practice: named volumes for data only containers touch — databases above all, since Docker initialises the volume with the ownership the image expects and file permissions just work. Bind mounts for files you touch from the host — config files you edit with a text editor, a media library you rsync into, anything whose path you want to be obvious. The classic bind-mount failure is ownership: the container runs as UID 999, your host directory is owned by UID 1000, and the app dies on startup with permission denied in its logs. Named volumes make that class of bug mostly disappear, at the cost of the data living at a Docker-managed path — covered below.
environment and .env — keep secrets out of git
${POSTGRES_PASSWORD} is not read from your shell; Compose interpolates it from a file named .env sitting next to compose.yml. Create it:
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignoreGenerate real values with openssl rand -hex 24. Hex, not base64, on purpose: this password lands inside the DATABASE_URL connection string, and the /, +, and = characters base64 produces break URL parsing — a failure that surfaces as an authentication error, not a syntax error, and costs an evening. The .gitignore line goes in before the first commit: the compose file is safe to publish and version, the .env file never is, and a secret that has touched git history is a secret you rotate. If you start the stack with a variable missing, Compose warns loudly and continues with an empty string — which for a Postgres password means a broken deployment:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config prints the fully-interpolated file — the fastest way to check what the containers will actually receive; remember its output includes your secrets.
depends_on waits for nothing — unless you add a healthcheck
A bare depends_on: [db] controls start order only: Compose launches Postgres first and the app a moment later, while Postgres is still seconds away from accepting connections. The app hits the database, fails, and crashes or retries depending on how well it was written.
The reliable version is what the file above uses: the db service defines a healthcheck (Postgres ships pg_isready for exactly this), and the app declares depends_on with condition: service_healthy. Compose starts the database, polls the check every 10 seconds, and only starts Miniflux once the check passes. If the database never becomes healthy — bad password, corrupt volume — the app never starts and Compose tells you which dependency failed:
dependency failed to start: container miniflux-db-1 is unhealthyThat message points you at docker compose logs db, which is where the real error lives.
restart: unless-stopped
restart: unless-stopped on both services means the containers come back after a crash and after a VPS reboot, but stay down if you deliberately ran docker compose stop. The alternative always resurrects containers even after a manual stop — rarely what you meant. Without a restart policy, a kernel-update reboot at 4 a.m. quietly takes your services down until you notice.
Los comandos diarios
Todo el trabajo diario se realiza con 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 networkup -d es seguro para ejecutar repetidamente; compara el archivo con el estado actual y solo modifica los servicios cuya configuración o imagen haya cambiado. El par de actualización descarga lo que apunten sus etiquetas fijas (pinned tags): versiones de parche bajo postgres:16-alpine, nada para un pin exacto hasta que se edite, que es el objetivo. Las imágenes antiguas se acumulan tras las actualizaciones; libere espacio en disco con docker image prune -f.
Ahora el comando destructivo, advertencia importante: docker compose down es seguro; los contenedores y la red son desechables, y sus datos residen en el volumen. docker compose down -v también elimina los volúmenes con nombre. Eso es su base de datos, eliminada instantáneamente, sin confirmación y sin opción de deshacer. El flag -v existe para desmantelar experimentos; en un stack con datos reales, trátelo con el mismo cuidado que rm -rf. No existe papelera en /var/lib/docker/volumes/.
Para una shell única dentro de un contenedor en ejecución: docker compose exec db psql -U miniflux le conecta a la base de datos, y docker compose exec miniflux sh le proporciona una shell en la aplicación.
Dónde reside realmente su información
Los volúmenes con nombre reciben el prefijo del proyecto; por lo tanto, db-data en un directorio llamado miniflux se convierte en miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataLa 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 al usuario root, reside en el sistema de archivos del host y persiste tras down, actualizaciones y reconstrucciones de contenedores. Es exactamente lo que sus copias de seguridad deben capturar.
Realizar una copia de seguridad de un volumen con nombre
El patrón estándar consiste en usar un contenedor temporal que monte el volumen en modo solo lectura junto a un directorio del host, y luego aplicar un tar:
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 instalación, no deja procesos ejecutándose y la restauración es el proceso inverso — tar xzf en un volumen nuevo y vacío con los mismos montajes invertidos.
Una advertencia para bases de datos: realizar un tar de un directorio de datos de Postgres en ejecución puede capturar un estado de escritura incompleto que impedirá el inicio limpio. Use docker compose stop durante los segundos que dure el tar o, preferiblemente, realice un volcado lógico, que es consistente por diseño:
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gzEl flag -T desactiva el pseudo-terminal que Compose asigna por defecto; redirigir la salida del volcado a través de un TTY puede corromperla. Configure una de estas tareas en cron y copie el resultado fuera del VPS; una copia de seguridad en el mismo disco que los datos que protege es solo una copia, no un respaldo. La guía de Nextcloud establece una rutina programada completa basada precisamente en estos dos patrones.
Modos de fallo y los mensajes que verá
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — no pertenece al grupo docker o la sesión actual es anterior a la asignación del grupo. id muestra sus grupos efectivos; newgrp docker corrige la shell actual; cerrar sesión e iniciar una nueva corrige todos los grupos.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — problema distinto: el daemon está detenido. sudo systemctl status docker y sudo journalctl -u docker -n 50 indican la causa. En un VPS, la causa común es el disco lleno — revise df -h /var/lib/docker primero.
Bind for 127.0.0.1:8080 failed: port is already allocated — otro contenedor ya tiene publicado ese puerto del host. docker ps muestra cuál; un contenedor residual de un docker run experimental de hace semanas suele ser el culpable. Si docker ps está limpio, un proceso ajeno a Docker retiene el puerto: sudo ss -tlnp | grep 8080 lo identifica.
yaml: line 14: did not find expected key — error de indentación en la línea indicada o justo encima. Los archivos Compose son YAML: use indentación de dos espacios, use solo espacios y evite el uso de tabuladores. docker compose config valida el archivo sin iniciar servicios; ejecútelo tras cada edición.
El error de ufw no muestra ningún error, lo cual es peligroso: el despliegue funciona, ufw status parece correcto, pero un escaneo de puertos externo accede a su base de datos. Relea la sección de puertos anterior, verifique que cada entrada en ports: tenga el prefijo 127.0.0.1: y confirme desde una máquina distinta con curl http://your-vps-ip:8080 — el resultado esperado es connection refused.
A partir de aquí, la guía de Traefik convierte este stack único en múltiples aplicaciones tras un único punto de entrada HTTPS, y qué vale la pena self-hostear en 2026 es la lista de aplicaciones para ejecutar a través de él.
Un servidor de juegos como un servidor de Minecraft en un VPS es un proyecto de Compose ideal para practicar.
FAQ
Why do I get "permission denied while trying to connect to the Docker daemon socket"?
Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.
Does docker compose down delete my data?
Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.
What is the difference between docker-compose and docker compose?
docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.
Why can I reach my Docker container from the internet even though ufw blocks the port?
Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.
Should I use a named volume or a bind mount?
Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.