VOOZH about

URL: https://dev.to/matheussesso/cicd-profissional-com-gitlab-e-laravel-em-vps-do-push-ao-deploy-automatico-1igh

⇱ CI/CD Profissional com GitLab e Laravel em VPS: Do Push ao Deploy Automático - 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
# ──────────────────────────────────────────────
FROMcomposer:2ASvendor

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
# ──────────────────────────────────────────────
FROMphp:8.4-fpm-bookwormASruntime

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.