DEV Community

Cover image for CI/CD Profissional com GitLab e Laravel em VPS: Do Push ao Deploy Automático
Matheus Mendonça Sesso
Matheus Mendonça Sesso

Posted on

CI/CD Profissional com GitLab e Laravel em VPS: Do Push ao Deploy Automático

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
Enter fullscreen mode Exit fullscreen mode
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)    │          │              │
└──────────────┘          └──────────────────┘          └──────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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.]+)?$/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.]+)?$/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 ""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Obtendo o Known Hosts

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

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Arquivo .env de produção

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

nano /opt/apps/api/.env
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

🔒 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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       │
   └───────────────────┘
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)