💰 Escolhendo a VPS
Para um stack Postiz + Postgres + Redis + Nginx, o piso confortável é 2 vCPU / 4 GB RAM / 40 GB SSD. Abaixo disso, o build do Next e as filas do BullMQ derrubam o servidor sob carga moderada. A escolha campeã em custo-benefício é a Hetzner Cloud CX22 (Frankfurt/Helsinki) por ~€4/mês — rede de 1 Gbps, snapshots baratos, painel limpo.
Alternativas válidas: Contabo VPS S (mais RAM por euro, mas IO de disco oscila), OVH VPS Starter (DC em Beauharnois/Gravelines, bom para Brasil/Europa) e DigitalOcean Basic (mais caro, mas ecossistema maduro). Evite "VPS de R$ 9/mês" — overcommit cruel.
# Comparativo rápido (Mai/2026, valores aproximados)
# Provider | CPU | RAM | Disco | Tráfego | Preço/mês
# -------------|------|------|--------|---------|----------
# Hetzner CX22 | 2vC | 4GB | 40GB | 20TB | €4,15
# Contabo VPS S| 4vC | 8GB | 100GB | 32TB | €5,99
# OVH Starter | 2vC | 4GB | 80GB | ilim. | €6,00
# DO Basic | 2vC | 4GB | 80GB | 4TB | $24,00
# Antes de comprar, teste latência do seu lugar:
ping -c 5 nbg1-speed.hetzner.com # Nuremberg
ping -c 5 ash-speed.contabo.com # Ashburn
mtr --report --report-cycles 10 fra1-speed.hetzner.com
💡 Dica prática
Para usuário no Brasil, Hetzner Falkenstein/Helsinki dá ~210ms de RTT — ruim para painel, ótimo para cron job. Se latência interativa importa, OVH Beauharnois (~120ms) ou DigitalOcean São Paulo (~10ms, mas caro) ganham. Postiz roda agendamentos em background — latência alta no painel é tolerável.
Conceitos-chave
Núcleo virtual; geralmente metade de um core físico.
Provider vende mais recurso do que tem fisicamente.
Imagem do disco para restore rápido.
Tráfego de saída — cota mensal varia muito.
🔒 Setup inicial do Ubuntu
Servidor recém-criado é uma porta aberta. Antes de instalar qualquer coisa, faça os cinco hardenings básicos: usuário não-root, chave SSH (senha desligada), firewall ufw, fail2ban contra brute force, e unattended-upgrades para patches automáticos de segurança.
# 1. Criar usuário deploy não-root
adduser deploy
usermod -aG sudo deploy
# 2. Copiar chave SSH do root para deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
# 3. No SEU laptop, gerar chave e adicionar (se ainda não tem)
ssh-keygen -t ed25519 -C "deploy@mkblogs"
ssh-copy-id deploy@SEU.IP.DO.SERVIDOR
# 4. Desligar senha SSH e login root em /etc/ssh/sshd_config
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl reload ssh
Com SSH endurecido, levante o firewall e os daemons de proteção. A ordem importa: libere SSH antes de habilitar UFW, senão você se tranca para fora.
# 5. Firewall: SSH + HTTP + HTTPS
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH (ANTES de enable!)
sudo ufw allow 80/tcp # HTTP (certbot ACME)
sudo ufw allow 443/tcp # HTTPS
sudo ufw --force enable
sudo ufw status verbose
# 6. fail2ban contra brute force
sudo apt install -y fail2ban
sudo tee /etc/fail2ban/jail.local > /dev/null <<EOF
[sshd]
enabled = true
port = 22
maxretry = 3
bantime = 1h
findtime = 10m
EOF
sudo systemctl enable --now fail2ban
# 7. Patches automáticos de segurança
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
⚠️ Dica prática
ANTES de desligar PasswordAuthentication, abra uma segunda sessão SSH e confirme que ela está autenticada por chave. Se a chave não funciona e você fecha a primeira sessão, fica sem acesso e precisa do console web do provider para recuperar.
Conceitos-chave
Par público/privado; muito mais seguro que senha.
Frontend amigável do iptables/nftables.
Lê logs e banne IPs com tentativas falhas.
Reduzir superfície de ataque do sistema.
🐳 Instalando Docker + Compose
O docker.io do apt do Ubuntu vem velho e sem o plugin Compose v2. Use o script oficial de get.docker.com, que adiciona o repositório certo da Docker Inc. e instala Engine + Compose v2 em uma tacada.
# 1. Baixar e rodar script oficial
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
# 2. Adicionar usuário deploy ao grupo docker (sem sudo)
sudo usermod -aG docker deploy
# 3. Logout + login para o grupo aplicar (ou: newgrp docker)
exit
ssh deploy@SEU.IP.DO.SERVIDOR
# 4. Verificar instalação
docker --version
# Docker version 27.3.1, build ce12230
docker compose version
# Docker Compose version v2.29.7
# 5. Smoke test
docker run --rm hello-world
# Hello from Docker! ...
# 6. Habilitar auto-start no boot
sudo systemctl enable docker
🧠 Dica prática
Estar no grupo docker = ter root efetivo (qualquer um do grupo pode montar / dentro de um container). Restrinja o grupo a usuários de deploy e nunca adicione contas de serviço de terceiros nele.
Conceitos-chave
Daemon dockerd + CLI docker.
Plugin oficial em Go, sub-comando docker compose.
Acesso ao socket /var/run/docker.sock.
Gerencia o daemon e sobe Docker no boot.
📝 docker-compose.prod.yml
O Compose de produção difere do de dev em quatro pontos críticos: sem portas de DB expostas no host, restart: always, redes internas isoladas e limites de recursos. O Postiz fala com o Postgres apenas pela network interna; quem expõe ao mundo é o reverse proxy (próximo módulo).
# /home/deploy/postiz/docker-compose.prod.yml
services:
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
container_name: postiz
restart: always
env_file: .env.prod
expose:
- "5000" # interno, NÃO ports:
volumes:
- postiz-uploads:/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [postiz-net]
deploy:
resources:
limits: { cpus: '1.5', memory: 2G }
postgres:
image: postgres:16-alpine
container_name: postiz-postgres
restart: always
environment:
POSTGRES_USER: postiz
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postiz
volumes:
- postgres-data:/var/lib/postgresql/data
# Sem ports: — Postgres só acessível via postiz-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postiz -d postiz"]
interval: 10s
timeout: 5s
retries: 5
networks: [postiz-net]
redis:
image: redis:7-alpine
container_name: postiz-redis
restart: always
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 5
networks: [postiz-net]
volumes:
postgres-data:
postiz-uploads:
redis-data:
networks:
postiz-net:
driver: bridge
internal: false # postiz precisa de internet p/ APIs
✓ Prod faz assim
- ✓
exposeem vez deportspara serviços internos. - ✓
restart: alwayspara sobreviver a reboots. - ✓Imagem com tag fixa (
:v1.42.0) em prod, não:latest. - ✓Resource limits para evitar OOM derrubar o host.
✗ Dev faz, prod NÃO
- ✗
ports: "5432:5432"no Postgres (DB exposto na internet). - ✗Bind mount em
./datacom permissão errada. - ✗Sem healthcheck — Postiz aborta antes do DB subir.
- ✗
restart: no— primeiro crash derruba o serviço.
Conceitos-chave
Porta visível só para outros containers da network.
Publica porta no host — uso restrito ao proxy.
Teto de CPU/RAM evita um container matar o host.
Deploy reproduzível — :latest é roleta.
💾 Volumes persistentes
Container é efêmero — se você der docker compose down, tudo que estava no FS do container some. Volumes ficam fora desse ciclo: vivem em /var/lib/docker/volumes/ e sobrevivem a destruir e recriar containers.
Há duas opções: named volumes (gerenciados pelo Docker, escolha padrão) e bind mounts (mapeiam um caminho do host). Para dados de DB sempre use named volume — bind mounts dão dor de cabeça com UID/permissão.
📂 Quando usar cada um
- Named volume (
postgres-data:/var/lib/postgresql/data): dados de banco, filas, uploads de usuários. Docker cuida das permissões. - Bind mount (
./nginx.conf:/etc/nginx/nginx.conf:ro): arquivos de config que VOCÊ edita no host. Use modo:rosempre que possível. - tmpfs (
type: tmpfs): dados temporários sensíveis que devem morrer com o container. - Nunca: misturar bind mount em diretório de dados que o container escreve com UID diferente do dono do diretório no host.
# Listar volumes do projeto
docker volume ls --filter "name=postiz"
# Inspecionar um volume (onde mora no host)
docker volume inspect postiz_postgres-data | grep Mountpoint
# "Mountpoint": "/var/lib/docker/volumes/postiz_postgres-data/_data"
# Backup ad-hoc de um volume (tar comprimido com data)
docker run --rm \
-v postiz_postgres-data:/source:ro \
-v $(pwd)/backups:/backup \
alpine tar czf /backup/pg-$(date +%F).tar.gz -C /source .
# Restore (CUIDADO: destrói o destino)
docker run --rm \
-v postiz_postgres-data:/target \
-v $(pwd)/backups:/backup \
alpine sh -c "cd /target && tar xzf /backup/pg-2026-05-24.tar.gz"
# Paths críticos para backup em prod
# /var/lib/docker/volumes/postiz_postgres-data -> banco
# /var/lib/docker/volumes/postiz_postiz-uploads -> mídia dos posts
# /var/lib/docker/volumes/postiz_redis-data -> filas pendentes
# /home/deploy/postiz/.env.prod -> secrets
🗄️ Dica prática
Backup que não foi restaurado em um dry run não é backup, é torcida. Crie uma VPS pequena uma vez por mês e teste o restore completo. Se em 30 minutos você não tem o Postiz no ar a partir só dos tar.gz, seu plano de DR é fantasia.
Conceitos-chave
Docker gerencia, abstrai path e permissões.
Mapeia caminho do host direto no container.
Dados que sobrevivem ao ciclo do container.
Disaster recovery — restaurar do zero.
❤️ Restart policy e healthcheck
Em produção, dois mecanismos garantem que o stack se recupera sozinho: restart policy (Docker reinicia o container se ele cair) e healthcheck (Docker sabe se o processo dentro está realmente respondendo, não só "rodando").
Use restart: unless-stopped em quase tudo: reinicia em crash e no boot, mas respeita um stop manual seu. restart: always é mais agressivo (ignora stop manual após reboot) e útil só em componentes críticos como o reverse proxy.
# Healthcheck no Postiz com curl interno
services:
postiz:
image: ghcr.io/gitroomhq/postiz-app:latest
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
interval: 30s # frequência
timeout: 10s # cada check tem 10s p/ responder
retries: 3 # 3 falhas seguidas = unhealthy
start_period: 60s # graça inicial: ignora falhas no boot
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
O par depends_on + condition: service_healthy é o segredo de um boot determinístico: o Postiz só inicia depois que pg_isready e redis-cli ping respondem OK. Sem isso, no primeiro up a app crasha tentando rodar migrations em um Postgres que ainda está fazendo initdb.
# Inspecionar saúde de cada serviço
docker compose ps
# NAME STATUS PORTS
# postiz Up 2h (healthy) 5000/tcp
# postiz-postgres Up 2h (healthy)
# postiz-redis Up 2h (healthy)
# Ver últimos resultados do healthcheck (JSON)
docker inspect postiz --format '{{json .State.Health}}' | jq
# Forçar reinício de um serviço
docker compose restart postiz
# Acompanhar reinícios automáticos (quantas vezes caiu)
docker inspect postiz --format '{{.RestartCount}}'
📈 Dica prática
Se RestartCount passar de 5 em uma hora, alguma coisa está crashando em loop — restart policy só esconde o sintoma. Vá nos logs (docker compose logs --tail=200 postiz) e descubra o erro real antes que o disco encha de logs do próprio container reiniciando.
Conceitos-chave
Reinicia em crash, respeita stop manual.
Reinicia sempre, até depois de stop + reboot.
Graça inicial — evita falso negativo no boot.
Bloqueia boot até dependência estar saudável.
🎯 Resumo do Módulo
deploy, SSH só por chave, UFW liberando 22/80/443, fail2ban e unattended-upgrades ativos.get.docker.com, usuário no grupo docker, auto-start no boot.ports, restart always, network postiz-net isolada e resource limits.unless-stopped + depends_on: service_healthy garantem boot determinístico e auto-recovery.Próximo Módulo:
6.2 — Reverse proxy, HTTPS e domínio em produção