Docker deploy pipeline: build, push, kubectl rollout

Published: 2026-03-07

The minimal viable deployment pipeline: build image → push to registry → update the Kubernetes deployment. Here's the full GitLab CI implementation for a Python/FastAPI app with a self-hosted registry, including tag management, rollout verification, and multi-environment patterns.


The pipeline structure

yamlstages:
  - build
  - deploy

variables:
  REGISTRY: registry.example.com
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_BUILDKIT: "1"

Two stages. Build produces an image tagged with the commit SHA. Deploy applies it to the cluster and waits for completion.


Build and push

yamlbuild:
  stage: build
  image: docker:27
  services:
    - name: docker:27-dind
      alias: docker
  before_script:
    - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin
  script:
    - |
      IMAGE="${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"
      LATEST="${REGISTRY}/${CI_PROJECT_NAME}:latest"
      docker buildx build \
        --platform linux/amd64 \
        --cache-from "${LATEST}" \
        --build-arg BUILDKIT_INLINE_CACHE=1 \
        --tag "${IMAGE}" \
        --tag "${LATEST}" \
        --push \
        .
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

--cache-from latest reuses layer cache from the previous build. --build-arg BUILDKIT_INLINE_CACHE=1 embeds cache metadata in the image so the next build can use it even when pulling from the registry.

The short SHA tag is immutable — it identifies exactly which commit is running. The latest tag is mutable and used for cache only.


Deploy job

yamldeploy:
  stage: deploy
  image: bitnami/kubectl:latest
  needs: [build]
  before_script:
    - echo "${KUBECONFIG_BASE64}" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig
  script:
    - |
      IMAGE="${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"

      kubectl set image deployment/${CI_PROJECT_NAME} \
        app="${IMAGE}" \
        --namespace="${NAMESPACE}"

      kubectl rollout status deployment/${CI_PROJECT_NAME} \
        --namespace="${NAMESPACE}" \
        --timeout=120s
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

kubectl set image updates the container image in-place — no manifest editing. kubectl rollout status --timeout=120s blocks until the rollout completes or fails. If the new pods crash-loop, the CI job fails and old pods keep running.


The kubeconfig secret

Store the kubeconfig as a base64-encoded CI variable KUBECONFIG_BASE64:

bashcat ~/.kube/my-cluster.yaml | base64 -w0

Copy into GitLab CI → Settings → Variables → add as masked, protected. In the job, decode to a file and point KUBECONFIG at it. Never write it to a path that CI artifacts might upload.

Use a dedicated ServiceAccount with minimal RBAC — don't use admin kubeconfig in CI:

yamlapiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deployer
  namespace: app
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "patch", "update"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]

Multi-environment pipeline

yaml.deploy-template: &deploy-template
  stage: deploy
  image: bitnami/kubectl:latest
  before_script:
    - echo "${KUBECONFIG_BASE64}" | base64 -d > /tmp/kubeconfig
    - export KUBECONFIG=/tmp/kubeconfig
  script:
    - kubectl set image deployment/${CI_PROJECT_NAME}
        app="${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}"
        --namespace="${NAMESPACE}"
    - kubectl rollout status deployment/${CI_PROJECT_NAME}
        --namespace="${NAMESPACE}" --timeout=120s

deploy-dev:
  <<: *deploy-template
  variables:
    NAMESPACE: dev
    KUBECONFIG_BASE64: "${KUBECONFIG_DEV_BASE64}"
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

deploy-prod:
  <<: *deploy-template
  variables:
    NAMESPACE: prod
    KUBECONFIG_BASE64: "${KUBECONFIG_PROD_BASE64}"
  when: manual
  environment:
    name: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

Dev deploys automatically on every main push. Prod requires a manual click in the GitLab UI.


Registry cleanup

Old images accumulate. Add a cleanup job:

yamlcleanup-registry:
  stage: build
  image: curlimages/curl:latest
  script:
    - |
      TAGS=$(curl -s "${REGISTRY}/v2/${CI_PROJECT_NAME}/tags/list" | jq -r '.tags[]')
      for tag in $TAGS; do
        if [ "$tag" != "${CI_COMMIT_SHORT_SHA}" ] && [ "$tag" != "latest" ]; then
          DIGEST=$(curl -sI \
            -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
            "${REGISTRY}/v2/${CI_PROJECT_NAME}/manifests/${tag}" \
            | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d '\r')
          curl -s -X DELETE "${REGISTRY}/v2/${CI_PROJECT_NAME}/manifests/${DIGEST}" || true
          echo "Deleted: ${tag}"
        fi
      done
  needs: [build]
  allow_failure: true
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

allow_failure: true ensures registry cleanup problems don't break the pipeline.


Rollback

If a deploy fails, rollback with:

bashkubectl rollout undo deployment/my-app -n app
kubectl rollout status deployment/my-app -n app --timeout=60s

Or rollback to a specific revision:

bashkubectl rollout history deployment/my-app -n app
kubectl rollout undo deployment/my-app -n app --to-revision=3

Smoke tests after deploy

Add a smoke test job after deploy:

yamlsmoke-test:
  stage: deploy
  image: curlimages/curl:latest
  needs: [deploy-dev]
  script:
    - |
      for i in $(seq 1 10); do
        STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://dev.example.com/healthz)
        if [ "$STATUS" = "200" ]; then
          echo "Smoke test passed"
          exit 0
        fi
        sleep 5
      done
      echo "Smoke test failed after 50s"
      exit 1
  rules:
    - if: $CI_COMMIT_BRANCH == "main"