← Blog / CI/CD

GitLab CI/CD Pipeline for a Docker Application — Step by Step

Introduction

Manual deployments are a straight road to errors and downtime. A well-configured CI/CD pipeline ensures that every code change automatically goes through tests, image building and deployment — without human involvement. In this article we’ll build a complete GitLab CI/CD pipeline for a Docker application, from scratch to automatic production deployment.


Pipeline Architecture

git push → GitLab CI → test → build → push to registry → deploy to server

Stages:

  1. test — run unit tests
  2. build — build the Docker image
  3. push — push the image to GitLab Container Registry
  4. deploy:staging — deploy to staging environment
  5. deploy:production — deploy to production (manual approval)

Dockerfile

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY src/ .

EXPOSE 8000

RUN useradd -m -u 1001 appuser && chown -R appuser:appuser /app
USER appuser

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]

.gitlab-ci.yml — Complete Configuration

variables:
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  IMAGE_TAG: $CI_COMMIT_SHORT_SHA
  DOCKER_DRIVER: overlay2

stages:
  - test
  - build
  - push
  - deploy

cache:
  key: "$CI_COMMIT_REF_SLUG"
  paths:
    - .pip-cache/

unit-tests:
  stage: test
  image: python:3.12-slim
  script:
    - pip install -r requirements.txt
    - pip install pytest pytest-cov
    - pytest tests/ -v --cov=src --cov-report=xml
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

build-image:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker build --tag $IMAGE_NAME:$IMAGE_TAG --tag $IMAGE_NAME:latest .
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_BRANCH == "develop"'

push-image:
  stage: push
  image: docker:24
  services:
    - docker:24-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker push $IMAGE_NAME:$IMAGE_TAG
    - docker push $IMAGE_NAME:latest

deploy-staging:
  stage: deploy
  environment:
    name: staging
    url: https://staging.example.com
  script:
    - |
      ssh $STAGING_USER@$STAGING_HOST << EOF
        docker pull $IMAGE_NAME:$IMAGE_TAG
        docker stop myapp-staging || true
        docker run -d --name myapp-staging --restart unless-stopped \
          -p 8001:8000 $IMAGE_NAME:$IMAGE_TAG
      EOF
  rules:
    - if: '$CI_COMMIT_BRANCH == "develop"'

deploy-production:
  stage: deploy
  environment:
    name: production
  when: manual
  script:
    - |
      ssh $PROD_USER@$PROD_HOST << EOF
        docker pull $IMAGE_NAME:$IMAGE_TAG
        docker stop myapp-prod || true
        docker run -d --name myapp-prod --restart unless-stopped \
          -p 8000:8000 $IMAGE_NAME:$IMAGE_TAG
      EOF
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

Configuring Variables in GitLab

Go to Settings → CI/CD → Variables and add:

VariableTypeDescription
SSH_PRIVATE_KEY_STAGINGFileSSH key for staging server
SSH_PRIVATE_KEY_PRODFileSSH key for production server
STAGING_HOSTVariableStaging server IP/hostname
PROD_HOSTVariableProduction server IP/hostname

Preparing the Target Server

curl -fsSL https://get.docker.com | sh
usermod -aG docker deploy

useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/.ssh
echo "$(cat id_rsa_deploy.pub)" >> /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys

Summary

You now have a complete CI/CD pipeline that:

  • Automatically tests code on every push
  • Builds and publishes a Docker image to the registry
  • Deploys to staging automatically on push to develop
  • Deploys to production after manual approval from the main branch

In the next article we’ll extend the pipeline with Trivy image security scanning and SonarQube code quality analysis.