32.5 GitLab CI with Kubernetes Integration
Right, so you’ve got a Kubernetes cluster humming along, and now you want to get your code onto it without manually kubectl apply-ing until 3 AM. Good call. Let’s talk about using GitLab CI to do this properly. It’s a powerful combo because GitLab isn’t just a CI/CD tool; it’s a whole integrated platform. This means less configuration hell, but also a few GitLab-isms you need to understand to avoid pulling your hair out.
The core idea is simple: you define a .gitlab-ci.yml file in your repo. This file tells the GitLab runner (the thing that executes your jobs) what to do. To deploy to Kubernetes, that “what to do” almost always involves using kubectl. But here’s the first gotcha: you can’t just willy-nilly slap a kubectl command in there. You need to securely connect to your cluster from inside the pipeline. GitLab makes this… interesting.
The Kubernetes Cluster Connection
The most secure and GitLab-native way to handle this is by using a Kubernetes Integration. You can add your cluster directly to your GitLab project or group under Infrastructure > Kubernetes clusters. This does a few magical things for you: it creates a dedicated service account in your cluster’s gitlab namespace, and it automatically makes the cluster’s API URL, CA certificate, and service account token available as CI/CD variables (KUBE_URL, KUBE_CA_PEM, KUBE_TOKEN).
Why is this better than just pasting your kubeconfig file into a variable? Because those CI/CD variables are protected/masked, and more importantly, you can control access via GitLab’s permissions. It also automatically sets up things like GitLab Environments. The downside? It’s a bit of a “my way or the highway” approach. If your cluster is behind a firewall, you’ll need to open it up to GitLab.com’s IPs (or your self-hosted runner’s IP), which is a non-starter for some security teams.
Here’s the alternative, more manual approach. You create a service account in your cluster yourself, get its token and the cluster’s CA cert, and plug them into your project’s CI/CD settings as custom variables. It’s more work but gives you ultimate control.
# A job using the manual variable method
deploy:production:
stage: deploy
image: bitnami/kubectl:latest # Always pin your tag!
script:
- |
kubectl config set-cluster my-cluster --server="$KUBE_URL" --certificate-authority=/tmp/ca.crt
kubectl config set-credentials gitlab-service-account --token="$KUBE_TOKEN"
kubectl config set-context my-context --cluster=my-cluster --user=gitlab-service-account
kubectl config use-context my-context
kubectl apply -f k8s/
before_script:
# Write the CA cert from the variable to a file
- echo "$KUBE_CA_PEM" > /tmp/ca.crt
The Anatomy of a Deployment Job
Your deployment job shouldn’t just be a blind kubectl apply. You need to be smart about it. Let’s break down a robust job definition.
deploy:staging:
stage: deploy
environment:
name: staging
url: https://my-app-staging.example.com # GitLab will link this from the UI!
image:
name: bitnami/kubectl:latest
entrypoint: [""] # Crucial! Disables the image's default entrypoint.
rules:
- if: $CI_COMMIT_BRANCH == "main" # Deploy main to staging
when: manual # Often a good idea for deploy steps!
script:
# Use envsubst to inject CI variables into your k8s manifests
- envsubst < k8s/deployment.yaml | kubectl apply -f -
- kubectl rollout status deployment/my-app -n $NAMESPACE --timeout=60s
after_script:
# Even on failure, try to get logs to help debug a failed rollout
- kubectl describe deployment/my-app -n $NAMESPACE || true
- kubectl logs -l app=my-app -n $NAMESPACE --tail=100 || true
Notice the rules keyword and the when: manual. This is your “are you really, really sure?” button. Never set a production deployment to run automatically on push to main. Always make it manual. Trust me on this.
The rollout status command is non-negotiable. It makes the job wait until the new pods are actually up and healthy. Without it, your job succeeds the second the manifest is applied, leaving you with a broken deployment that’s still starting. The after_script is a pro move. Even if the rollout fails (rollout status will exit with an error, failing the job), the after_script runs, fetching crucial debugging info.
Taming Configuration with Helm or Kustomize
You are not just deploying a raw deployment.yaml file, are you? If you are, how are you handling environment-specific differences (e.g., replicaCount: 2 for staging, 10 for production)? You need a templating solution.
Helm is the common choice, and GitLab CI works nicely with it.
deploy:production:
stage: deploy
environment: production
image: alpine/helm:3.15.2
rules:
- if: $CI_COMMIT_TAG # Only deploy tags to production
when: manual
script:
- helm upgrade --install --atomic --wait --timeout 5m0s \
--namespace $PROD_NAMESPACE \
--set image.tag=$CI_COMMIT_TAG \
--set ingress.host="my-app-prod.example.com" \
my-app ./chart/
The --atomic flag is brilliant. It automatically rolls back the release if the upgrade fails, preventing a half-broken state from lingering. Always use it.
Common Pitfalls and How to Avoid Them
- The
image:Pitfall: Notice theentrypoint: [""]in the job above? Many Docker images, including the officialkubectlone, have an entrypoint set. GitLab runs yourscript:commands by shelling out through this entrypoint. If you don’t reset it, you’ll get bizarre errors wherekubectldoesn’t recognize your flags. It’s a classic “works on my machine” (because you run it directly) vs. “fails in CI” gotcha. - Secret Management: Under no circumstances should you put Kubernetes Secrets in your repository. Just don’t. Use GitLab’s CI/CD Variables. They are secure, masked, and protected. Then, in your job, you can inject them as environment variables or use a tool like
gitlab-vaultor evenkubectlto create a secret on the fly from these variables. - Runner Configuration: Is your GitLab Runner actually able to reach your Kubernetes API server? If your cluster is on a private VPC, your runner needs to be there too. This is where self-hosted runners shine. A runner on the same network as your cluster is fast, secure, and doesn’t require exposing your API server to the entire internet.
- Resource Limits: Your CI/CD pipeline is a workload like any other. If you’re using the Kubernetes executor for your runners, define resource requests and limits for your
gitlab-runnerdeployment. A pipeline that runs out of memory and gets OOMKilled is incredibly frustrating to debug.
The goal is to make your deployment process boring. It should be a reliable, repeatable, and—frankly—uneventful process. The wit and cleverness should be in your application code, not in your frantic debugging of a midnight deployment script.