Como implantar aplicações com Docker Compose em um VPS
Guia prático de produção do Docker Compose v2 no Ubuntu: instalar o engine, escrever um arquivo Compose com redes e volumes, cuidar de segredos, colocar um proxy reverso na frente, atualizar sem pânico e fazer backup dos volumes nomeados.

Docker Compose é o jeito usual de rodar uma pilha pequena num VPS: um app web, um banco, um cache Redis e um proxy reverso, descritos num arquivo YAML. Não é Kubernetes. Você não vai ter failover multi-nó. Vai ter um setup repetível e revisável, que recria em minutos num VPS Hiddence novo — exatamente o que a maioria dos side projects e produtos pequenos precisa.
Este artigo assume que você quer algo mais perto de produção do que um docker run numa sexta à noite. Vamos instalar o Docker Engine e o plugin Compose, criar um usuário de deploy sem root, escrever um arquivo Compose com healthchecks e políticas de restart, manter os segredos fora do Git, publicar na internet só as portas do proxy reverso e definir uma rotina de atualização e backup. A pilha de exemplo é um app web típico mais PostgreSQL mais Caddy, mas o mesmo padrão vale para Node, PHP, Python ou um bando de workers.
Por que Compose num VPS só ainda faz sentido
O pessoal pula para Kubernetes porque está na moda e depois passa o fim de semana em YAML para um site. O Compose continua legível. Você versiona o arquivo, documenta variáveis de ambiente e consegue fazer diff da mudança antes de aplicar. O isolamento basta: um container do app comprometido não deveria segurar o socket UNIX do banco se você usou redes e usuários com o mínimo de privilégio. Limites de recurso impedem que um vazamento de memória congele o VPS inteiro.
- Um arquivo descreve a pilha inteira
- Volumes nomeados sobrevivem à recriação de containers
- Redes internas do Docker mantêm o Postgres fora da internet pública
- restart: unless-stopped cobre a maioria dos reboots
- Fácil de copiar para um segundo VPS quando o primeiro fica apertado
Requisitos
Use um VPS com RAM suficiente para o app mais o Postgres. Um plano de 2 GB é um mínimo realista para app + banco + proxy. Assume-se Ubuntu 24.04. Não instale o Docker de um Snap qualquer sem ler o que ele faz com cgroups; os passos abaixo usam o repositório apt oficial da Docker.
- Ubuntu 22.04/24.04, root ou sudo
- 2 GB de RAM recomendados (1 GB só para pilhas minúsculas sem Postgres)
- Um domínio apontando para o VPS se você quiser HTTPS automático
- Git ou outro jeito de copiar o projeto para o servidor
Passo 1: Instalar o Docker Engine e o Compose v2
O Compose v2 é um plugin invocado como docker compose (com um espaço), não o binário Python antigo docker-compose. Instale os dois a partir do repositório da Docker para receber correções de segurança atuais.
ssh root@YOUR_VPS_IP
apt update && apt -y install ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
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" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker version
docker compose versionPasso 2: Usuário de deploy e layout de diretórios
Rodar Compose como root funciona, mas um usuário dedicado no grupo docker é mais limpo. Coloque o projeto em /opt ou /srv, não em /root, para backups e permissões ficarem óbvios. Nunca deixe com escrita mundial o diretório que contém .env.
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
mkdir -p /srv/app
chown deploy:deploy /srv/app
chmod 750 /srv/app
# Log in as deploy for the rest of the file editing:
# su - deploy
# cd /srv/appPasso 3: Escrever um arquivo Compose pensado para produção
O arquivo abaixo é um modelo. Troque a imagem do app pela sua. O Postgres não é publicado em 0.0.0.0:5432 — só o Caddy sai nas 80 e 443. Healthchecks impedem que um proxy reverso mande tráfego para um app que ainda está migrando o banco. Fixe as tags de imagem; latest é como surgem as quebras de surpresa.
# /srv/app/compose.yaml
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
app:
condition: service_healthy
app:
image: ghcr.io/example/webapp:1.4.2
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 10
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- backend
networks:
frontend: {}
backend: {}
volumes:
pgdata:
caddy_data:
caddy_config:Passo 4: Segredos, .env e Caddyfile
Coloque senhas em .env no servidor, chmod 600, e nunca faça commit desse arquivo. Gere senhas com openssl rand -base64 32. O Caddyfile só precisa fazer proxy reverso para o nome de serviço do app na rede Docker.
# /srv/app/.env (permissions 600)
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-long-random
POSTGRES_DB=app
# plus any APP_SECRET / NEXTAUTH_SECRET your image needs
# /srv/app/Caddyfile
app.example.com {
encode gzip
reverse_proxy app:8000
}
chmod 600 /srv/app/.env
cd /srv/app
docker compose up -d
docker compose ps
docker compose logs -f --tail=100Passo 5: Firewall, logs e limites de recurso
O UFW deve liberar só 22, 80 e 443. O Docker às vezes contorna o UFW nas portas publicadas; se você precisa de um firewall de host rígido, consulte a interação atual Docker + UFW da sua versão do Ubuntu ou publique portas só em 127.0.0.1 e coloque um Caddy de host na frente. Defina limites de memória no Compose para o Postgres não comer o VPS.
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Optional in compose.yaml under app or db:
# deploy:
# resources:
# limits:
# memory: 512M
# Logs (do not let json-file grow forever):
# { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
# in /etc/docker/daemon.json then: systemctl restart dockerPasso 6: Atualizações e rollbacks
A atualização chata que funciona: puxar imagens novas, up -d, olhar os healthchecks, guardar a tag anterior no git para poder voltar a fixá-la. Não rode docker compose down num banco de produção a menos que você queira parar de aceitar tráfego. down -v apaga volumes — isso é wipe, não restart.
cd /srv/app
git pull # if the Compose file lives in git
# Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker compose ps
# Rollback: set the old tag, pull, up -d again
# NEVER: docker compose down -v # this deletes named volumesFazer backup dos volumes nomeados
Um snapshot do VPS é bom. Um dump lógico do Postgres é melhor se você precisa restaurar em outra máquina. Agende isso com cron. Teste um restore uma vez; senão você não tem backups — tem arquivos que espera que sejam backups.
# Postgres dump while the db container is running:
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gz
# Copy off-box
# scp /var/backups/app-*.sql.gz backup-host:~
# Restore sketch (maintenance window):
# gunzip -c app-2026-08-19.sql.gz | docker compose exec -T db psql -U app appSolução de problemas
Leia docker compose ps e docker compose logs service. Falhas no pull de imagem costumam ser limite de taxa do ghcr/Docker Hub ou uma imagem privada sem login. Um 502 do Caddy significa que o app não está healthy ou que o hostname do proxy está errado (use o nome de serviço do Compose, não localhost, de dentro do container Caddy). Erros de permissão em volumes muitas vezes significam que a imagem roda como uid 1000 enquanto o diretório do host é do root.
- compose: command not found — você instalou o nome antigo do binário; use docker compose
- port is already allocated — outra coisa é dona de 80/443 (Apache, Nginx, outro Caddy)
- database connection refused — o app subiu antes de o Postgres ficar pronto; use healthcheck + condição depends_on
- disk full — docker system df, depois prune cuidadoso de imagens não usadas
- permission denied on docker.sock — usuário fora do grupo docker, ou você precisa de uma nova sessão de login
Segurança
Não publique portas de banco. Não use privileged: true. Não monte /var/run/docker.sock num container de app a menos que você esteja escrevendo de propósito um gerenciador de containers. Mantenha o engine atualizado. Escaneie as suas próprias imagens se você as constrói. .env no disco continua sendo um arquivo secreto — restrinja quem pode entrar por SSH na máquina.
- Sem 5432 / 3306 / 6379 públicos
- Fixe digests de imagem ou pelo menos tags imutáveis
- chmod 600 .env
- Unattended-upgrades do SO host ainda importam
- Um projeto Compose por app reduz o raio de explosão
Dicas
- Coloque compose.yaml no git; mantenha .env.example sem segredos reais
- Use profiles para workers opcionais (docker compose --profile workers up -d)
- Olhe docker stats quando for dimensionar o VPS
- Bind mounts de código-fonte não são o padrão de produção — faça o bake de uma imagem
- Se a pilha crescer para vários VPS, aí sim olhe orquestração — não antes
Um setup Compose quase de produção num VPS é: pacotes oficiais Docker, um .env travado, um YAML que não publica o banco, healthchecks, um proxy reverso nas 80/443, UFW, tags de imagem para as quais você pode voltar e dumps Postgres que você realmente restaurou uma vez. Comece pelo modelo deste artigo, troque a imagem do app e trate docker compose down -v como um comando destrutivo, com o mesmo respeito de rm -rf.