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

6 min readUpdated on

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.

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:

yaml
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

yaml
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

yaml
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):

yaml
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:

yaml
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!

Related articles

Maintenance and growth

Do you need cleaner deployments or maintenance?

I help teams stabilize environments, improve release flow, and keep existing systems moving without unnecessary disruption.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.