Active Nerds
Module #3 · advanced Article

GitLab CI/CD Integration: Automated Container Builds and Manifest Deployments

The modern pattern is push-based CI (GitLab builds + tests + pushes image) combined with pull-based CD (ArgoCD detects config changes and syncs to cluster) —...

8 min read+20 XPPublished 2026-07-03

Quick Answer: The modern pattern is push-based CI (GitLab builds + tests + pushes image) combined with pull-based CD (ArgoCD detects config changes and syncs to cluster) — separating build concerns from deployment concerns.

Detailed Answer:

Architecture Overview:

Developer pushes code
        │
        ▼
GitLab CI Pipeline
├── build:    docker build + push to ECR
├── test:     unit tests, integration tests
├── scan:     trivy image scan, SAST
├── publish:  tag image with commit SHA
└── update:   bump image tag in config repo
        │
        ▼
Config Repo (k8s-configs)
└── ArgoCD detects change (polls every 3 min or webhook)
        │
        ▼
ArgoCD syncs to cluster
├── Applies new Deployment manifest
├── Monitors rollout health
└── Auto-rollback if health checks fail
# .gitlab-ci.yml — Complete pipeline
stages:
  - build
  - test
  - scan
  - publish
  - deploy

variables:
  IMAGE_NAME: $CI_REGISTRY_IMAGE
  IMAGE_TAG: $CI_COMMIT_SHA
  AWS_REGION: us-east-1
  ECR_REGISTRY: 123456789012.dkr.ecr.us-east-1.amazonaws.com

# ── BUILD ───────────────────────────────────────────────────────
build:
  stage: build
  image: docker:24-dind
  services:
  - docker:24-dind
  variables:
    DOCKER_BUILDKIT: "1"
  before_script:
  - aws ecr get-login-password --region $AWS_REGION |
      docker login --username AWS --password-stdin $ECR_REGISTRY
  script:
  - |
    docker build \
      --cache-from $ECR_REGISTRY/my-app:latest \
      --build-arg BUILDKIT_INLINE_CACHE=1 \
      --tag $ECR_REGISTRY/my-app:$IMAGE_TAG \
      --tag $ECR_REGISTRY/my-app:latest \
      .
  - docker push $ECR_REGISTRY/my-app:$IMAGE_TAG
  - docker push $ECR_REGISTRY/my-app:latest
  only:
  - main
  - merge_requests

# ── TEST ────────────────────────────────────────────────────────
unit-test:
  stage: test
  image: $ECR_REGISTRY/my-app:$IMAGE_TAG
  script:
  - pytest tests/unit/ --junitxml=report.xml
  artifacts:
    reports:
      junit: report.xml
  only:
  - main
  - merge_requests

integration-test:
  stage: test
  image: $ECR_REGISTRY/my-app:$IMAGE_TAG
  services:
  - name: postgres:16
    alias: postgres
  variables:
    POSTGRES_DB: testdb
    POSTGRES_PASSWORD: testpassword
  script:
  - pytest tests/integration/ --junitxml=integration-report.xml
  only:
  - main

# ── SECURITY SCAN ───────────────────────────────────────────────
trivy-scan:
  stage: scan
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
  - |
    trivy image \
      --exit-code 1 \
      --severity HIGH,CRITICAL \
      --no-progress \
      --format table \
      $ECR_REGISTRY/my-app:$IMAGE_TAG
  allow_failure: false              # Fail pipeline on HIGH/CRITICAL vulns
  only:
  - main

# ── DEPLOY (GitOps pattern — update config repo) ─────────────────
update-staging:
  stage: deploy
  image: alpine/git:latest
  before_script:
  - git config --global user.email "gitlab-ci@company.com"
  - git config --global user.name "GitLab CI"
  script:
  - |
    git clone https://oauth2:$CONFIG_REPO_TOKEN@gitlab.com/myorg/k8s-configs.git
    cd k8s-configs

    # Update image tag in staging overlay
    sed -i "s|newTag:.*|newTag: $IMAGE_TAG|" \
      overlays/staging/kustomization.yaml

    git add .
    git commit -m "chore: update my-app to $IMAGE_TAG [skip ci]

    Pipeline: $CI_PIPELINE_URL
    Commit: $CI_COMMIT_URL
    Author: $GITLAB_USER_EMAIL"

    git push origin main
  environment:
    name: staging
  only:
  - main

update-production:
  stage: deploy
  image: alpine/git:latest
  script:
  - |
    git clone https://oauth2:$CONFIG_REPO_TOKEN@gitlab.com/myorg/k8s-configs.git
    cd k8s-configs
    sed -i "s|newTag:.*|newTag: $IMAGE_TAG|" \
      overlays/production/kustomization.yaml
    git add .
    git commit -m "chore: promote my-app $IMAGE_TAG to production [skip ci]"
    git push origin main
  environment:
    name: production
  when: manual                      # Require manual approval for production
  only:
  - main

Config Repo Structure (k8s-configs):

k8s-configs/
├── base/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── hpa.yaml
│   └── kustomization.yaml
├── overlays/
│   ├── staging/
│   │   ├── kustomization.yaml      # newTag: abc123 (updated by CI)
│   │   ├── replica-patch.yaml      # replicas: 2
│   │   └── resource-patch.yaml     # smaller resources
│   └── production/
│       ├── kustomization.yaml      # newTag: def456 (updated by CI)
│       ├── replica-patch.yaml      # replicas: 5
│       └── resource-patch.yaml     # full resources
└── argocd/
    ├── staging-app.yaml
    └── production-app.yaml

ArgoCD Application Definitions:

# argocd/staging-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app-staging
  namespace: argocd
  finalizers:
  - resources-finalizer.argocd.argoproj.io
spec:
  project: staging
  source:
    repoURL: https://gitlab.com/myorg/k8s-configs
    targetRevision: main
    path: overlays/staging
  destination:
    server: https://kubernetes.default.svc
    namespace: staging
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
      allowEmpty: false
    syncOptions:
    - CreateNamespace=true
    - ApplyOutOfSyncOnly=true
    - PrunePropagationPolicy=foreground
    retry:
      limit: 3
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m
  # Health checks — ArgoCD monitors these after sync
  ignoreDifferences:
  - group: apps
    kind: Deployment
    jsonPointers:
    - /spec/replicas                # Ignore HPA-managed replica count

---
# ArgoCD Project — scopes what staging app can access
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: staging
  namespace: argocd
spec:
  description: Staging environment applications
  sourceRepos:
  - https://gitlab.com/myorg/k8s-configs
  destinations:
  - namespace: staging
    server: https://kubernetes.default.svc
  clusterResourceWhitelist:
  - group: ""
    kind: Namespace
  namespaceResourceWhitelist:
  - group: "*"
    kind: "*"
  roles:
  - name: developer
    description: Developers can sync but not delete
    policies:
    - p, proj:staging:developer, applications, sync, staging/*, allow
    - p, proj:staging:developer, applications, get, staging/*, allow
    groups:
    - frontend-team
    - backend-team

Common Mistakes to Avoid:

  • Running kubectl apply directly from CI — creates drift ArgoCD can't track
  • Using latest image tag — ArgoCD can't detect changes if tag doesn't change
  • Storing kubeconfig in CI variables — use IRSA or ArgoCD's own sync instead
  • Not separating app repo from config repo — mixing concerns makes rollbacks harder
  • Forgetting [skip ci] in config repo commits — causes infinite CI loops

Key Takeaway: Separate your CI (build/test/push) from your CD (deploy/sync) — CI is push-based and triggered by code changes; CD is pull-based via ArgoCD watching the config repo, giving you a complete audit trail and drift detection.


Finished reading?

Mark as complete to claim your +20 XP and track progress.