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"