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 = trueapenas nos runners dedicados ao build de imagens Docker. Para runners de testes, mantenhaprivileged = 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 (comochore/fix-typo) não geram pipeline algum. Owhen: neverno 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 viaextends. É 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.envsã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 hoursgarante 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:
-
docker pull— baixa a imagem mais recente do registry para o servidor. -
docker stop+docker rm— para e remove o container anterior. O|| trueevita que o script falhe se o container não existir (primeira execução). -
docker run— sobe o novo container com as configurações de produção. O--env-fileaponta para um arquivo.envque você mantém manualmente no servidor, nunca commite credenciais de produção no repositório. -
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
vendorusa a imagem oficial do Composer (que inclui git, unzip e tudo mais necessário para instalar pacotes PHP) e gera a pastavendor/otimizada. - O estágio
runtimecomeça do zero com uma imagem PHP limpa e copia apenas ovendor/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
dockercomprivileged = true - [ ] Par de chaves SSH gerado e configurado
- [ ] Chave pública adicionada ao
authorized_keysda VPS - [ ] Variáveis de CI/CD configuradas no GitLab
- [ ] Arquivo
.envde produção criado manualmente na VPS - [ ] Docker autenticado no GitLab Registry na VPS
- [ ] Diretório
/opt/apps/apicriado na VPS - [ ]
pint.jsonconfigurado no projeto (ou usando o preset padrãolaravel) - [ ] 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)