CI/CD: deploying to a serverplus Kubernetes cluster
The idea
Access to the cluster (kubeconfig) is put into CI secrets, and the pipeline runs kubectl apply on every push. Below is how to set this up in GitLab CI and GitHub Actions for a Kubernetes cluster on serverplus, and how to do it safely without handing out admin access.
What goes into CI secrets
- The cluster kubeconfig — ideally not admin but a restricted one (from a ServiceAccount, see below). Encode it in base64 and store it as a masked CI variable (
KUBE_CONFIG). - application credential id/secret — only if the pipeline touches the OpenStack infrastructure itself (for example via Terraform).
# get kubeconfig and encode it for the CI secret
openstack coe cluster config my-cluster
base64 -w0 ./config # result -> KUBE_CONFIG variableNever commit the kubeconfig to the repository. Use masked CI secrets only. One kubeconfig file means full access to the cluster.
Restricted access (ServiceAccount + RBAC)
Instead of an admin kubeconfig, create a service account with permissions in a single namespace:
kubectl create namespace app
kubectl -n app create serviceaccount deployer
kubectl -n app create rolebinding deployer-edit \
--clusterrole=edit --serviceaccount=app:deployer
# short-lived token for this account
kubectl -n app create token deployer --duration=24hFrom this token and the cluster address you assemble a separate kubeconfig for CI — it can only change the app namespace.
GitLab CI
.gitlab-ci.yml — deploy on push to main:
deploy:
stage: deploy
image: bitnami/kubectl:latest
script:
- echo "$KUBE_CONFIG" | base64 -d > kubeconfig
- export KUBECONFIG=$PWD/kubeconfig
- kubectl -n app apply -f k8s/
- kubectl -n app rollout status deployment/web
rules:
- if: $CI_COMMIT_BRANCH == "main"KUBE_CONFIG is set in Settings → CI/CD → Variables as a masked variable (base64 of the kubeconfig).
GitHub Actions
.github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: kubeconfig
run: |
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > kubeconfig
echo "KUBECONFIG=$PWD/kubeconfig" >> $GITHUB_ENV
- name: deploy
run: |
kubectl -n app apply -f k8s/
kubectl -n app rollout status deployment/webKUBE_CONFIG is added in Settings → Secrets and variables → Actions.
Build and private registry
A typical full cycle: build the image, push it to the registry, update the deployment.
docker build -t registry.example/web:$TAG .
docker push registry.example/web:$TAG
kubectl -n app set image deployment/web web=registry.example/web:$TAGIf the registry is private, add an imagePullSecret to the cluster and reference it in the deployment manifest.
Security
- Not an admin kubeconfig. A separate ServiceAccount with the
editrole in a single namespace (RBAC) — a compromised runner won't get access to the whole cluster. - Masked CI secrets only, separate credentials per environment (stage/prod), rotated regularly.
- Restrict who can run the deploy: protected branches and protected environments.
FAQ
kubectl: connection refused / timeout from the pipeline. The kubeconfig must contain the external control plane address (floating IP), not an internal 10.x. The CI runner must have network access to that address.
"forbidden" error. The service account lacks RBAC permissions — check the rolebinding and namespace.
x509 certificate error. A stale kubeconfig (the cluster was recreated). Regenerate it: openstack coe cluster config my-cluster --force.