Deploy e Ambiente de Produção

Deploy e Ambiente de Produção

NGINX com PHP-FPM, script de deploy, pipeline no GitHub Actions, Docker e logs — o caminho do repositório até o servidor respondendo. Com os nomes de cipher TLS que não existem, a imagem do PHP que não traz o Composer e o env() que vira null depois do config:cache.
PHP

• • 30 min de leitura

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

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.

Comentários

Mais em PHP

Exceções Avançadas
Exceções Avançadas

Uma hierarquia de exceções bem desenhada diz ao chamador o que ele pode tratar…

Configurando o Ambiente de Desenvolvimento
Configurando o Ambiente de Desenvolvimento

Instalar o PHP é a parte fácil; o que decide se o ambiente vai ajudar ou…

Arrays em Profundidade
Arrays em Profundidade

O array do PHP é uma estrutura só que faz o papel de lista, mapa e registro —…