SSD Nodes Learn
Guias Matt ConnorPor Matt Connor · Atualizado 2026-07-24

Como instalar Docker Compose no Ubuntu 24.04

Guia para instalar Docker Engine e Compose v2 no Ubuntu 24.04. Aprenda a configurar compose.yml, evitar conflitos com ufw e fazer backup de volumes Docker.

O que você está construindo

O Docker Compose é a base para quase tudo neste site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat — todos esses guias começam com "escreva este arquivo compose", e esta é a página que explica o significado real desse arquivo. Você instalará o Docker Engine e o plugin Compose v2 a partir do repositório apt oficial da Docker no Ubuntu 24.04. Depois, você subirá uma stack real de dois serviços — Miniflux, um leitor RSS leve, mais o PostgreSQL — pois esse par utiliza todos os padrões usados por aplicativos maiores: imagens com tag fixa, um banco de dados com healthcheck, um volume nomeado, secrets em um arquivo .env e uma porta publicada apenas para o localhost.

A instalação leva cinco minutos. O restante deste guia aborda os problemas que surgem depois: o grupo docker possuindo privilégios de root, portas publicadas ignorando as regras do ufw e a flag no docker compose down que deleta seu banco de dados sem pedir confirmação.

Pré-requisitos: um VPS KVM com Ubuntu 24.04 recém-instalado, um usuário com sudo e pelo menos 1 GB de RAM. Uma instalação existente do Docker também funciona — a primeira seção explica o que deve ser removido.

Instale pelo repositório do Docker, não pelo do Ubuntu

Evite dois erros antes do primeiro comando. O pacote docker.io do próprio Ubuntu funciona, mas as versões são desatualizadas em relação ao Docker e o layout dos plugins é incompatível com o padrão do sistema. O binário docker-compose isolado — o que possui o hífen — é o Compose v1: baseado em Python e sem suporte desde 2023; este é o motivo de tutoriais antigos falharem. O Compose atual é docker compose (com espaço), um plugin de CLI instalado no mesmo repositório do engine.

Se algum desses itens já estiver instalado, remova-os primeiro — incluindo o docker-compose-v2, o empacotamento do plugin feito pelo Ubuntu, para que tudo venha de um único repositório:

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 é o output padrão em uma VPS nova. Em seguida, adicione o repositório do 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-plugin

Verifique as três camadas:

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

Os dois primeiros exibem as strings de versão — o Docker Compose version v2.x.x confirma que você tem o plugin, e não o binário obsoleto v1. O comando hello-world deve terminar com Hello from Docker!. O pacote habilita o serviço no boot; systemctl is-enabled docker exibe enabled.

O grupo docker é root — decida com cautela

Atualmente, cada comando docker exige sudo, pois o socket do daemon em /var/run/docker.sock pertence ao root e ao grupo docker. Sem o pertencimento ao grupo, você receberá o erro de Docker mais pesquisado:

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

A correção padrão:

sudo usermod -aG docker $USER

A associação ao grupo é aplicada no login, portanto o erro persiste no shell atual. Execute newgrp docker para esta sessão, ou faça logout e login novamente; o comando id deve então listar docker em seus grupos.

Agora a parte honesta, declarada de forma direta: o pertencimento ao grupo docker é root no host. Não é "quase root", nem "elevado" — é root. Qualquer usuário nesse grupo pode executar docker run --rm -it -v /:/host alpine chroot /host e assumir o controle de todo o filesystem, sem solicitar senha. O grupo existe por conveniência, não por isolamento.

O modo rootless do Docker é a alternativa real — o daemon roda como seu usuário sem privilégios. Isso exige compensações: portas abaixo de 1024 precisam de configuração extra, o networking roda através de um shim em userspace com overhead mensurável, e algumas imagens apresentam falhas sem root real. Em um VPS com apenas um administrador onde o único login já possui sudo, a alteração do grupo não muda nada na prática, e é isso que todos os guias aqui assumem — apenas nunca conceda esse acesso como se fosse menos que sudo.

Anatomia de um arquivo compose

Atribua um diretório próprio para cada stack — o nome do diretório torna-se o nome do projeto, que prefixa containers, redes e volumes:

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

Crie compose.yml (o nome moderno; docker-compose.yml ainda funciona). Ignore a chave version: antiga — ela é obsoleta e o Compose emitirá um aviso se encontrá-la.

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 linha acima é uma decisão. Tome-as uma por uma.

Fixe versões de imagem — :latest mais um pull é um upgrade não supervisionado

postgres:16-alpine, não postgres:latest. Uma tag não é congelada: :latest resolve novamente para o que o mantenedor publicou mais recentemente, toda vez que você executa o pull. Combine isso com o hábito de upgrade rotineiro que você aprenderá em seguida — docker compose pull && docker compose up -d — e :latest significa que saltos de versão major ocorrem quando o upstream os lança, não quando você escolhe. Com o PostgreSQL isso não é hipotético: um salto inesperado da 16 para a 17 deixa o container em crash-loop devido a um diretório de dados incompatível, pois upgrades major do Postgres exigem dump e restore, não apenas um restart.

Fixe pelo menos a versão major (postgres:16-alpine segue os patches de 16.x), e fixe aplicações em uma release exata como miniflux/miniflux:2.2.9 — verifique a página de releases do projeto e use o que estiver atual no momento em que escrever o arquivo. Um upgrade torna-se então uma edição de uma linha feita propositalmente, visível em git diff.

Publique em 127.0.0.1, porque o Docker ignora o ufw

"127.0.0.1:8080:8080" — endereço do host, porta do host, porta do container. A maioria dos tutoriais escreve "8080:8080", que é um atalho para 0.0.0.0:8080:8080: escutando em todas as interfaces, incluindo a pública.

Aqui está a armadilha, e ela atinge quase todos uma vez. O Docker publica uma porta escrevendo uma regra DNAT que reescreve o destino do pacote para o IP interno do container antes da filtragem, então o pacote segue o caminho FORWARD e nunca toca o INPUT, onde residem suas regras de ufw. O sudo ufw deny 8080 reporta sucesso, o ufw status mostra a porta negada, e o serviço continua respondendo para toda a internet. Seu firewall não está quebrado; ele está sendo contornado por design. Por que o Docker contorna o ufw, e como filtrar o tráfego do container de verdade detalha o mecanismo e a correção DOCKER-USER para portas que devem permanecer públicas.

O hábito que faz o problema desaparecer: vincule as portas publicadas ao 127.0.0.1, a menos que tenha um motivo específico para não fazê-lo, e coloque um reverse proxy à frente de qualquer coisa que deva estar exposta ao mundo. É exatamente isso que o guia do reverse proxy Traefik constrói como o próximo passo após esta página — um container que detém as portas 80 e 443 e roteia para todo o resto por hostname, com TLS. (Vindo de uma configuração antiga do Traefik v2? O guia de migração do Traefik v2 para v3 cobre as renomeações e mudanças de regras.)

Verifique o bind após iniciar a stack: sudo ss -tlnp | grep 8080 deve mostrar 127.0.0.1:8080, não 0.0.0.0:8080 ou *:8080.

Named volumes vs bind mounts

db-data:/var/lib/postgresql/data é um named volume: o Docker cria e gerencia um diretório sob /var/lib/docker/volumes/ e o monta no container. A alternativa é um bind mount, ./data:/var/lib/postgresql/data, que mapeia um caminho escolhido no host.

A divisão que funciona na prática: named volumes apenas para containers de dados — bancos de dados acima de tudo, já que o Docker inicializa o volume com o ownership que a imagem espera e as permissões de arquivo funcionam corretamente. Bind mounts para arquivos que você manipula do host — arquivos de configuração que você edita com um editor de texto, uma biblioteca de mídia que você sincroniza via rsync, qualquer coisa cujo caminho deva ser óbvio. A falha clássica de bind-mount é o ownership: o container roda como UID 999, o diretório do seu host pertence ao UID 1000, e o app morre na inicialização com permission denied em seus logs. Named volumes fazem esse tipo de bug desaparecer quase completamente, ao custo de os dados residirem em um caminho gerenciado pelo Docker — detalhado abaixo.

environment e .env — mantenha segredos fora do git

${POSTGRES_PASSWORD} não é lido do seu shell; o Compose o interpola de um arquivo chamado .env localizado ao lado de compose.yml. Crie-o:

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

Gere valores reais com openssl rand -hex 24. Hex, não base64, propositalmente: esta senha entra na string de conexão DATABASE_URL, e os caracteres /, + e = que o base64 produz quebram o parsing de URL — uma falha que aparece como erro de autenticação, não erro de sintaxe, e custa uma noite de trabalho. A linha .gitignore deve ir antes do primeiro commit: o arquivo compose é seguro para publicar e versionar, o arquivo .env nunca é, e um segredo que tocou o histórico do git é um segredo que você deve rotacionar. Se você iniciar a stack com uma variável ausente, o Compose emitirá um aviso alto e continuará com uma string vazia — o que para uma senha de Postgres significa um deployment quebrado:

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

docker compose config imprime o arquivo totalmente interpolado — a maneira mais rápida de verificar o que os containers realmente receberão; lembre-se que a saída inclui seus segredos.

depends_on não espera por nada — a menos que você adicione um healthcheck

Um depends_on: [db] simples controla apenas a ordem de inicialização: o Compose inicia o Postgres primeiro e o app um momento depois, enquanto o Postgres ainda está a segundos de aceitar conexões. O app tenta conectar ao banco, falha, e crasha ou tenta novamente dependendo de quão bem foi escrito.

A versão confiável é a que o arquivo acima utiliza: o serviço db define um healthcheck (o Postgres fornece pg_isready para exatamente isso), e o app declara depends_on com condition: service_healthy. O Compose inicia o banco de dados, verifica o status a cada 10 segundos e só inicia o Miniflux quando o check passa. Se o banco de dados nunca ficar saudável — senha errada, volume corrompido — o app nunca inicia e o Compose informa qual dependência falhou:

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

Essa mensagem aponta para o docker compose logs db, que é onde reside o erro real.

restart: unless-stopped

restart: unless-stopped em ambos os serviços significa que os containers retornam após um crash e após o reboot de uma VPS, mas permanecem desligados se você executou docker compose stop deliberadamente. A alternativa always ressuscita containers mesmo após um stop manual — raramente é o que você deseja. Sem uma política de restart, um reboot por atualização de kernel às 4 da manhã derruba seus serviços silenciosamente até que você perceba.

Os comandos diários

Tudo o que é feito no dia a dia consiste em cinco comandos, executados a partir do diretório do projeto.

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 é seguro para execução repetida — ele compara o arquivo com a realidade e só altera serviços cuja configuração ou imagem mudou. O par de upgrade busca o que as suas tags fixas apontam no momento: patches abaixo de postgres:16-alpine, nada para um pin exato até que você o edite — que é o objetivo. Imagens antigas acumulam após upgrades; libere espaço em disco com docker image prune -f.

Agora o comando destrutivo, avisado claramente: docker compose down é seguro — containers e a rede são descartáveis, e seus dados estão no volume. docker compose down -v deleta os volumes nomeados também. Isso apaga seu banco de dados, instantaneamente, sem prompt de confirmação e sem possibilidade de desfazer. A flag -v existe para encerrar experimentos; em um stack com dados reais, trate-a como você trata o rm -rf. Não existe lixeira no /var/lib/docker/volumes/.

Para um shell avulso dentro de um container em execução: docker compose exec db psql -U miniflux te joga para o banco de dados, e docker compose exec miniflux sh te dá um shell na aplicação.

Onde seus dados realmente residem

Volumes nomeados recebem o prefixo do projeto, portanto db-data em um diretório chamado miniflux torna-se miniflux_db-data:

docker volume ls
docker volume inspect miniflux_db-data

A saída do inspect inclui a linha relevante:

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

Esse diretório é o banco de dados — pertence ao root, está no filesystem do host e sobrevive a down, upgrades e rebuilds de containers. É exatamente isso que seus backups devem capturar.

Fazer backup de um volume nomeado

O padrão comum é usar um container temporário que monta o volume como read-only ao lado de um diretório do host, e então executa o 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 .

Sem instalações, sem processos rodando, e a restauração é o processo inverso — tar xzf para um volume novo e vazio com os mesmos mounts invertidos.

Uma ressalva para bancos de dados: fazer o tar de um diretório de dados do Postgres em execução pode capturar um estado de escrita incompleto, impedindo o boot limpo. Ou use docker compose stop durante os segundos do processo de tar ou — preferencialmente — faça um dump lógico, que é consistente por definição:

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

O -T desabilita o pseudo-terminal que o Compose aloca por padrão — enviar a saída do dump através de um TTY pode corrompê-lo. Configure um desses comandos no cron e copie o resultado para fora do VPS; um backup no mesmo disco que os dados protegidos é apenas uma cópia, não um backup. O guia do Nextcloud cria uma rotina agendada completa baseada exatamente nestes dois padrões.

Modos de falha e as strings que você verá

permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — você ainda não pertence ao grupo docker, ou você pertence, mas a sessão atual é anterior à alteração. id mostra seus grupos efetivos; newgrp docker corrige o shell atual; fazer logout e login novamente corrige todos eles.

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — problema diferente: o daemon está parado. sudo systemctl status docker e sudo journalctl -u docker -n 50 explicam o motivo. Em um VPS, a causa comum é disco cheio — verifique df -h /var/lib/docker primeiro.

Bind for 127.0.0.1:8080 failed: port is already allocated — outro container já publicou essa porta do host. docker ps mostra qual; um container antigo de um docker run experimental de semanas atrás é o culpado usual. Se docker ps estiver limpo, um processo fora do Docker detém a porta: sudo ss -tlnp | grep 8080 identifica o processo.

yaml: line 14: did not find expected key — erro de indentação na linha mencionada ou logo acima dela. Arquivos Compose são YAML: use indentação de dois espaços, apenas espaços; qualquer caractere tab é fatal. docker compose config valida o arquivo sem iniciar nada; rodar o comando após cada edição é uma boa prática.

O problema do ufw não exibe erro algum, o que o torna perigoso: o deploy funciona, ufw status parece correto, mas um scan de portas externo encontra seu banco de dados. Releia a seção de portas acima, verifique cada entrada ports: para garantir que não falta o prefixo 127.0.0.1:, e confirme a partir de uma outra máquina usando curl http://your-vps-ip:8080 — "connection refused" é a resposta desejada.

A partir daqui, o guia do Traefik transforma este stack único em vários apps atrás de um único ponto de entrada HTTPS, e o que vale a pena hospedar em 2026 é a lista de aplicações para rodar através dele.

Um servidor de jogo como um servidor de Minecraft em um VPS é um bom primeiro projeto Compose para praticar.

FAQ

Por que recebo "permission denied while trying to connect to the Docker daemon socket"?

Seu usuário não pertence ao grupo docker, ou foi adicionado após o início da sessão atual — a permissão só é aplicada no login. Execute sudo usermod -aG docker $USER, depois newgrp docker ou faça logout e login novamente, e confirme com id. O grupo concede acesso equivalente ao root no host; adicione apenas usuários aos quais você daria sudo.

O docker compose down deleta meus dados?

O comando docker compose down puro não deleta — ele remove containers e a rede do projeto; volumes nomeados sobrevivem e o próximo up -d os reconecta. O docker compose down -v é a forma destrutiva: ele deleta os volumes nomeados, o que significa que seu banco de dados será removido sem confirmação e sem possibilidade de desfazer. Nunca execute -v em um stack com dados reais sem possuir um backup verificado.

Qual é a diferença entre docker-compose e docker compose?

docker-compose (hífen) é o Compose v1, um binário Python independente que atingiu o fim de vida em 2023 e não deve ser instalado em novos servidores. docker compose (espaço) é o Compose v2, um plugin em Go para o Docker CLI, instalado como docker-compose-plugin via repositório apt do Docker. Comandos e YAML são quase totalmente compatíveis; quando um tutorial antigo indicar docker-compose up, digite docker compose up.

Por que consigo acessar meu container Docker pela internet mesmo com o ufw bloqueando a porta?

Porque o Docker publica portas usando regras DNAT na chain PREROUTING do iptables, e os pacotes reescritos seguem o caminho FORWARD através das próprias chains do Docker — eles nunca passam pela chain INPUT onde as regras do ufw se aplicam. Portanto, o ufw deny 8080 não tem efeito sobre uma porta de container publicada. Corrija na origem: publique para 127.0.0.1: e exponha os serviços através de um reverse proxy.

Devo usar um named volume ou um bind mount?

Use named volumes para dados que apenas o container manipula — especialmente bancos de dados, pois o Docker define o ownership esperado pela imagem e as permissões funcionam corretamente. Use bind mounts para arquivos que você também gerencia pelo host: arquivos de configuração que você edita, mídia que você faz upload, ou qualquer item cujo caminho precise ser óbvio. Se um container falhar na inicialização com permission denied em um bind mount, verifique primeiro a incompatibilidade de UID entre host e container.