惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
DataBreaches.Net
P
Proofpoint News Feed
The Cloudflare Blog
宝玉的分享
宝玉的分享
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
月光博客
月光博客
美团技术团队
Spread Privacy
Spread Privacy
Latest news
Latest news
Cisco Talos Blog
Cisco Talos Blog
T
Threatpost
Project Zero
Project Zero
博客园 - 司徒正美
D
Darknet – Hacking Tools, Hacker News & Cyber Security
Simon Willison's Weblog
Simon Willison's Weblog
Apple Machine Learning Research
Apple Machine Learning Research
腾讯CDC
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
F
Fortinet All Blogs
Security Latest
Security Latest
Blog — PlanetScale
Blog — PlanetScale
T
Tailwind CSS Blog
Cyberwarzone
Cyberwarzone
The Hacker News
The Hacker News
Scott Helme
Scott Helme
T
Tor Project blog
Engineering at Meta
Engineering at Meta
H
Help Net Security
Recorded Future
Recorded Future
Microsoft Azure Blog
Microsoft Azure Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
I
Intezer
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
P
Privacy & Cybersecurity Law Blog
T
The Blog of Author Tim Ferriss
I
InfoQ
C
Cybersecurity and Infrastructure Security Agency CISA
大猫的无限游戏
大猫的无限游戏
F
Full Disclosure
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
博客园 - 三生石上(FineUI控件)
L
LINUX DO - 热门话题
V
Vulnerabilities – Threatpost
S
SegmentFault 最新的问题
人人都是产品经理
人人都是产品经理
G
GRAHAM CLULEY
A
Arctic Wolf
P
Privacy International News Feed

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
CI/CD Profissional com GitLab e Laravel em VPS: Do Push ao Deploy Automático
Matheus Mendonça Sesso · 2026-06-19 · via DEV Community

Se você ainda faz deploy da sua API Laravel conectando no servidor via SSH, rodando git pull e rezando para que nada quebre, este artigo pode ser útil para ti.

Vou mostrar como configurar um de CI/CD completo no GitLab, do zero, para uma aplicação Laravel hospedada em uma VPS comum. Ao final, cada push na branch certa vai automaticamente instalar dependências, verificar a qualidade do código, rodar os testes, construir uma imagem Docker e fazer deploy no servidor, sem nenhuma intervenção manual.


O que vamos construir

Um pipeline com 5 stages em sequência:

prepare → quality → test → build → deploy

Stage O que faz
prepare Instala dependências com Composer
quality Verifica o estilo do código com Laravel Pint
test Executa os testes com Pest
build Constrói a imagem Docker e publica no Registry
deploy Faz o deploy na VPS via SSH

Pré-requisitos

  • Uma conta no GitLab (gitlab.com ou self-hosted)
  • Uma VPS com Ubuntu/Debian e Docker instalado
  • Um projeto Laravel com Pest configurado
  • Acesso SSH à VPS

Parte 1: O GitLab Runner

O GitLab por si só não executa nenhum comando. Quem faz o trabalho pesado é o GitLab Runner: um agente leve que você instala em qualquer máquina (sua VPS, um servidor dedicado, ou até o próprio computador) e que fica escutando o GitLab por jobs para executar.

Como o Runner funciona

┌──────────────┐          ┌──────────────────┐          ┌──────────────┐
│   GitLab     │◄─polling─│   GitLab Runner  │──executa─►│  Seu código  │
│  (servidor)  │          │  (na sua VPS)    │          │              │
└──────────────┘          └──────────────────┘          └──────────────┘

O Runner faz polling no GitLab a cada poucos segundos. Quando há um job disponível, ele pega, executa e devolve o resultado. Você pode ter quantos runners quiser, em máquinas diferentes, e o GitLab distribui os jobs entre eles.

Tipos de executor

O Runner suporta vários executores. Os dois mais usados são:

Executor Como funciona Quando usar
Shell Executa comandos diretamente no servidor Simples, sem isolamento
Docker Executa cada job dentro de um container Recomendado — isolamento total

Usaremos o executor Docker: cada job roda em um container descartável com a imagem que você especificar. Isso garante que o ambiente é sempre limpo e reproduzível.

Instalando o GitLab Runner na VPS

Conecte na sua VPS via SSH e execute:

# Adiciona o repositório oficial do GitLab Runner
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash

# Instala o Runner
sudo apt-get install -y gitlab-runner

# Verifica se o serviço está rodando
sudo systemctl status gitlab-runner

💡 O Runner roda como um serviço do sistema (systemd) e é iniciado automaticamente com o servidor.

Registrando o Runner no GitLab

Com o Runner instalado, você precisa registrá-lo no seu projeto do GitLab. É nesta etapa que você associa o agente ao repositório.

No GitLab, vá em: Settings > CI/CD > Runners > New project runner

O GitLab vai gerar um token de registro. De volta à VPS, execute:

sudo gitlab-runner register

O comando vai fazer algumas perguntas interativas:

Enter the GitLab instance URL:
> https://gitlab.com

Enter the registration token:
> glrt-xxxxxxxxxxxxxxxxxxxx

Enter a description for the runner:
> vps-production-runner

Enter tags for the runner (comma-separated):
> docker,laravel

Enter optional maintenance note for the runner:
>

Enter an executor:
> docker

Enter the default Docker image:
> php:8.4-cli-bookworm

Após o registro, o Runner aparece na interface do GitLab como "Online".

Configuração adicional para Docker-in-Docker

Para que os jobs de build do Docker funcionem dentro de containers, você precisa de uma configuração extra. Edite o arquivo /etc/gitlab-runner/config.toml:

[[runners]]
  name = "vps-production-runner"
  url = "https://gitlab.com"
  token = "glrt-xxxxxxxxxxxxxxxxxxxx"
  executor = "docker"

  [runners.docker]
    image = "php:8.4-cli-bookworm"
    privileged = true          # necessário para Docker-in-Docker
    volumes = [
      "/certs/client",         # certificados TLS para DinD
      "/cache"
    ]

O modo privileged = true é necessário para que o container do job possa acessar o daemon Docker do host durante o build da imagem.

Após editar, reinicie o Runner:

sudo systemctl restart gitlab-runner

⚠️ Segurança: use privileged = true apenas nos runners dedicados ao build de imagens Docker. Para runners de testes, mantenha privileged = false.


Parte 2: O arquivo .gitlab-ci.yml

Toda a lógica do pipeline vive em um único arquivo na raiz do projeto: .gitlab-ci.yml. O GitLab lê esse arquivo a cada push e monta o pipeline correspondente.

Vamos construir o arquivo completo, seção por seção.

Configurações globais

# .gitlab-ci.yml

default:
  interruptible: true
  retry:
    max: 1
    when:
      - runner_system_failure
      - stuck_or_timeout_failure

workflow:
  rules:
    - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
    - when: never

Explicando cada decisão:

  • interruptible: true — se um novo commit chegar enquanto o pipeline está rodando, o pipeline antigo é cancelado. Economiza recursos do runner e evita deploys fora de ordem.
  • retry — o pipeline tenta novamente em caso de falhas de infraestrutura (runner caiu, timeout), e não por falhas do seu código.
  • workflow.rules — define quais situações criam um pipeline. Branches que não estão listadas (como chore/fix-typo) não geram pipeline algum. O when: never no final age como um "else" que descarta tudo que não foi explicitamente listado.

Stages e variáveis

stages:
  - prepare
  - quality
  - test
  - build
  - deploy

variables:
  PHP_VERSION: "8.4"
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
  COMPOSER_ALLOW_SUPERUSER: "1"
  COMPOSER_NO_INTERACTION: "1"

A ordem dos stages define a sequência de execução. Um stage só começa quando todos os jobs do stage anterior passam. As variáveis configuram o ambiente de forma consistente para todos os jobs.

COMPOSER_CACHE_DIR aponta o cache do Composer para dentro do projeto. Isso permite configurar cache entre pipelines mais facilmente (veremos a seguir).

Templates reutilizáveis

# Template base para todos os jobs PHP
.php-env:
  image: "php:${PHP_VERSION}-cli-bookworm"
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip libzip-dev libsqlite3-dev
    - docker-php-ext-install zip pdo_sqlite

# Âncoras de regras para não repetir em cada job
.rules-ci: &rules-ci
  - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  - if: $CI_COMMIT_BRANCH == "main"
  - if: $CI_COMMIT_BRANCH == "develop"
  - if: $CI_COMMIT_BRANCH =~ /^feature\//

.rules-deploy: &rules-deploy
  - if: $CI_COMMIT_BRANCH == "main"
  - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/

Dois recursos poderosos do GitLab CI aqui:

  • Job templates (prefixo .): não geram jobs reais, servem apenas como base para outros jobs via extends. É como herança, você define uma vez e reutiliza em vários lugares.
  • Âncoras YAML (& define, * referencia): evitam repetir as mesmas regras em cada job. Se precisar adicionar uma branch, edita em um único lugar.

Parte 3: Os jobs do pipeline

Stage prepare: instalando dependências

composer:install:
  extends: .php-env
  stage: prepare
  script:
    - cp .env.example .env
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-progress --no-interaction
    - php artisan key:generate --ansi
  artifacts:
    paths:
      - vendor/
      - .env
    expire_in: 2 hours
  cache:
    key: composer-$CI_COMMIT_REF_SLUG
    paths:
      - .composer-cache/
  rules: *rules-ci

Este job é a base de tudo. Ele instala as dependências e as disponibiliza para todos os jobs seguintes.

O mecanismo de artifacts é o que elimina a necessidade de reinstalar dependências em cada job:

  • Os diretórios vendor/ e o arquivo .env são "empacotados" ao final do job e armazenados temporariamente no GitLab.
  • Qualquer job que declare needs: [{job: composer:install, artifacts: true}] recebe esses arquivos automaticamente antes de executar, sem baixar nada novamente.
  • expire_in: 2 hours garante limpeza automática.

O cache é diferente dos artifacts: ele persiste entre pipelines diferentes (não apenas entre jobs do mesmo pipeline). Isso acelera builds subsequentes porque o Composer não precisa baixar os pacotes da internet toda vez, apenas verifica se algo mudou no composer.lock.


Stage quality: verificação de estilo com Pint

pint:
  extends: .php-env
  stage: quality
  needs:
    - job: composer:install
      artifacts: true
  script:
    - vendor/bin/pint --test
  rules: *rules-ci

O Laravel Pint é o formatador de código oficial do Laravel, baseado no PHP-CS-Fixer. O flag --test é a chave: ele não modifica nenhum arquivo, apenas verifica se o código está em conformidade com as regras configuradas no pint.json (ou usa o preset laravel por padrão).

Se algum arquivo estiver mal formatado, o Pint retorna um código de saída diferente de zero, o job falha e o pipeline para, antes mesmo de rodar os testes. Isso dá feedback rápido ao desenvolvedor.

💡 Configure seu editor para rodar o Pint ao salvar o arquivo. O CI é a rede de segurança, não o seu fluxo de trabalho principal.


Stage test: testes automatizados com Pest

pest:
  extends: .php-env
  stage: test
  needs:
    - job: composer:install
      artifacts: true
  script:
    - php artisan test --compact
  rules: *rules-ci

O Pest é um framework de testes elegante para PHP, construído sobre o PHPUnit. O comando php artisan test é um wrapper conveniente do Laravel.

O flag --compact exibe os resultados de forma condensada, perfeito para os logs do CI onde você quer identificar rapidamente o que passou e o que falhou.

Sobre o needs: com ele você define dependências entre jobs individuais, e não entre stages inteiros. Isso significa que pint e pest podem rodar em paralelo (ambos dependem apenas de composer:install), acelerando o pipeline. O stage test só começa quando o stage quality termina, mas dentro do mesmo stage, jobs sem dependências entre si rodam simultaneamente.


Stage build: construindo e publicando a imagem Docker

.docker-publish:
  stage: build
  image: docker:27.4.0-cli
  services:
    - docker:27.4.0-dind
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    - |
      export IMAGE="${CI_REGISTRY_IMAGE}:${DOCKER_IMAGE_TAG}"
      export IMAGE_SHA="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
      docker build -t "${IMAGE}" -t "${IMAGE_SHA}" .
      docker push "${IMAGE}"
      docker push "${IMAGE_SHA}"
      echo "✓ Imagem publicada: ${IMAGE}"

docker:build:develop:
  extends: .docker-publish
  variables:
    DOCKER_IMAGE_TAG: develop
  needs:
    - job: pest
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

docker:build:main:
  extends: .docker-publish
  variables:
    DOCKER_IMAGE_TAG: latest
  needs:
    - job: pest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

docker:build:release:
  stage: build
  image: docker:27.4.0-cli
  services:
    - docker:27.4.0-dind
  needs:
    - job: pest
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    - |
      export RELEASE_VERSION="${CI_COMMIT_TAG#v}"
      export IMAGE="${CI_REGISTRY_IMAGE}:${RELEASE_VERSION}"
      export IMAGE_SHA="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
      docker build -t "${IMAGE}" -t "${CI_REGISTRY_IMAGE}:latest" -t "${IMAGE_SHA}" .
      docker push "${IMAGE}"
      docker push "${CI_REGISTRY_IMAGE}:latest"
      docker push "${IMAGE_SHA}"
      echo "✓ Release ${RELEASE_VERSION} publicada"
  rules:
    - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/

Entendendo as variáveis automáticas do GitLab:

Variável O que contém
$CI_REGISTRY URL do GitLab Container Registry
$CI_REGISTRY_USER Usuário para autenticação (automático)
$CI_REGISTRY_PASSWORD Senha para autenticação (automático)
$CI_REGISTRY_IMAGE Caminho completo da imagem (ex: registry.gitlab.com/grupo/projeto)
$CI_COMMIT_SHORT_SHA Primeiros 8 caracteres do hash do commit
$CI_COMMIT_TAG A tag Git que disparou o pipeline (ex: v1.4.2)

Por que tagear com o SHA do commit?

registry.gitlab.com/seu-grupo/api:latest     ← versão atual de produção
registry.gitlab.com/seu-grupo/api:1.4.2      ← versão semântica
registry.gitlab.com/seu-grupo/api:a1b2c3d4   ← commit exato

Com a tag do SHA, você sempre sabe qual commit gerou qual imagem. Isso é fundamental para rastreabilidade e para fazer rollback: basta saber o SHA do commit anterior e trocar a tag no servidor.

O serviço docker:27.4.0-dind (Docker-in-Docker) permite executar comandos Docker dentro de um container de CI. É por isso que o executor do Runner precisa de privileged = true.


Stage deploy: fazendo deploy na VPS via SSH

Este é o estágio final e onde a mágica acontece. O job conecta na sua VPS via SSH e atualiza o container em execução.

.deploy-ssh:
  stage: deploy
  image: alpine:3.21
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts

deploy:develop:
  extends: .deploy-ssh
  script:
    - |
      ssh $SSH_USER@$SSH_HOST_DEVELOP "
        docker pull ${CI_REGISTRY_IMAGE}:develop
        docker stop laravel-api-develop || true
        docker rm laravel-api-develop || true
        docker run -d \
          --name laravel-api-develop \
          --restart unless-stopped \
          -p 8081:80 \
          --env-file /opt/apps/api-develop/.env \
          ${CI_REGISTRY_IMAGE}:develop
        docker image prune -f
      "
  environment:
    name: develop
    url: $DEPLOY_DEVELOP_URL
  needs:
    - job: docker:build:develop
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

deploy:production:
  extends: .deploy-ssh
  script:
    - |
      ssh $SSH_USER@$SSH_HOST_PRODUCTION "
        docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD} ${CI_REGISTRY}
        docker pull ${CI_REGISTRY_IMAGE}:latest
        docker stop laravel-api || true
        docker rm laravel-api || true
        docker run -d \
          --name laravel-api \
          --restart unless-stopped \
          -p 8080:80 \
          --env-file /opt/apps/api/.env \
          ${CI_REGISTRY_IMAGE}:latest
        docker image prune -f
      "
  environment:
    name: production
    url: $DEPLOY_PRODUCTION_URL
  when: manual
  needs:
    - job: docker:build:main
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Analisando o script de deploy:

  1. docker pull — baixa a imagem mais recente do registry para o servidor.
  2. docker stop + docker rm — para e remove o container anterior. O || true evita que o script falhe se o container não existir (primeira execução).
  3. docker run — sobe o novo container com as configurações de produção. O --env-file aponta para um arquivo .env que você mantém manualmente no servidor, nunca commite credenciais de produção no repositório.
  4. docker image prune -f — limpa imagens antigas não utilizadas para evitar que o disco da VPS se esgote.

when: manual no deploy de produção é uma decisão intencional de segurança: o job de deploy para produção não executa automaticamente. Ele fica disponível na interface do GitLab aguardando a aprovação humana. Um desenvolvedor ou tech lead precisa clicar em "Play" para disparar o deploy.


Parte 4: Configurando as chaves SSH

Para o deploy funcionar, o Runner precisa se autenticar na VPS via SSH sem senha. Vamos configurar isso de forma segura usando variáveis do GitLab.

Gerando o par de chaves

Na sua máquina local (ou em qualquer lugar seguro), gere um par de chaves dedicado para o CI:

ssh-keygen -t ed25519 -C "gitlab-ci-deploy" -f ~/.ssh/gitlab_ci_deploy -N ""

Isso gera dois arquivos:

  • ~/.ssh/gitlab_ci_deploy — a chave privada (vai para o GitLab)
  • ~/.ssh/gitlab_ci_deploy.pub — a chave pública (vai para o servidor)

Adicionando a chave pública ao servidor

# Copie o conteúdo da chave pública
cat ~/.ssh/gitlab_ci_deploy.pub

# No servidor, adicione ao arquivo authorized_keys do usuário de deploy
echo "CONTEUDO_DA_CHAVE_PUBLICA" >> ~/.ssh/authorized_keys

Obtendo o Known Hosts

# Na sua máquina local, obtenha a assinatura do servidor
ssh-keyscan -H SEU_IP_DA_VPS

Copie a saída (vai parecer algo como |1|abc123...|ssh-ed25519 AAAA...).

Configurando as variáveis no GitLab

Vá em Settings > CI/CD > Variables e adicione:

Variável Valor Tipo Protegida
SSH_PRIVATE_KEY Conteúdo de gitlab_ci_deploy File ✅ Sim
SSH_KNOWN_HOSTS Saída do ssh-keyscan Variable ✅ Sim
SSH_USER Usuário SSH da VPS (ex: ubuntu) Variable ✅ Sim
SSH_HOST_PRODUCTION IP ou hostname da VPS Variable ✅ Sim
SSH_HOST_DEVELOP IP ou hostname do servidor de dev Variable ✅ Sim
DEPLOY_PRODUCTION_URL URL da API em produção Variable Não
DEPLOY_DEVELOP_URL URL do ambiente de dev Variable Não

⚠️ Marque todas as variáveis sensíveis como Masked e Protected. Masked impede que o valor apareça nos logs. Protected restringe o uso a branches e tags protegidas.


Parte 5: O Dockerfile multi-stage

O Dockerfile usa o padrão multi-stage build, essencial para imagens de produção enxutas e seguras:

# syntax=docker/dockerfile:1

# ──────────────────────────────────────────────
# Estágio 1: instala dependências via Composer
# ──────────────────────────────────────────────
FROM composer:2 AS vendor

WORKDIR /app

COPY composer.json composer.lock ./

RUN composer install \
    --no-dev \
    --no-interaction \
    --no-scripts \
    --prefer-dist \
    --optimize-autoloader

COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY routes ./routes
COPY artisan ./artisan

RUN composer dump-autoload --optimize --classmap-authoritative --no-dev

# ──────────────────────────────────────────────
# Estágio 2: imagem de runtime final
# ──────────────────────────────────────────────
FROM php:8.4-fpm-bookworm AS runtime

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    nginx supervisor libzip-dev libpng-dev \
    libonig-dev libxml2-dev libpq-dev curl \
    && docker-php-ext-install bcmath opcache pdo_mysql pdo_pgsql zip \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/html

# Copia apenas o vendor compilado do estágio anterior
COPY --from=vendor /app/vendor ./vendor

COPY app ./app
COPY bootstrap ./bootstrap
COPY config ./config
COPY database ./database
COPY public ./public
COPY resources ./resources
COPY routes ./routes
COPY artisan ./artisan

COPY docker/nginx/default.conf /etc/nginx/sites-available/default
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/php/php.ini /usr/local/etc/php/conf.d/php.ini

RUN mkdir -p storage/framework/{cache,sessions,views} storage/logs bootstrap/cache \
    && chown -R www-data:www-data storage bootstrap/cache \
    && chmod -R ug+rwx storage bootstrap/cache

COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

EXPOSE 80

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

Por que dois estágios?

  • O estágio vendor usa a imagem oficial do Composer (que inclui git, unzip e tudo mais necessário para instalar pacotes PHP) e gera a pasta vendor/ otimizada.
  • O estágio runtime começa do zero com uma imagem PHP limpa e copia apenas o vendor/ já pronto.
  • A imagem final não contém o Composer, git, ou qualquer outra ferramenta de build. Isso reduz o tamanho da imagem e a superfície de ataque em produção.

O Supervisor gerencia dois processos dentro do mesmo container: o PHP-FPM (processa as requisições PHP) e o Nginx (recebe as requisições HTTP e as repassa ao PHP-FPM). Para uma API stateless, esta é uma abordagem simples e eficaz.


Parte 6: Preparando a VPS para o deploy

Estrutura de diretórios

Crie a estrutura de diretórios no servidor:

sudo mkdir -p /opt/apps/api
sudo chown -R $USER:$USER /opt/apps/api

Arquivo .env de produção

Crie o arquivo .env de produção diretamente no servidor:

nano /opt/apps/api/.env

APP_NAME="Minha API"
APP_ENV=production
APP_KEY=base64:SUA_APP_KEY_AQUI
APP_DEBUG=false
APP_URL=https://api.meudominio.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=minha_api_prod
DB_USERNAME=usuario_prod
DB_PASSWORD=senha_super_segura

# ... demais variáveis

🔒 Este arquivo nunca deve ir para o repositório. Ele contém segredos de produção e deve existir apenas no servidor.

Autenticando o Docker no Registry

No servidor de produção, autentique o Docker no GitLab Registry para que ele consiga fazer docker pull da imagem privada:

docker login registry.gitlab.com

Ou, para automatizar sem senha interativa, crie um Deploy Token no GitLab (Settings > Repository > Deploy tokens) com permissão read_registry e use:

docker login registry.gitlab.com -u SEU_DEPLOY_TOKEN_USER -p SEU_DEPLOY_TOKEN_PASSWORD

O Docker salva as credenciais em ~/.docker/config.json, então futuras chamadas docker pull funcionam sem autenticação manual.


O arquivo .gitlab-ci.yml completo

# .gitlab-ci.yml

default:
  interruptible: true
  retry:
    max: 1
    when:
      - runner_system_failure
      - stuck_or_timeout_failure

workflow:
  rules:
    - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"
    - if: $CI_COMMIT_BRANCH == "develop"
    - if: $CI_COMMIT_BRANCH =~ /^feature\//
    - when: never

stages:
  - prepare
  - quality
  - test
  - build
  - deploy

variables:
  PHP_VERSION: "8.4"
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: "/certs"
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
  COMPOSER_ALLOW_SUPERUSER: "1"
  COMPOSER_NO_INTERACTION: "1"

# ── Templates ────────────────────────────────────────────────────────────────

.php-env:
  image: "php:${PHP_VERSION}-cli-bookworm"
  before_script:
    - apt-get update -qq && apt-get install -y -qq git unzip libzip-dev libsqlite3-dev
    - docker-php-ext-install zip pdo_sqlite

.rules-ci: &rules-ci
  - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  - if: $CI_COMMIT_BRANCH == "main"
  - if: $CI_COMMIT_BRANCH == "develop"
  - if: $CI_COMMIT_BRANCH =~ /^feature\//

.deploy-ssh:
  stage: deploy
  image: alpine:3.21
  before_script:
    - apk add --no-cache openssh-client
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - chmod 644 ~/.ssh/known_hosts

# ── Stage: prepare ────────────────────────────────────────────────────────────

composer:install:
  extends: .php-env
  stage: prepare
  script:
    - cp .env.example .env
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
    - composer install --prefer-dist --no-progress --no-interaction
    - php artisan key:generate --ansi
  artifacts:
    paths:
      - vendor/
      - .env
    expire_in: 2 hours
  cache:
    key: composer-$CI_COMMIT_REF_SLUG
    paths:
      - .composer-cache/
  rules: *rules-ci

# ── Stage: quality ────────────────────────────────────────────────────────────

pint:
  extends: .php-env
  stage: quality
  needs:
    - job: composer:install
      artifacts: true
  script:
    - vendor/bin/pint --test
  rules: *rules-ci

# ── Stage: test ───────────────────────────────────────────────────────────────

pest:
  extends: .php-env
  stage: test
  needs:
    - job: composer:install
      artifacts: true
  script:
    - php artisan test --compact
  rules: *rules-ci

# ── Stage: build ──────────────────────────────────────────────────────────────

.docker-publish:
  stage: build
  image: docker:27.4.0-cli
  services:
    - docker:27.4.0-dind
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    - |
      export IMAGE="${CI_REGISTRY_IMAGE}:${DOCKER_IMAGE_TAG}"
      export IMAGE_SHA="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
      docker build -t "${IMAGE}" -t "${IMAGE_SHA}" .
      docker push "${IMAGE}"
      docker push "${IMAGE_SHA}"
      echo "✓ Publicado: ${IMAGE}"

docker:build:develop:
  extends: .docker-publish
  variables:
    DOCKER_IMAGE_TAG: develop
  needs:
    - job: pest
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

docker:build:main:
  extends: .docker-publish
  variables:
    DOCKER_IMAGE_TAG: latest
  needs:
    - job: pest
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

docker:build:release:
  stage: build
  image: docker:27.4.0-cli
  services:
    - docker:27.4.0-dind
  needs:
    - job: pest
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
  script:
    - |
      export RELEASE_VERSION="${CI_COMMIT_TAG#v}"
      export IMAGE="${CI_REGISTRY_IMAGE}:${RELEASE_VERSION}"
      docker build -t "${IMAGE}" -t "${CI_REGISTRY_IMAGE}:latest" -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}" .
      docker push "${IMAGE}"
      docker push "${CI_REGISTRY_IMAGE}:latest"
      docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
      echo "✓ Release ${RELEASE_VERSION} publicada"
  rules:
    - if: $CI_COMMIT_TAG =~ /^v?\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/

# ── Stage: deploy ─────────────────────────────────────────────────────────────

deploy:develop:
  extends: .deploy-ssh
  script:
    - |
      ssh $SSH_USER@$SSH_HOST_DEVELOP "
        docker pull ${CI_REGISTRY_IMAGE}:develop
        docker stop laravel-api-develop || true
        docker rm laravel-api-develop || true
        docker run -d \
          --name laravel-api-develop \
          --restart unless-stopped \
          -p 8081:80 \
          --env-file /opt/apps/api-develop/.env \
          ${CI_REGISTRY_IMAGE}:develop
        docker image prune -f
      "
  environment:
    name: develop
    url: $DEPLOY_DEVELOP_URL
  needs:
    - job: docker:build:develop
  rules:
    - if: $CI_COMMIT_BRANCH == "develop"

deploy:production:
  extends: .deploy-ssh
  script:
    - |
      ssh $SSH_USER@$SSH_HOST_PRODUCTION "
        docker pull ${CI_REGISTRY_IMAGE}:latest
        docker stop laravel-api || true
        docker rm laravel-api || true
        docker run -d \
          --name laravel-api \
          --restart unless-stopped \
          -p 8080:80 \
          --env-file /opt/apps/api/.env \
          ${CI_REGISTRY_IMAGE}:latest
        docker image prune -f
      "
  environment:
    name: production
    url: $DEPLOY_PRODUCTION_URL
  when: manual
  needs:
    - job: docker:build:main
  rules:
    - if: $CI_COMMIT_BRANCH == "main"


O fluxo completo em um diagrama

Push na branch main
        │
        ▼
┌────────────────────┐
│  composer:install  │ ← instala deps, gera artefato vendor/ + .env
└──────────┬─────────┘
           │ artifacts
      ┌────┴────┐
      ▼         ▼
  ┌───────┐ ┌──────┐
  │ pint  │ │ pest │ ← rodam em paralelo (dependem apenas de composer:install)
  └───────┘ └──┬───┘
               │ pass
               ▼
   ┌────────────────────┐
   │  docker:build:main │ ← build + push :latest e :sha
   └──────────┬─────────┘
              │
              ▼
   ┌───────────────────┐
   │  deploy:production│ ← aguarda clique manual [▶ Play]
   │  SSH → VPS        │
   │  docker pull      │
   │  docker run       │
   └───────────────────┘


Checklist de configuração

Antes de testar o pipeline pela primeira vez, verifique:

  • [ ] GitLab Runner instalado e registrado no projeto
  • [ ] Executor configurado como docker com privileged = true
  • [ ] Par de chaves SSH gerado e configurado
  • [ ] Chave pública adicionada ao authorized_keys da VPS
  • [ ] Variáveis de CI/CD configuradas no GitLab
  • [ ] Arquivo .env de produção criado manualmente na VPS
  • [ ] Docker autenticado no GitLab Registry na VPS
  • [ ] Diretório /opt/apps/api criado na VPS
  • [ ] pint.json configurado no projeto (ou usando o preset padrão laravel)
  • [ ] Testes Pest passando localmente antes do primeiro push

Conclusão

Com essa configuração, cada push no seu repositório dispara um pipeline que garante automaticamente que o código está bem formatado, os testes passam e, quando você quiser, a nova versão é entregue ao servidor com um único clique.

O resultado é confiança para deployar. Não mais SSH manual, não mais "será que quebrou alguma coisa?", não mais deploy às sextas às 17h com o coração na mão.

Esta estrutura é simples o suficiente para um time de uma pessoa e escalável para crescer com o projeto: você pode adicionar novos ambientes, novos stages de análise estática (PHPStan, por exemplo) ou migrar para um orquestrador mais sofisticado depois — sem precisar reescrever tudo.