Docker Compose no VPS: guia básico para Ubuntu 24.04
Instale Docker Engine e Compose v2 no Ubuntu 24.04, crie uma stack Miniflux com PostgreSQL, evite a armadilha do ufw e faça backup dos volumes.
O que você vai criar
O Docker Compose é a base de quase tudo o resto neste site. Nextcloud, Vaultwarden, n8n, Immich, Rocket.Chat: todos esses guias começam por «escreva este ficheiro Compose». Esta página explica o que esse ficheiro realmente significa. Você vai instalar o Docker Engine e o plugin Compose v2 a partir do repositório apt oficial do Docker no Ubuntu 24.04. Em seguida, vai iniciar uma stack real com dois serviços: o Miniflux, um pequeno leitor de RSS, e o PostgreSQL. Essa combinação exercita todos os padrões usados pelas aplicações maiores: imagens fixadas, uma base de dados com healthcheck, um volume nomeado, secrets num ficheiro .env e uma porta publicada apenas para localhost.
A instalação demora cinco minutos. O restante deste guia aborda os problemas que causam dificuldades mais tarde: o grupo docker ser root com outro nome, as portas publicadas ignorarem diretamente as regras do ufw e a única flag em docker compose down que elimina a sua base de dados sem pedir confirmação.
Pré-requisitos: uma KVM VPS Ubuntu 24.04 nova, um utilizador com sudo e pelo menos 1 gigabyte de RAM. Também pode utilizar uma instalação existente do Docker; a primeira secção explica o que deve remover.
Instale a partir do repositório do Docker, não do Ubuntu
Recuse dois caminhos incorretos antes do primeiro comando. O pacote docker.io do próprio Ubuntu funciona, mas fica atrás dos lançamentos do Docker e não inclui a estrutura de plugins assumida pelo restante da documentação. O binário autónomo docker-compose, com hífen, é o Compose v1: baseado em Python, fora de suporte desde 2023 e responsável por muitos tutoriais antigos deixarem de funcionar. Atualmente, o Compose é docker compose com um espaço, um plugin da CLI instalado a partir do mesmo repositório do engine.
Se algum desses componentes já estiver instalado no servidor, remova-o primeiro, incluindo docker-compose-v2, o pacote do plugin fornecido 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 runcPackage 'docker.io' is not installed, so not removed é o resultado normal numa VPS nova. Em seguida, adicione o repositório do Docker e instale os pacotes:
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 as três camadas:
docker --version
docker compose version
sudo docker run --rm hello-worldOs dois primeiros comandos mostram as versões instaladas. Docker Compose version v2.x.x confirma que tem o plugin, e não o binário v1 obsoleto. A execução de hello-world deve terminar com Hello from Docker!. O pacote ativa o serviço no arranque; systemctl is-enabled docker mostra enabled.
O grupo docker equivale a root; decida de forma consciente
Neste momento, todos os comandos docker precisam de sudo, porque o socket do daemon em /var/run/docker.sock pertence a root e ao grupo docker. Sem ser membro desse grupo, verá o erro do Docker mais pesquisado:
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sockA correção padrão:
sudo usermod -aG docker $USERA associação ao grupo só é aplicada no início da sessão, por isso o erro persiste na shell atual. Execute newgrp docker nesta sessão ou termine a sessão e volte a iniciar; depois, id deverá listar docker entre os seus grupos.
Agora, a parte importante, de forma direta: ser membro do grupo docker equivale a ter root no host. Não é "quase root", nem acesso "elevado": é root. Qualquer pessoa nesse grupo pode executar docker run --rm -it -v /:/host alpine chroot /host e assumir o controlo de todo o sistema de ficheiros, sem ser solicitada uma palavra-passe. O grupo existe por conveniência, não para contenção.
O modo rootless do Docker é a alternativa real: o próprio daemon é executado pelo seu utilizador sem privilégios. Há custos: as portas abaixo de 1024 precisam de configuração adicional, a rede passa por um shim em userspace com sobrecarga mensurável e algumas imagens comportam-se de forma incorreta sem root real. Num VPS com um único administrador, em que o único login já tem sudo, a alteração do grupo não muda nada na prática. É essa a configuração assumida por todos os guias aqui, mas nunca atribua este acesso como se fosse menos privilegiado do que sudo.
Anatomia de um ficheiro Compose
Dê a cada stack o seu próprio diretório. O nome do diretório torna-se o nome do projeto e passa a prefixar os containers, as redes e os volumes:
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxCrie compose.yml (o nome moderno; docker-compose.yml continua a funcionar). Não use a chave antiga version:. Está obsoleta e o Compose emite um aviso quando a encontra.
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 representa uma decisão. Analise-as uma de cada vez.
Fixe as versões das imagens; :latest com um pull é uma atualização não supervisionada
postgres:16-alpine, não postgres:latest. Uma tag não é fixa: :latest é resolvida novamente para o que o maintainer publicou mais recentemente sempre que fizer pull. Se combinar isso com o procedimento de atualização que vai aprender agora, docker compose pull && docker compose up -d, :latest significa que os saltos de versão principal acontecem quando o upstream os publica, não quando você decide. Com PostgreSQL, isto não é uma hipótese: um salto inesperado de 16 para 17 deixa o container num ciclo contínuo de falhas com um diretório de dados incompatível, porque as atualizações de versão principal do Postgres exigem dump e restore, não um restart.
Fixe pelo menos a versão principal (postgres:16-alpine acompanha as versões de correção 16.x) e fixe as aplicações numa release exata, como miniflux/miniflux:2.2.9. Consulte a página de releases do projeto e use a versão atual no momento em que escrever o ficheiro. Assim, uma atualização passa a ser uma alteração de uma linha feita deliberadamente e visível em git diff.
Publique em 127.0.0.1, porque o Docker contorna 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 é uma abreviatura de 0.0.0.0:8080:8080: escutar em todas as interfaces, incluindo a interface pública.
Este é o problema, e quase toda a gente se depara com ele pelo menos uma vez. O Docker publica uma porta escrevendo uma regra DNAT que altera o destino do pacote para o IP interno do container antes da filtragem. Assim, o pacote segue o caminho FORWARD e nunca passa por INPUT, onde estão as regras do ufw. sudo ufw deny 8080 indica sucesso, ufw status mostra a porta como bloqueada e o serviço continua a responder à Internet inteira. A firewall não está avariada; está a ser contornada deliberadamente. Porque o Docker contorna o ufw e como filtrar realmente o tráfego dos containers explica o mecanismo e a correção DOCKER-USER para portas que têm de permanecer públicas.
O procedimento que elimina o problema: associe as portas publicadas a 127.0.0.1, salvo quando tiver uma razão específica para fazer diferente, e coloque um reverse proxy à frente de tudo o que deva ficar acessível ao exterior. É exatamente isso que o guia do reverse proxy Traefik configura como passo seguinte a esta página: um container que fica responsável pelas portas 80 e 443 e encaminha o tráfego para os restantes serviços pelo hostname, com TLS. (Se vem de uma configuração antiga do Traefik v2, o guia de migração do Traefik v2 para o v3 explica as alterações de nomes e regras.)
Confirme a associação depois de iniciar a stack: sudo ss -tlnp | grep 8080 deve mostrar 127.0.0.1:8080, e não 0.0.0.0:8080 nem *:8080.
Volumes nomeados e bind mounts
db-data:/var/lib/postgresql/data é um volume nomeado: o Docker cria e gere um diretório em /var/lib/docker/volumes/ e monta-o no container. A alternativa é um bind mount, ./data:/var/lib/postgresql/data, que associa um caminho escolhido por si no host.
A separação que funciona na prática é esta: use volumes nomeados para dados que apenas os containers alteram, sobretudo bases de dados, porque o Docker inicializa o volume com o proprietário esperado pela imagem e as permissões funcionam sem ajustes. Use bind mounts para ficheiros que altera no host: ficheiros de configuração que edita num editor de texto, uma biblioteca multimédia para a qual faz rsync e qualquer conteúdo cujo caminho queira conhecer claramente. O problema clássico dos bind mounts são as permissões: o container é executado com o UID 999, o diretório no host pertence ao UID 1000 e a aplicação termina durante o arranque com permission denied nos logs. Os volumes nomeados fazem este tipo de problema praticamente desaparecer, mas os dados ficam num caminho gerido pelo Docker, explicado abaixo.
environment e .env: mantenha os segredos fora do git
${POSTGRES_PASSWORD} não é lido da sua shell. O Compose faz a interpolação a partir de um ficheiro chamado .env, localizado junto 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" >> .gitignoreGere valores reais com openssl rand -hex 24. Use hexadecimal, não base64, de propósito: esta password fica dentro da string de ligação DATABASE_URL e os caracteres /, + e = produzidos pelo base64 quebram a análise do URL. A falha aparece como um erro de autenticação, não como um erro de sintaxe, e pode consumir uma noite inteira. A linha .gitignore deve ser adicionada antes do primeiro commit: o ficheiro Compose pode ser publicado e versionado com segurança; o ficheiro .env nunca deve ser publicado, e um segredo que entrou no histórico do git é um segredo que deve ser substituído. Se iniciar a stack com uma variável em falta, o Compose emite um aviso claro e continua com uma string vazia. No caso da password do Postgres, isso resulta numa implementação avariada:
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose config apresenta o ficheiro totalmente interpolado. É a forma mais rápida de confirmar o que os containers vão realmente receber. Lembre-se de que a saída inclui os seus segredos.
depends_on não espera por nada, a menos que adicione um healthcheck
Um depends_on: [db] simples controla apenas a ordem de arranque: o Compose inicia primeiro o Postgres e a aplicação um momento depois, enquanto o Postgres ainda precisa de vários segundos para aceitar ligações. A aplicação tenta aceder à base de dados, falha e termina ou repete a tentativa, dependendo da qualidade da implementação.
A versão fiável é a usada no ficheiro acima: o serviço db define um healthcheck (o Postgres inclui pg_isready precisamente para este fim) e a aplicação declara depends_on com condition: service_healthy. O Compose inicia a base de dados, verifica o teste a cada 10 segundos e só inicia o Miniflux quando o teste passa. Se a base de dados nunca ficar saudável, por exemplo devido a uma password incorreta ou a um volume corrompido, a aplicação não inicia e o Compose informa qual a dependência que falhou:
dependency failed to start: container miniflux-db-1 is unhealthyEssa mensagem direciona-o para docker compose logs db, onde está o erro real.
restart: unless-stopped
restart: unless-stopped em ambos os serviços significa que os containers voltam a iniciar depois de uma falha e depois de um reboot do VPS, mas permanecem parados se executar deliberadamente docker compose stop. A alternativa always volta a iniciar os containers mesmo depois de uma paragem manual, o que raramente corresponde ao que pretendia. Sem uma política de restart, um reboot às 4 da manhã causado por uma atualização do kernel deixa silenciosamente os serviços indisponíveis até reparar no problema.
Os comandos do dia a dia
As tarefas diárias resumem-se a 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 networkup -d pode ser executado repetidamente com segurança. Compara o ficheiro com o estado real e só altera os serviços cuja configuração ou imagem tenha mudado. O par de comandos de atualização obtém o que as tags fixadas apontam atualmente: versões de correção sob postgres:16-alpine; para uma versão fixada exatamente, não obtém nada até editar essa versão, que é precisamente o objetivo. As imagens antigas acumulam-se depois das atualizações. Recupere espaço em disco com docker image prune -f.
Agora, o comando destrutivo, com toda a clareza: docker compose down é seguro, porque os contentores e a rede são descartáveis, e os seus dados estão no volume. docker compose down -v também elimina os volumes nomeados. Isso elimina a sua base de dados instantaneamente, sem qualquer pedido de confirmação e sem possibilidade de desfazer a operação. A opção -v existe para desmontar experiências. Numa stack que contenha dados reais, trate-a como trata rm -rf. Não existe uma reciclagem sob /var/lib/docker/volumes/.
Para abrir uma shell única dentro de um contentor em execução: docker compose exec db psql -U miniflux abre uma sessão na base de dados e docker compose exec miniflux sh abre uma shell na aplicação.
Onde os seus dados estão realmente armazenados
Os volumes nomeados recebem o prefixo do projeto. Por isso, db-data num diretório chamado miniflux torna-se miniflux_db-data:
docker volume ls
docker volume inspect miniflux_db-dataA saída de inspect inclui a linha relevante:
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"Esse diretório contém a base de dados, pertence a root no sistema de ficheiros do host e sobrevive a down, atualizações e recriações do contentor. É exatamente esse diretório que as suas cópias de segurança têm de incluir.
Fazer backup de um volume nomeado
O padrão habitual é usar um contentor descartável que monte o volume em modo só de leitura junto de um diretório do host e crie um tar entre os dois:
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 .Não é necessária nenhuma instalação e não fica nada em execução. A restauração é a operação inversa: tar xzf para um volume vazio novo, invertendo as mesmas montagens.
Existe uma ressalva para bases de dados: criar um tar de um diretório de dados do Postgres em execução pode capturar um estado a meio de uma escrita, que pode não iniciar corretamente. Use docker compose stop durante os segundos necessários para criar o tar ou, melhor ainda, 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.gzA opção -T desativa o pseudo-terminal que o Compose aloca por padrão. Encaminhar a saída do dump através de um TTY pode corrompê-la. Coloque uma destas opções no cron e copie o resultado para fora do VPS. Um backup no mesmo disco dos dados que protege é uma cópia, não um backup. O guia do Nextcloud cria uma rotina agendada completa com base exatamente nestes dois padrões.
Modos de falha e as mensagens que verá
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, ainda não está no grupo docker ou a sessão é anterior à alteração. id mostra os seus grupos efetivos; newgrp docker corrige a shell atual. Terminar a sessão e iniciar sessão novamente corrige todas as shells.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?, é outro problema: o daemon está parado. sudo systemctl status docker e sudo journalctl -u docker -n 50 indicam a causa. Numa VPS, a causa clássica é o disco cheio; execute primeiro df -h /var/lib/docker.
Bind for 127.0.0.1:8080 failed: port is already allocated, outro contentor já publicou essa porta do host. docker ps mostra qual; um contentor antigo de um docker run experimental de há algumas semanas é normalmente o culpado. Se docker ps não devolver resultados, um processo que não pertence ao Docker está a ocupar a porta: sudo ss -tlnp | grep 8080 identifica-o.
yaml: line 14: did not find expected key, existe um erro de indentação na linha indicada ou imediatamente acima dela. Os ficheiros Compose são YAML: use indentação de dois espaços, apenas espaços, e nenhum caráter de tabulação. Uma tabulação em qualquer ponto causa uma falha. docker compose config valida o ficheiro sem iniciar nada. Executá-lo depois de cada alteração é uma prática simples e útil.
A surpresa do ufw não apresenta qualquer erro. É isso que a torna perigosa: a implementação funciona, ufw status parece correto e uma análise de portas a partir do exterior encontra a sua base de dados. Reveja a secção de portas acima, verifique cada entrada ports: para confirmar se não falta o prefixo 127.0.0.1: e confirme a partir de uma máquina diferente com curl http://your-vps-ip:8080. A resposta que pretende é connection refused.
A partir daqui, o guia do Traefik transforma esta stack única em várias aplicações atrás de um único ponto de entrada HTTPS, e o que vale a pena alojar por conta própria em 2026 é a lista de serviços a implementar através dela. Quando várias dessas stacks estiverem em execução e cada uma tiver criado o seu próprio formulário de início de sessão, um servidor SSO alojado por conta própria, como o Authentik reúne-as novamente numa única conta atrás do mesmo proxy.
Um servidor de jogos, como um servidor Minecraft numa VPS, é um bom primeiro projeto Compose para praticar. Se preferir aprender com algo que abre todos os dias, o openGym, um gestor de treinos autoalojado, é uma stack fixada numa tag Git, e não numa tag de imagem. Este serviço requer TLS à frente antes de registar a primeira passkey. As fotografias são normalmente o primeiro tipo de dados que se quer retirar da cloud de terceiros, e a comparação entre PhotoPrism e Immich ajuda a determinar a quantidade mínima de RAM e a rotina de backups que terá de manter antes de associar um volume a qualquer um dos dois. Quando dois serviços já não forem suficientes, instalar o AFFiNE como um workspace ao estilo do Notion aplica os mesmos padrões a quatro contentores e testa se as tags fixadas, os healthchecks e os volumes nomeados acima já se tornaram um hábito.
FAQ
Por que recebo "permission denied while trying to connect to the Docker daemon socket"?
O seu utilizador não está no grupo docker ou foi adicionado depois do início da sessão atual. A associação só se aplica no login. Execute sudo usermod -aG docker $USER e depois newgrp docker, ou termine a sessão e volte a iniciar sessão. Confirme com id. O grupo concede acesso equivalente a root ao host. Por isso, adicione apenas utilizadores a quem daria acesso sudo.
docker compose down elimina os meus dados?
O docker compose down simples não elimina os dados. Remove os contentores e a rede do projeto. Os volumes nomeados permanecem e o up -d seguinte volta a associá-los. O docker compose down -v é a forma destrutiva. Elimina os volumes nomeados, incluindo a base de dados, sem confirmação e sem possibilidade de anulação. Nunca execute -v numa stack com dados reais, a menos que tenha um backup verificado.
Qual é a diferença entre docker-compose e docker compose?
docker-compose (com hífen) é o Compose v1, um binário Python autónomo que chegou ao fim da vida útil em 2023 e não deve ser instalado em servidores novos. docker compose (com espaço) é o Compose v2, um plugin Go para a CLI do Docker, instalado como docker-compose-plugin a partir do repositório apt do Docker. Os comandos e o YAML são quase totalmente compatíveis. Por isso, quando um tutorial antigo indicar docker-compose up, escreva docker compose up.
Por que consigo aceder ao meu contentor Docker a partir da Internet, apesar de o ufw bloquear a porta?
O Docker publica as portas com regras DNAT na cadeia PREROUTING do iptables. Os pacotes reescritos seguem o caminho FORWARD através das próprias cadeias do Docker. Por isso, nunca chegam à cadeia INPUT, onde se aplicam as regras do ufw. Consequentemente, ufw deny 8080 não faz nada numa porta publicada por um contentor. Corrija a configuração na origem: publique em 127.0.0.1: e exponha os serviços através de um reverse proxy.
Devo usar um volume nomeado ou um bind mount?
Use volumes nomeados para dados que só o contentor utiliza, especialmente bases de dados. O Docker define a propriedade esperada pela imagem e as permissões funcionam sem ajustes adicionais. Use bind mounts para ficheiros que também gere a partir do host: configurações que edita, ficheiros multimédia que carrega e qualquer conteúdo cujo caminho queira manter explícito. Se um contentor falhar ao iniciar com permission denied num bind mount, a incompatibilidade entre o UID do host e o UID do contentor é o primeiro ponto a verificar.