Saber desenvolver é necessário, mas não suficiente. Um sistema que funciona perfeitamente no seu computador e nunca chega ao servidor de produção não tem valor. Deploy é o ato de levar o código do repositório para o servidor — e fazê-lo de forma segura, repetível e rastreável.
Configuração do servidor — NGINX + PHP-FPM
PHP-FPM (FastCGI Process Manager) é como o PHP roda em produção real: gerencia um pool de processos PHP separado do servidor web. NGINX recebe as requisições e as passa para o PHP-FPM processar:
# /etc/nginx/sites-available/minha-loja
server {
listen 80;
server_name minha-loja.com.br www.minha-loja.com.br;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl;
http2 on; # desde o nginx 1.25.1 é diretiva própria:
# "The parameter is deprecated, the http2
# directive should be used instead"
server_name minha-loja.com.br www.minha-loja.com.br;
root /var/www/minha-loja/public;
index index.php;
# SSL — gerado com Let's Encrypt / Certbot
ssl_certificate /etc/letsencrypt/live/minha-loja.com.br/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/minha-loja.com.br/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Confira SEMPRE o nome do cipher antes de colar: não existe nenhuma
# suite TLS terminada em SHA512 (`openssl ciphers -v ALL:COMPLEMENTOFALL`
# devolve zero). O nome certo do AES-256-GCM é SHA384, e uma lista sem
# nenhum nome válido faz o nginx recusar a configuração no boot.
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off; # com TLS 1.3 a escolha é do cliente
# Laravel: rota todas as requisições para index.php
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
# Bloqueia acesso direto a arquivos sensíveis
location ~ /\.(env|git|htaccess) {
deny all;
}
# Cache de assets estáticos
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Logs
access_log /var/log/nginx/minha-loja.access.log;
error_log /var/log/nginx/minha-loja.error.log;
}
PHP-FPM — configuração do pool
; /etc/php/8.3/fpm/pool.d/minha-loja.conf
[minha-loja]
user = www-data
group = www-data
listen = /var/run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
; Gerenciamento de processos
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500 ; reinicia o processo após 500 requisições (evita memory leaks)
; Timeout
request_terminate_timeout = 60s
; Logs de slowlog
slowlog = /var/log/php-fpm/minha-loja.slow.log
request_slowlog_timeout = 5s
Deploy com janela de manutenção — script completo
#!/bin/bash
# deploy.sh — executa no servidor de produção
#
# Este script NÃO é zero downtime, e vale dizer isso com todas as letras:
# ele começa com `artisan down` e só volta no `artisan up`, ou seja, a
# janela de indisponibilidade é o deploy inteiro. Para a maioria dos
# projetos isso é aceitável — são poucos segundos, a página de
# manutenção é honesta com o usuário e o `--secret` deixa você conferir
# o site antes de reabrir.
#
# Zero downtime de verdade exige outra arquitetura: cada deploy monta um
# diretório novo em `releases/`, e só no fim um symlink atômico aponta
# `current` para ele. A última questão dos exercícios monta esse caminho
# passo a passo, com rollback.
set -euo pipefail # para em qualquer erro
PROJETO_DIR="/var/www/minha-loja"
BRANCH="${1:-main}"
echo "🚀 Iniciando deploy — branch: $BRANCH"
cd $PROJETO_DIR
# 1. Ativa modo de manutenção (Laravel)
php artisan down --secret="token-secreto-de-bypass"
echo "✅ Modo de manutenção ativado"
# 2. Atualiza o código
git fetch origin
git reset --hard origin/$BRANCH
echo "✅ Código atualizado"
# 3. Instala dependências (sem dev, otimizado)
composer install --no-dev --optimize-autoloader --no-interaction
echo "✅ Dependências instaladas"
# 4. Roda migrations
php artisan migrate --force
echo "✅ Migrations executadas"
# 5. Otimizações do Laravel
php artisan config:cache # cache de configurações
php artisan route:cache # cache de rotas
php artisan view:cache # pre-compila templates Blade
php artisan event:cache # cache de event listeners
echo "✅ Cache otimizado"
# 6. Reinicia workers de fila (se houver)
php artisan queue:restart
echo "✅ Workers de fila reiniciados"
# 7. Recarrega PHP-FPM (graceful — sem derrubar requisições em andamento)
sudo systemctl reload php8.3-fpm
echo "✅ PHP-FPM recarregado"
# 8. Desativa modo de manutenção
php artisan up
echo "🎉 Deploy concluído com sucesso!"
GitHub Actions — CI/CD completo
# .github/workflows/deploy.yml
name: Testes e Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
testes:
name: Testes
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: teste
MYSQL_ROOT_PASSWORD: senha
ports: ['3306:3306']
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- name: Instalar PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, pdo, pdo_mysql, gd, redis
coverage: xdebug
- name: Cache do Composer
uses: actions/cache@v3
with:
path: vendor
key: composer-${{ hashFiles('composer.lock') }}
- name: Instalar dependências
run: composer install --no-interaction
- name: Auditoria de segurança
run: composer audit
- name: Preparar ambiente de teste
run: |
cp .env.testing .env
php artisan key:generate
php artisan migrate --force
- name: Análise estática
run: vendor/bin/phpstan analyse --no-progress
- name: Estilo de código
run: vendor/bin/php-cs-fixer fix --dry-run --diff
- name: Testes com cobertura
run: vendor/bin/pest --coverage --min=75
deploy:
name: Deploy em Produção
needs: testes
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVIDOR_HOST }}
username: ${{ secrets.SERVIDOR_USER }}
key: ${{ secrets.SERVIDOR_SSH_KEY }}
script: |
cd /var/www/minha-loja
bash deploy.sh main
Docker para desenvolvimento padronizado
# Dockerfile
FROM php:8.3-fpm-alpine
# Extensões necessárias
RUN docker-php-ext-install pdo pdo_mysql opcache
RUN apk add --no-cache libpng-dev && docker-php-ext-install gd
# OPcache — melhora performance significativamente em produção
COPY opcache.ini /usr/local/etc/php/conf.d/
# A imagem oficial `php:8.3-fpm-alpine` NÃO traz o Composer — o
# Dockerfile dela não o menciona uma vez sequer, e sem esta linha o
# build morre em `composer: not found`. Copiar o binário da imagem
# oficial do Composer é o caminho curto e versão-travável.
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY . .
RUN composer install --no-dev --optimize-autoloader
RUN chown -R www-data:www-data storage bootstrap/cache
# docker-compose.yml (desenvolvimento)
services:
app:
build: .
volumes:
- .:/var/www/html # monta código local
depends_on: [db, redis]
nginx:
image: nginx:alpine
ports: ['8000:80']
volumes:
- .:/var/www/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: loja
MYSQL_ROOT_PASSWORD: senha
volumes:
- db_data:/var/lib/mysql
redis:
image: redis:7-alpine
volumes:
db_data:
Monitoramento e logs
<?php
// config/logging.php — Laravel
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily', 'slack'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'level' => 'critical', // Apenas erros críticos vão para o Slack
],
],
// Uso — o mesmo que vimos na PSR-3:
Log::info('Pedido criado', ['id' => $pedido->id, 'total' => $pedido->total]);
Log::error('Falha no pagamento', ['pedido_id' => $id, 'erro' => $e->getMessage()]);
Log::critical('Banco de dados inacessível', ['exception' => $e]);
Deploy é a etapa em que o custo de um erro muda de natureza. Um bug no código quebra uma funcionalidade; um erro na configuração de produção quebra o site inteiro, quase sempre num horário ruim, e quase sempre com o autor sem acesso ao que precisa para entender. Por isso a parte que mais se paga aqui não é a automação — é a reversibilidade. Um deploy que sobe é metade do trabalho; a outra metade é o caminho de volta, ensaiado antes de precisar dele. Se o plano de rollback é "dar git revert e rodar de novo", o plano é torcer para que a falha esteja no código e não no banco.
Vale também desconfiar da configuração que só existe na cabeça de quem a escreveu. O servidor que ninguém sabe reconstruir, o .env que só está lá, o certificado renovado à mão uma vez por ano — cada um é uma dependência de uma pessoa específica continuar disponível. O antídoto é chato e conhecido: tudo em arquivo, tudo versionado, e o ambiente inteiro reconstruível a partir do repositório. Não é perfeccionismo de infraestrutura. É a diferença entre uma noite de trabalho e uma semana de arqueologia, descoberta sempre no pior momento possível.
Fontes e leituras recomendadas
- NGINX Docs: https://nginx.org/en/docs/
- PHP-FPM Configuration: https://www.php.net/manual/pt_BR/install.fpm.configuration.php
- GitHub Actions: https://docs.github.com/en/actions
- Docker for PHP: https://hub.docker.com/_/php
- Let's Encrypt: https://letsencrypt.org/
Exercícios
Exercício 1
Configure NGINX + PHP-FPM localmente com Docker. Crie um docker-compose.yml com app, nginx, mysql e redis. Garanta que o Laravel funciona completamente nesse ambiente.
Ver resposta
✓ Resposta: Três detalhes que consomem uma tarde cada. O volume anônimo em /var/www/html/vendor: sem ele, o bind mount do host cobre o vendor/ instalado no build e a aplicação sobe sem dependências. O condition: service_healthy: depends_on sozinho espera o container subir, não o MySQL aceitar conexão — daí o "connection refused" no primeiro migrate. E fastcgi_pass app:9000, com o nome do serviço: o PHP-FPM está em outro container, localhost ali é o próprio NGINX.
# ============ estrutura ============
# projeto/
# ├── docker-compose.yml
# ├── docker/
# │ ├── php/Dockerfile
# │ ├── php/php.ini
# │ └── nginx/default.conf
# └── (o Laravel na raiz)
# ============ docker-compose.yml ============
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
volumes:
# :cached melhora bastante o desempenho no macOS; no Linux é
# indiferente. Em produção NÃO se monta o código como volume — ele
# vai dentro da imagem.
- .:/var/www/html:cached
# Volume anônimo para o vendor: sem ele, o bind mount do host
# esconde o vendor/ instalado durante o build da imagem.
- /var/www/html/vendor
environment:
DB_HOST: mysql
DB_DATABASE: loja
DB_USERNAME: loja
DB_PASSWORD: secret
REDIS_HOST: redis
CACHE_STORE: redis
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
depends_on:
# depends_on sozinho só espera o container SUBIR, não o serviço
# ficar pronto. Com condition + healthcheck, espera de verdade — e
# some o "connection refused" do primeiro artisan migrate.
mysql:
condition: service_healthy
redis:
condition: service_healthy
networks: [interna]
nginx:
image: nginx:1.27-alpine
ports:
- "8000:80"
volumes:
- .:/var/www/html:cached
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on: [app]
networks: [interna]
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: loja
MYSQL_USER: loja
MYSQL_PASSWORD: secret
volumes:
- dados-mysql:/var/lib/mysql
ports:
# Porta alta no host: 3306 costuma já estar ocupada por um MySQL
# local, e o erro que aparece não diz isso.
- "33061:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks: [interna]
redis:
image: redis:7-alpine
# appendonly liga a persistência: sem isso, reiniciar o container
# apaga filas e sessões.
command: redis-server --appendonly yes
volumes:
- dados-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks: [interna]
# O worker é um processo à parte, com a MESMA imagem do app.
worker:
build:
context: .
dockerfile: docker/php/Dockerfile
command: php artisan queue:work --tries=3 --max-time=3600
volumes:
- .:/var/www/html:cached
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
networks: [interna]
volumes:
dados-mysql:
dados-redis:
networks:
interna:
driver: bridge
# ============ docker/php/Dockerfile ============
# Build em dois estágios: o primeiro tem Composer e ferramentas de
# compilação; o segundo, só o necessário para rodar. A imagem final fica
# com menos de metade do tamanho e sem o Composer dentro.
#
# FROM composer:2 AS dependencias
# WORKDIR /app
# COPY composer.json composer.lock ./
# # --no-scripts aqui porque o código da aplicação ainda não foi copiado.
# RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
# COPY . .
# RUN composer dump-autoload --optimize --classmap-authoritative
#
# FROM php:8.3-fpm-alpine
#
# RUN apk add --no-cache \
# icu-dev libzip-dev oniguruma-dev postgresql-dev linux-headers \
# && docker-php-ext-install pdo_mysql mbstring zip exif pcntl bcmath intl opcache \
# && pecl install redis && docker-php-ext-enable redis \
# && apk del linux-headers
#
# COPY docker/php/php.ini /usr/local/etc/php/conf.d/app.ini
#
# WORKDIR /var/www/html
# COPY --from=dependencias /app/vendor ./vendor
# COPY . .
#
# # O usuário www-data já existe na imagem: rodar como root é o erro de
# # segurança mais comum em container PHP.
# RUN chown -R www-data:www-data storage bootstrap/cache
# USER www-data
#
# EXPOSE 9000
# CMD ["php-fpm"]
# ============ docker/nginx/default.conf ============
# server {
# listen 80;
# server_name _;
# root /var/www/html/public; # public/, NUNCA a raiz do projeto
# index index.php;
#
# client_max_body_size 20M; # tem de casar com o upload_max_filesize
#
# location / {
# try_files $uri $uri/ /index.php?$query_string;
# }
#
# location ~ \.php$ {
# # "app" é o NOME DO SERVIÇO no compose — o DNS interno do Docker
# # resolve. Não é localhost: o PHP-FPM está em outro container.
# fastcgi_pass app:9000;
# fastcgi_index index.php;
# include fastcgi_params;
# fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# fastcgi_read_timeout 60s;
# }
#
# # .env, .git e afins nunca devem ser servidos.
# location ~ /\.(?!well-known).* { deny all; }
#
# gzip on;
# gzip_types text/css application/javascript application/json image/svg+xml;
# }
# ============ docker/php/php.ini ============
# memory_limit = 256M
# upload_max_filesize = 20M
# post_max_size = 21M ; maior que o upload_max_filesize
# max_execution_time = 60
# expose_php = Off
#
# opcache.enable = 1
# opcache.memory_consumption = 256
# opcache.max_accelerated_files = 20000
# ; Em DESENVOLVIMENTO, 1: o PHP confere o arquivo a cada request e você vê
# ; a alteração ao salvar. Em produção, 0 — e o deploy reinicia o FPM.
# opcache.validate_timestamps = 1
# ============ subindo ============
#
# docker compose up -d --build
# docker compose exec app php artisan key:generate
# docker compose exec app php artisan migrate --seed
# docker compose logs -f app
#
# # a aplicação em http://localhost:8000
#
# Problemas mais comuns:
# · "Permission denied" em storage/ → chown do Dockerfile não rodou;
# `docker compose exec app chown -R www-data:www-data storage`
# · "Connection refused" no mysql → falta o healthcheck + condition
# · alterações não aparecem → opcache.validate_timestamps = 0
# · vendor/ vazio → falta o volume anônimo que protege o diretório
Exercício 2
Escreva o script deploy.sh completo. Teste localmente simulando as etapas. Adicione tratamento de erro: se php artisan migrate falhar, o script deve reverter o git reset e reativar a aplicação.
Ver resposta
✓ Resposta: O trap reverter ERR combinado com set -e é o que separa "o deploy quebrou" de "o site caiu". Duas linhas dentro dele merecem atenção: o php artisan up — sem ele o site fica na página de manutenção para sempre, mesmo com o código já revertido — e o composer install, porque o composer.lock voltou junto com o git reset e o vendor/ atual não corresponde mais. Detalhe fácil de perder: sem pipefail, migrate | tee log devolve sucesso mesmo com a migration explodindo.
#!/usr/bin/env bash
#
# deploy.sh — deploy com reversão automática em caso de falha.
#
# ./deploy.sh # deploy da branch main
# ./deploy.sh v1.4.0 # deploy de uma tag
# ./deploy.sh --simular # mostra o que faria, sem executar
# As três primeiras linhas de todo script sério:
# -e aborta no primeiro comando que falhar
# -u variável não definida vira erro, em vez de string vazia
# -o pipefail o pipe falha se QUALQUER etapa falhar, não só a última
#
# Sem `pipefail`, `php artisan migrate | tee log` devolve sucesso mesmo com
# a migration explodindo — porque quem retornou 0 foi o `tee`.
set -euo pipefail
APP_DIR="/var/www/loja"
BRANCH="${1:-main}"
SIMULAR="${SIMULAR:-0}"
[[ "${1:-}" == "--simular" ]] && SIMULAR=1 && BRANCH="main"
VERDE='\033[0;32m'; VERMELHO='\033[0;31m'; AMARELO='\033[1;33m'; SEM_COR='\033[0m'
log() { echo -e "${VERDE}==>${SEM_COR} $*"; }
aviso() { echo -e "${AMARELO}==>${SEM_COR} $*"; }
erro() { echo -e "${VERMELHO}==> ERRO:${SEM_COR} $*" >&2; }
executar() {
if [[ "$SIMULAR" == "1" ]]; then
echo " [simulação] $*"
else
"$@"
fi
}
cd "$APP_DIR"
# ---------------------------------------------------------------------
# Guardar o estado ANTES de tocar em qualquer coisa. Sem isto, não há
# para onde voltar.
# ---------------------------------------------------------------------
COMMIT_ANTERIOR="$(git rev-parse HEAD)"
LOTE_ANTERIOR="$(php artisan migrate:status --no-ansi 2>/dev/null | grep -c 'Ran' || echo 0)"
log "Commit atual: ${COMMIT_ANTERIOR:0:8}"
log "Migrations aplicadas: $LOTE_ANTERIOR"
# ---------------------------------------------------------------------
# A rede de segurança: trap ERR dispara em QUALQUER comando que falhe,
# graças ao `set -e`. É o que transforma "o deploy quebrou e o site ficou
# fora" em "o deploy quebrou e o site voltou sozinho".
# ---------------------------------------------------------------------
reverter() {
local codigo=$?
erro "Falha no deploy (código $codigo). Revertendo..."
# 1. Desfaz a migration desta leva, se houver.
if [[ "${MIGROU:-0}" == "1" ]]; then
aviso "Revertendo migrations..."
php artisan migrate:rollback --force --step=1 || \
erro "ROLLBACK DA MIGRATION FALHOU — intervenção manual necessária."
fi
# 2. Volta o código.
aviso "Voltando para ${COMMIT_ANTERIOR:0:8}..."
git reset --hard "$COMMIT_ANTERIOR"
# 3. Reinstala as dependências daquele commit — o composer.lock voltou
# junto, e o vendor/ atual pode não corresponder mais.
composer install --no-dev --optimize-autoloader --no-interaction
# 4. Recompila os caches com o código antigo.
php artisan optimize:clear
php artisan optimize
# 5. E, por último, tira do modo de manutenção. Esta é a linha que não
# pode faltar: sem ela, o site fica com a página de manutenção
# para sempre, mesmo com o código já revertido.
php artisan up || true
erro "Revertido para o estado anterior. Investigue antes de tentar de novo."
exit "$codigo"
}
trap reverter ERR
# ---------------------------------------------------------------------
# Verificações antes de começar — falhe barato.
# ---------------------------------------------------------------------
log "Verificando pré-requisitos..."
[[ -f .env ]] || { erro ".env não encontrado em $APP_DIR"; exit 1; }
if [[ -n "$(git status --porcelain)" ]]; then
erro "Há alterações não commitadas no servidor. Alguém editou direto em produção."
git status --short
exit 1
fi
# Espaço em disco: deploy que enche o disco no meio é pior que deploy que
# não começa.
DISPONIVEL_MB=$(df -Pm "$APP_DIR" | awk 'NR==2 {print $4}')
if (( DISPONIVEL_MB < 500 )); then
erro "Espaço insuficiente: ${DISPONIVEL_MB}MB livres (mínimo 500MB)."
exit 1
fi
# ---------------------------------------------------------------------
# Backup do banco ANTES da migration. Barato agora, impagável depois.
# ---------------------------------------------------------------------
log "Backup do banco..."
BACKUP="/var/backups/loja/$(date +%Y%m%d-%H%M%S).sql.gz"
executar mkdir -p "$(dirname "$BACKUP")"
executar bash -c "mysqldump --single-transaction --quick loja | gzip > '$BACKUP'"
log "Backup em $BACKUP"
# ---------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------
log "Entrando em modo de manutenção..."
# --secret permite ao time continuar acessando pelo link secreto para
# conferir o resultado antes de liberar para o público.
executar php artisan down --render="errors::503" --secret="$(openssl rand -hex 16)" --retry=60
log "Atualizando o código para $BRANCH..."
executar git fetch --all --tags --prune
executar git reset --hard "origin/$BRANCH"
log "Instalando dependências..."
# --no-dev em produção: PHPUnit e amigos não têm o que fazer no servidor.
executar composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
log "Compilando assets..."
executar npm ci --omit=dev
executar npm run build
log "Aplicando migrations..."
MIGROU=1
executar php artisan migrate --force --no-interaction
log "Recompilando caches..."
executar php artisan optimize:clear
executar php artisan optimize # config + route + view + events
executar php artisan storage:link || true
log "Reiniciando serviços..."
# reload, não restart: o FPM termina as requisições em andamento antes de
# trocar os workers. `restart` derruba quem estava no meio de um checkout.
executar sudo systemctl reload php8.3-fpm
# Os workers precisam do restart de verdade: eles carregam o código na
# memória e continuariam rodando a versão antiga indefinidamente.
executar php artisan queue:restart
executar php artisan horizon:terminate || true
# ---------------------------------------------------------------------
# Verificação de saúde ANTES de liberar o público.
# ---------------------------------------------------------------------
log "Verificando a saúde da aplicação..."
for tentativa in {1..5}; do
if curl -fsS --max-time 10 "http://127.0.0.1/health" > /dev/null; then
log "Aplicação respondendo."
break
fi
if (( tentativa == 5 )); then
erro "A aplicação não respondeu depois de 5 tentativas."
exit 1 # dispara o trap → reverte
fi
aviso "Tentativa $tentativa falhou. Nova tentativa em 3s..."
sleep 3
done
log "Saindo do modo de manutenção..."
executar php artisan up
# Deu certo: desarma o trap, para que uma falha na limpeza abaixo não
# reverta um deploy que já está no ar.
trap - ERR
log "Limpando backups com mais de 30 dias..."
find /var/backups/loja -name '*.sql.gz' -mtime +30 -delete 2>/dev/null || true
COMMIT_NOVO="$(git rev-parse --short HEAD)"
log "Deploy concluído: ${COMMIT_ANTERIOR:0:8} → $COMMIT_NOVO"
# Avisa o time (o webhook do Slack fica no .env, nunca aqui).
if [[ -n "${SLACK_WEBHOOK:-}" ]]; then
curl -fsS -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Deploy da loja: $COMMIT_NOVO\"}" > /dev/null || true
fi
Exercício 3
Configure o GitHub Actions com as etapas: composer audit, phpstan, php-cs-fixer, pest. Faça um PR intencional com erro e confirme que o CI falha.
Ver resposta
✓ Resposta: A separação em dois jobs com needs é o que faz o pipeline ser usável: os checks estáticos levam segundos e derrubam o build antes de qualquer runner gastar quatro minutos montando MySQL e Redis. Dois ajustes que economizam tempo real: medir cobertura em uma versão da matriz (nas três triplica o custo sem acrescentar informação) e fail-fast: false, para que a quebra no 8.3 não esconda o resultado do 8.4. E, como sempre, o portão que importa está na proteção do branch, não no YAML.
# ============ .github/workflows/ci.yml ============
name: CI
on:
push:
branches: [main, develop]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ---- Job 1: os checks que levam segundos. Falhe barato. ----
qualidade:
name: Qualidade estática
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, intl
coverage: none # sem cobertura aqui: é mais rápido
tools: composer:v2, php-cs-fixer, phpstan
- uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-8.3-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-8.3-
- run: composer install --no-interaction --no-progress --prefer-dist
# Ordem deliberada: do mais barato para o mais caro.
- name: composer validate
run: composer validate --strict --no-check-publish
- name: composer audit
run: composer audit --locked --no-dev
- name: Formatação (PSR-12)
run: php-cs-fixer fix --dry-run --diff
- name: Análise estática
# --error-format=github faz os erros aparecerem anotados na aba
# "Files changed" do PR, na linha exata.
run: phpstan analyse --error-format=github --no-progress
# ---- Job 2: os testes, em várias versões do PHP ----
testes:
name: Testes (PHP ${{ matrix.php }})
runs-on: ubuntu-latest
needs: qualidade # só roda se o job barato passou
strategy:
# Sem fail-fast: se o 8.3 quebra, você ainda quer ver o resultado do
# 8.4 no mesmo relatório.
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: teste
ports: ['3306:3306']
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot"
--health-interval=10s --health-timeout=5s --health-retries=10
redis:
image: redis:7
ports: ['6379:6379']
options: >-
--health-cmd="redis-cli ping"
--health-interval=10s --health-timeout=3s --health-retries=5
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: mbstring, pdo_mysql, redis, intl, bcmath
coverage: pcov
- uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
- run: composer install --no-interaction --no-progress --prefer-dist
- name: Preparar ambiente
run: |
cp .env.example .env
php artisan key:generate
- name: Migrations
env:
DB_HOST: 127.0.0.1
DB_DATABASE: teste
DB_USERNAME: root
DB_PASSWORD: root
run: php artisan migrate --force
- name: Pest
env:
DB_HOST: 127.0.0.1
DB_DATABASE: teste
DB_USERNAME: root
DB_PASSWORD: root
REDIS_HOST: 127.0.0.1
# A cobertura só é medida uma vez, na versão principal: medir nas
# três triplica o tempo sem acrescentar informação.
run: |
if [ "${{ matrix.php }}" = "8.3" ]; then
vendor/bin/pest --coverage --min=70 --coverage-clover=build/clover.xml
else
vendor/bin/pest
fi
- name: Guardar relatórios
if: failure()
uses: actions/upload-artifact@v4
with:
name: falhas-php-${{ matrix.php }}
path: |
storage/logs/
build/
# ---- Job 3: deploy, só no main e só se tudo passou ----
deploy:
name: Deploy em produção
runs-on: ubuntu-latest
needs: [qualidade, testes]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
# O environment permite exigir aprovação manual antes de rodar
# (Settings → Environments → producao → Required reviewers).
environment:
name: producao
url: https://loja.exemplo.com
steps:
- name: Deploy por SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
# Chave em secret, sempre. Chave no repositório é chave pública
# do ponto de vista prático.
key: ${{ secrets.SSH_KEY }}
script: cd /var/www/loja && ./deploy.sh main
# ============ o exercício: um PR que falha de propósito ============
#
# git checkout -b teste/ci-vermelho
#
# # 1) erro de formatação
# echo '<?php $x=1;$y=2;' >> app/Sujo.php
#
# # 2) erro que o PHPStan pega
# cat >> app/Sujo.php <<'PHP'
# function quebrada(): int { return "texto"; }
# PHP
#
# # 3) teste vermelho
# cat > tests/Unit/FalhaTest.php <<'PHP'
# <?php
# test('falha de propósito', fn () => expect(1)->toBe(2));
# PHP
#
# git add . && git commit -m "teste: CI deve falhar" && git push -u origin HEAD
#
# O esperado: o job `qualidade` falha primeiro (segundos), e o job `testes`
# nem chega a rodar por causa do `needs`. É a economia de ter separado os
# dois — não se gasta quatro minutos de runner para descobrir um espaço
# faltando.
#
# ============ o portão de verdade não está no YAML ============
#
# Settings → Branches → Add branch protection rule → main
# ☑ Require a pull request before merging
# ☑ Require status checks to pass before merging
# → qualidade
# → testes (PHP 8.3)
# ☑ Require branches to be up to date before merging
#
# Sem isso, o X vermelho é decorativo e o botão de merge continua clicável.
Exercício 4
Desafio: configure um pipeline de deploy com zero downtime real usando o padrão de diretórios releases/: cada deploy cria um novo diretório com timestamp, um symlink current aponta para o release ativo, e os últimos 5 releases são mantidos para rollback instantâneo.
Ver resposta
✓ Resposta: Três armadilhas específicas deste modelo. ln -sfn não é atômico — ele remove e recria, e a fresta vira 500 para usuários reais; a troca correta é criar com nome temporário e mv -Tf, que usa o rename(2). O PHP-FPM guarda o realpath em cache, então sem reload ele continua servindo o release antigo (e o NGINX faz o mesmo sem $realpath_root). E o banco é o único recurso compartilhado entre as duas versões durante a troca: toda migration precisa ser compatível com o código antigo e o novo — o que transforma "renomear uma coluna" em três deploys.
#!/usr/bin/env bash
#
# deploy-atomico.sh — deploy sem janela de manutenção, com rollback
# instantâneo.
#
# A ideia inteira cabe numa frase: o deploy monta o release novo AO LADO
# do que está no ar e, no fim, troca um symlink. A troca é atômica no
# nível do sistema de arquivos — não existe instante em que o site esteja
# meio antigo e meio novo.
#
# Estrutura no servidor:
#
# /var/www/loja/
# ├── releases/
# │ ├── 20260810-143022/
# │ ├── 20260812-091544/
# │ └── 20260815-160233/ ← o novo, sendo montado
# ├── shared/ ← sobrevive a todo deploy
# │ ├── .env
# │ └── storage/
# └── current -> releases/20260812-091544 ← o que está no ar
set -euo pipefail
BASE="/var/www/loja"
REPO="git@github.com:voce/loja.git"
BRANCH="${1:-main}"
MANTER=5 # releases guardados para rollback
CARIMBO="$(date +%Y%m%d-%H%M%S)"
RELEASE="$BASE/releases/$CARIMBO"
COMPARTILHADO="$BASE/shared"
ATUAL="$BASE/current"
VERDE='\033[0;32m'; VERMELHO='\033[0;31m'; SEM_COR='\033[0m'
log() { echo -e "${VERDE}==>${SEM_COR} $*"; }
erro() { echo -e "${VERMELHO}==> ERRO:${SEM_COR} $*" >&2; }
# ---------------------------------------------------------------------
# Limpeza em caso de falha: o release incompleto é apagado e o `current`
# nunca chega a apontar para ele. O site no ar não é tocado em momento
# algum — este é o ganho central do modelo.
# ---------------------------------------------------------------------
limpar() {
erro "Falha no deploy. O release $CARIMBO será descartado."
erro "O site continua no ar com $(basename "$(readlink -f "$ATUAL")")."
rm -rf "$RELEASE"
exit 1
}
trap limpar ERR
mkdir -p "$BASE/releases" "$COMPARTILHADO"
# ---------------------------------------------------------------------
# 1. Clonar o release novo
# ---------------------------------------------------------------------
log "Criando release $CARIMBO..."
git clone --depth 1 --branch "$BRANCH" "$REPO" "$RELEASE"
cd "$RELEASE"
COMMIT="$(git rev-parse --short HEAD)"
log "Commit $COMMIT"
# ---------------------------------------------------------------------
# 2. Ligar o que é compartilhado
#
# .env e storage/ NÃO podem viver dentro do release: o .env sumiria a cada
# deploy e o storage levaria junto todo upload de usuário e todo log.
# ---------------------------------------------------------------------
log "Ligando arquivos compartilhados..."
ln -sfn "$COMPARTILHADO/.env" "$RELEASE/.env"
rm -rf "$RELEASE/storage"
ln -sfn "$COMPARTILHADO/storage" "$RELEASE/storage"
# ---------------------------------------------------------------------
# 3. Montar o release — tudo isto acontece SEM afetar o site no ar
# ---------------------------------------------------------------------
log "Instalando dependências..."
composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
log "Compilando assets..."
npm ci --omit=dev && npm run build
log "Compilando caches..."
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# ---------------------------------------------------------------------
# 4. Migrations — o ÚNICO ponto que o modelo atômico não resolve sozinho
#
# O banco é compartilhado entre o release antigo e o novo. Durante alguns
# segundos, os dois códigos convivem com o mesmo schema. Por isso toda
# migration precisa ser COMPATÍVEL COM AS DUAS VERSÕES:
#
# ✅ acrescentar coluna nullable
# ✅ criar tabela nova
# ✅ criar índice (com CONCURRENTLY no PostgreSQL)
# ❌ remover coluna que o código antigo ainda lê
# ❌ renomear coluna
# ❌ acrescentar NOT NULL sem default
#
# Renomear coluna vira três deploys: (1) cria a nova e escreve nas duas;
# (2) passa a ler da nova; (3) remove a antiga. É chato, e é o preço de
# não ter janela de manutenção.
# ---------------------------------------------------------------------
log "Aplicando migrations..."
php artisan migrate --force --no-interaction
# ---------------------------------------------------------------------
# 5. Aquecer antes de receber tráfego real
# ---------------------------------------------------------------------
log "Aquecendo o OPcache..."
php -r 'opcache_compile_file("'"$RELEASE"'/public/index.php");' 2>/dev/null || true
# ---------------------------------------------------------------------
# 6. A TROCA ATÔMICA
#
# `ln -sfn` sozinho NÃO é atômico: ele apaga o link e cria outro, e existe
# uma fresta em que `current` não aponta para lugar nenhum — em servidor
# movimentado, isso é um punhado de 500 para usuários reais.
#
# Criar o link com nome temporário e movê-lo com `mv -T` usa o rename(2)
# do sistema, que é atômico por definição.
# ---------------------------------------------------------------------
log "Trocando o release ativo..."
ANTERIOR="$(readlink -f "$ATUAL" 2>/dev/null || echo '')"
ln -sfn "$RELEASE" "$BASE/current.novo"
mv -Tf "$BASE/current.novo" "$ATUAL"
log "current → $CARIMBO"
# ---------------------------------------------------------------------
# 7. Recarregar os serviços
# ---------------------------------------------------------------------
# O FPM guarda o realpath resolvido em cache: sem o reload, ele continua
# servindo os arquivos do release ANTIGO mesmo com o symlink já trocado.
# É a causa nº 1 de "troquei o link e nada mudou".
log "Recarregando PHP-FPM..."
sudo systemctl reload php8.3-fpm
# Os workers carregam o código na memória — sem restart, continuariam
# rodando a versão anterior indefinidamente.
php artisan queue:restart
# ---------------------------------------------------------------------
# 8. Verificar — e voltar em um segundo, se preciso
# ---------------------------------------------------------------------
log "Verificando saúde..."
if ! curl -fsS --max-time 10 "https://loja.exemplo.com/health" > /dev/null; then
erro "A aplicação não respondeu. Voltando para o release anterior..."
if [[ -n "$ANTERIOR" ]]; then
# O rollback é a mesma troca de symlink, ao contrário. Instantâneo,
# porque o release antigo continua inteiro no disco — com vendor/,
# assets compilados e caches prontos.
ln -sfn "$ANTERIOR" "$BASE/current.novo"
mv -Tf "$BASE/current.novo" "$ATUAL"
sudo systemctl reload php8.3-fpm
erro "Revertido para $(basename "$ANTERIOR")."
fi
exit 1
fi
trap - ERR
# ---------------------------------------------------------------------
# 9. Guardar os últimos N releases
# ---------------------------------------------------------------------
log "Mantendo os últimos $MANTER releases..."
cd "$BASE/releases"
ls -1dt */ | tail -n +$((MANTER + 1)) | xargs -r rm -rf
log "Releases disponíveis para rollback:"
ls -1dt */ | head -n "$MANTER" | sed 's|/$||' | while read -r r; do
marca=" "
[[ "$(readlink -f "$ATUAL")" == "$BASE/releases/$r" ]] && marca="*"
echo " $marca $r"
done
log "Deploy concluído: $CARIMBO ($COMMIT)"
# ============ rollback manual, em um comando ============
#
# #!/usr/bin/env bash
# # rollback.sh — volta para o release anterior em ~1 segundo
# set -euo pipefail
#
# BASE="/var/www/loja"
# ATUAL="$(readlink -f "$BASE/current")"
# ALVO="${1:-$(ls -1dt "$BASE"/releases/*/ | sed -n '2p')}"
#
# [[ -d "$ALVO" ]] || { echo "Release inexistente: $ALVO"; exit 1; }
#
# ln -sfn "$ALVO" "$BASE/current.novo"
# mv -Tf "$BASE/current.novo" "$BASE/current"
# sudo systemctl reload php8.3-fpm
#
# echo "Revertido para $(basename "$ALVO")"
#
# Compare com o modelo do item anterior: lá o rollback era `git reset` +
# `composer install` + rebuild de assets — minutos com o site fora. Aqui,
# o release antigo está inteiro no disco, pronto. É a troca de um symlink.
#
# ============ o NGINX precisa saber disso ============
#
# root /var/www/loja/current/public;
#
# E, no bloco do PHP:
#
# fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
# fastcgi_param DOCUMENT_ROOT $realpath_root;
#
# `$realpath_root` resolve o symlink a cada requisição. Com `$document_root`,
# o NGINX mantém o caminho antigo em cache e serve o release anterior — o
# mesmo sintoma do FPM sem reload, por outra causa.
Exercício 5
O passo 5 do deploy.sh roda php artisan config:cache, e é justamente ele que produz o bug de produção mais desconcertante do Laravel: código que funciona na sua máquina e devolve null no servidor, sem erro, sem log, sem diferença de versão. Reproduza. Ponha env('STRIPE_KEY') dentro de um service ou controller — não dentro de config/ —, confirme que funciona em desenvolvimento, rode php artisan config:cache e chame de novo. O que mudou? E por que config('services.stripe.key'), que lê o mesmo valor do .env, continua funcionando?
Ver resposta
✓ Resposta: Depois do config:cache, o arquivo .env deixa de ser lido — não parcialmente, não com atraso: não é lido. Toda chamada a env() passa a devolver o valor padrão, que na maioria das vezes é null. Não é comportamento acidental nem otimização agressiva; está escrito em três linhas no bootstrapper do framework:
<?php
// Illuminate/Foundation/Bootstrap/LoadEnvironmentVariables.php
public function bootstrap(Application $app)
{
if ($app->configurationIsCached()) {
return; // ← sai antes de carregar o .env
}
$this->checkForSpecificEnvironmentFile($app);
$this->createDotenv($app)->safeLoad();
}
A lógica é consistente: o bootstrap/cache/config.php é um array PHP com os valores já resolvidos no momento em que o cache foi gerado. Se o .env fosse lido depois, haveria duas fontes de verdade discordando. O framework escolhe uma — o cache — e a leitura do .env vira dispensável. Por isso config('services.stripe.key') continua respondendo: o env() dentro de config/services.php rodou na hora do config:cache, com o .env ainda disponível, e o valor foi congelado no array.
Daí a regra que a documentação do Laravel enuncia e que quase todo projeto viola pelo menos uma vez: env() só dentro de config/. Em qualquer outro lugar — controller, service, model, job, view — o acesso é por config(). A configuração vira o único caminho, e o .env vira o que ele deveria ser desde o começo: entrada para a configuração, não uma variável global lida de qualquer ponto do código.
Três detalhes que economizam a próxima madrugada. O primeiro: o sintoma é silencioso, porque null costuma virar string vazia, cabeçalho ausente ou uma chamada de API que responde 401 — e o rastro aponta para o serviço externo, não para o deploy. O segundo: dá para achar todos os casos antes que eles achem você, com grep -rn "env(" app/ routes/ resources/, e essa busca merece estar no CI. O terceiro: se o valor precisar mudar sem novo deploy, nem env() nem config() resolvem — cache de configuração e valor dinâmico são objetivos incompatíveis, e o lugar do que muda em tempo de execução é o banco ou um serviço de configuração, não o .env.