This guide shows how to build a professional CI/CD pipeline on GitLab for a Laravel (or any) application using Docker, buildx, automated testing, image building, and secure production deployment.
📋 Table of Contents
1. Introduction
GitLab CI/CD enables full automation of testing, building, and deploying your application. Combined with Docker, you get reproducible builds, fast tests, and secure deployments. Below is a practical description of a pipeline that works in production.
2. Pipeline Structure
Full Example: .gitlab-ci.yml
Below is a complete, ready-to-copy example of a production-ready .gitlab-ci.yml
file for a Laravel project with Docker:
image: docker:latest
stages:
- test
- build
- deploy
variables:
DOCKER_BUILDKIT: 1
DOCKER_HOST: tcp://docker:2375/
DOCKER_TLS_CERTDIR: ""
IMAGE_NAMESPACE: dommmin/laravel-production
REGISTRY: registry.gitlab.com
services:
- docker:dind
before_script:
- docker info
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
test_php:
stage: test
image: dommin/php-8.4-fpm:latest
needs: [test_node]
before_script:
- git config --global --add safe.directory $(pwd)
- composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
script:
- cp .env.testing .env
- php artisan key:generate
- php artisan ziggy:generate
- composer larastan
- composer pint
- php artisan test --env=testing
variables:
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
SESSION_DRIVER: array
artifacts:
paths:
- storage/logs/
expire_in: 1 week
only:
- main
- merge_requests
test_node:
stage: test
image: dommin/php-8.4-fpm:latest
before_script:
- git config --global --add safe.directory $(pwd)
- npm ci
- npm run build
script:
- npm run format
- npm run types
- npm run lint
artifacts:
paths:
- node_modules/
- public/build/
expire_in: 1 hour
only:
- main
- merge_requests
build_node:
stage: build
image: docker:24.0.5
needs: [test_node]
script:
- docker buildx create --use
- mkdir -p docker/node docker/php
- cat "$ENV_FILE" > docker/node/.env
- cat "$ENV_FILE" > docker/php/.env
- |
docker buildx build \
--platform linux/amd64 \
--file docker/node/Dockerfile \
--tag $REGISTRY/$IMAGE_NAMESPACE/node:latest \
--push \
.
only:
- main
build_php:
stage: build
image: docker:24.0.5
needs: [test_php, build_node]
script:
- docker buildx create --use
- |
docker buildx build \
--platform linux/amd64 \
--file docker/php/Dockerfile \
--tag $REGISTRY/$IMAGE_NAMESPACE/php:latest \
--push \
--build-context node=docker-image://$REGISTRY/$IMAGE_NAMESPACE/node:latest \
.
only:
- main
build_nginx:
stage: build
image: docker:24.0.5
needs: [test_node, build_node]
script:
- docker buildx create --use
- |
docker buildx build \
--platform linux/amd64 \
--file docker/nginx/Dockerfile \
--tag $REGISTRY/$IMAGE_NAMESPACE/nginx:latest \
--push \
--build-context node=docker-image://$REGISTRY/$IMAGE_NAMESPACE/node:latest \
--build-arg HTPASSWD_USER=$HTPASSWD_USER \
--build-arg HTPASSWD_PASS=$HTPASSWD_PASS \
.
only:
- main
deploy_production:
stage: deploy
image: alpine:3.19
needs: [build_node, build_php, build_nginx]
before_script:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -p $SSH_PORT $SSH_HOST >> ~/.ssh/known_hosts
script:
- cat "$ENV_FILE" > .env
- echo >> .env
- |
echo "REGISTRY=$REGISTRY" >> .env
echo "IMAGE_NAMESPACE=$IMAGE_NAMESPACE" >> .env
echo "NODE_IMAGE_NAME=$IMAGE_NAMESPACE/node" >> .env
echo "PHP_IMAGE_NAME=$IMAGE_NAMESPACE/php" >> .env
echo "NGINX_IMAGE_NAME=$IMAGE_NAMESPACE/nginx" >> .env
echo "TAG=latest" >> .env
echo "CI_REGISTRY_USER=$CI_REGISTRY_USER" >> .env
echo "CI_REGISTRY_PASSWORD=$CI_REGISTRY_PASSWORD" >> .env
- scp -P $SSH_PORT docker-compose.production.yml $SSH_USER@$SSH_HOST:~/laravel/docker-compose.yml
- scp -P $SSH_PORT .env deploy.sh $SSH_USER@$SSH_HOST:~/laravel/
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd ~/laravel && chmod +x deploy.sh && ./deploy.sh"
only:
- main
3. Testing: PHP and Node
PHP Tests
test_php:
stage: test
image: dommin/php-8.4-fpm:latest
needs: [test_node]
before_script:
- composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
script:
- cp .env.testing .env
- php artisan key:generate
- php artisan ziggy:generate
- composer larastan
- composer pint
- php artisan test --env=testing
variables:
DB_CONNECTION: sqlite
DB_DATABASE: ":memory:"
SESSION_DRIVER: array
- Tests run on a fresh Docker image.
- SQLite in-memory database is used for speed.
- Additionally: static analysis (
larastan
), formatting (pint
).
Node Tests
test_node:
stage: test
image: dommin/php-8.4-fpm:latest
before_script:
- npm ci
- npm run build
script:
- npm run format
- npm run types
- npm run lint
- Checks formatting, types, and lints JS/TS code.
- Builds the frontend before further steps.
4. Building Docker Images
Each component (Node, PHP, Nginx) is built separately using docker buildx
(supports multi-arch, cache, contexts):
build_node:
stage: build
image: docker:24.0.5
needs: [test_node]
script:
- docker buildx create --use
- docker buildx build \
--platform linux/amd64 \
--file docker/node/Dockerfile \
--tag $REGISTRY/$IMAGE_NAMESPACE/node:latest \
--push \
.
- Buildx allows building images for different architectures (e.g., ARM, AMD64).
- Images are pushed to a private registry (e.g., GitLab Registry).
PHP and Nginx images are built analogously, passing build context (e.g., Node artifacts to PHP).
5. Automated Production Deployment
Deployment is done via SSH and a simple bash script:
deploy_production:
stage: deploy
image: alpine:3.19
needs: [build_node, build_php, build_nginx]
before_script:
- apk add --no-cache openssh-client
- echo "$SSH_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -p $SSH_PORT $SSH_HOST >> ~/.ssh/known_hosts
script:
- scp -P $SSH_PORT docker-compose.production.yml $SSH_USER@$SSH_HOST:~/laravel/docker-compose.yml
- scp -P $SSH_PORT .env deploy.sh $SSH_USER@$SSH_HOST:~/laravel/
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd ~/laravel && chmod +x deploy.sh && ./deploy.sh"
- SSH key is passed as a CI/CD variable.
- Config files and the deployment script are copied to the server.
- Deployment is triggered automatically.
6. Production Checklist & Best Practices
- ENV_FILE variable – ensure the production environment is passed correctly.
- SSH keys – store them securely in GitLab CI/CD Variables.
- Image registry – images are pushed to a private registry (e.g., GitLab Registry).
- Tests must pass – build and deploy depend on tests.
- Docker buildx – use for better performance and multi-arch support.
- Artifacts – pass only necessary files (e.g., frontend build).
- Automatic cache clearing – e.g.,
php artisan config:clear
after deployment. - Logs – monitor pipeline and server logs after deployment.
7. Common Issues & Debugging
- Docker connection error: check if
docker:dind
is running andDOCKER_HOST
is set. - SSH permission denied: make sure the key has correct permissions and is passed properly.
- Outdated images: check if buildx is not using cache (add
--no-cache
if needed). - Test failures: pipeline will stop at the test stage – check logs in GitLab.
- Network issues: ensure the production server accepts SSH connections from the runner.
8. Conclusion
With such a pipeline, you have a fully automated, repeatable, and secure deployment process with tests, Docker build, and automatic deploy. This is the foundation of modern DevOps in any company!
For more Laravel & DevOps tips: Dominik Jasiński on LinkedIn