CI/CD: GitLab Pipeline

Jun 27, 2026

Example .gitlab-ci.yaml with descriptive comments

#automation #GitLab #CI/CD #DevOps

Example .gitlab-ci.yml

stages:                # Stages in order of execution
  - build              # (Jobs in the same stage can run in parallel)
  - test
  - deploy

default:               # Defaults that apply to all jobs when not defined
  image: node


.standard-rules:       # Make a hidden job to hold the common rules
  rules:
    - if: $CI_PIPELINE_SOURCE == 'merge_request_event'
                       # Run for all changes to a merge request's source branch

    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
                       # Run for all changes to the default branch

.test-cache:
  cache:               # Cache modules in between jobs
    key:
      files:           # Cache gets rebuilt if file changes
        - package-lock.json # npm install --save-dev htmlhint markdownlint-cli2
    paths:
      - .npm/
  before_script:
    - npm ci --cache .npm --prefer-offline

build-job:
  extends:             # Reuse the configuration in `.standard-rules` here
    - .standard-rules
  stage: build         # Set this job to run in the `build` stage
  tags:
    - kubernetes       # Runner selection "kubernetes"
  script:
    - npm install
    - npm run build
  artifacts:           # Save artifacts so later jobs can retrieve them with "dependencies:"
    paths:
      - "build/"

lint-markdown:
  stage: test
  extends:
    - .standard-rules
    - .test-cache
  dependencies: []     # Don't fetch any artifacts
  script:
    - npx markdownlint-cli2 "blog/**/*.md" "docs/**/*.md"
  allow_failure: true  # This job fails right now, but don't let it stop the pipeline.

test-html:
  stage: test
  extends:
    - .standard-rules  # Reuse the configuration in `.standard-rules` here
    - .test-cache
  dependencies:
    - build-job        # Only fetch artifacts from `build-job`
  script:
    - npx htmlhint build/

integration-test:      # Add postgres DB instance
  stage: test
  image: alpine:3.23.2
  extends:
    - .standard-rules
  services:
    - name: postgres:17
      alias: db
  variables:
    POSTGRES_DB: custom_db
    POSTGRES_USER: custom_user
    POSTGRES_PASSWORD: custom_pass
  script:
    - cat /etc/hosts
    - apk add postgresql17-client
    - export PGPASSWORD=$POSTGRES_PASSWORD
    - psql -h "postgres" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "SELECT 'OK' AS status;"

pages:
  stage: deploy
  image: busybox       # Override the default `image` value with `busybox`
  dependencies:
    - build-job
  script:
    - mv build/ public/
  environment:                   # Register this job as a deployment
    name: production             # Name shown in the Environments UI
    url: https://$CI_PROJECT_NAMESPACE.gitlab.io/$CI_PROJECT_NAME  
                                 # "View deployment" - Link to the live site
  artifacts:
    paths:
      - "public/"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

# Build a Docker image and push it to the project's container registry
# Uses the Dockerfile in the repo root. The image is tagged with the branch name,
# so each branch publishes its own image (e.g. registry.example.com/group/proj:main).
build-image:
  stage: build
  image: docker:24.0.5-cli       # job runs in a container that has the docker CLI
  services:
    - docker:24.0.5-dind         # sidecar daemon - the CLI talks to this
  variables:
    IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH    # only build images on main
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
    - docker build -t $IMAGE_TAG .                   # "." = repo root, finds Dockerfile
    - docker push $IMAGE_TAG

# upload a build artifact to the generic package registry on git tag
# Versioned by the tag (e.g. v1.2.0), so each release has its own download URL.
publish:
  stage: release
  image: curlimages/curl:latest
  dependencies:
    - build-job                  # pull build/ artifacts from build-job
  rules:
    - if: $CI_COMMIT_TAG         # only run when a git tag is pushed
  script:
    - |
      curl --location --fail --user "gitlab-ci-token:${CI_JOB_TOKEN}" \
        --upload-file build/app.tar.gz \
        "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/my_package/${CI_COMMIT_TAG}/app.tar.gz"


# Example Push-Based Deployment to k8s
deploy_to_k8s:
  stage: deploy
  image: alpine/kubectl:1.34
  script:
    - echo "$KUBE_CA_PEM" | base64 -d > ca.crt
    - export KUBECONFIG=$(pwd)/kubeconfig
    - cat "$KUBE_CONFIG_SECRET_FILE" > "$KUBECONFIG"
    - |
      yq eval ".spec.template.spec.containers[0].image = \"${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}\"" \
        -i k8s/deployment.yaml
    - kubectl apply -f k8s/deployment.yaml
  only:
    - main                       # run only on the production branch
  environment:
    name: production
    url: https://demo.example.com