🚀 Production-Ready CI/CD with Docker on GitLab – Practical Guide

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 and DOCKER_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

Comments (0)
Leave a comment

© 2025 All rights reserved.