Generate a GitLab CI/CD pipeline and Helm chart that builds the current project with kaniko, pushes to the GitLab Container Registry, and deploys to a Kubernetes namespace via a kubeconfig CI variable. Use when the user asks to set up CI/CD, create a GitLab pipeline, write a Helm chart, containerize and deploy an app to Kubernetes/GKE, or wire up "push to deploy". Also generates a Dockerfile tailored to the detected runtime when one is missing, handles backend + frontend split repos, and cove...
Install via CLI
openskills install smk-labs/claude-plugins---
name: gitlab-helm-deploy
description: Generate a GitLab CI/CD pipeline and Helm chart that builds the current project with kaniko, pushes to the GitLab Container Registry, and deploys to a Kubernetes namespace via a kubeconfig CI variable. Use when the user asks to set up CI/CD, create a GitLab pipeline, write a Helm chart, containerize and deploy an app to Kubernetes/GKE, or wire up "push to deploy". Also generates a Dockerfile tailored to the detected runtime when one is missing, handles backend + frontend split repos, and covers both ingress-nginx and Gateway API routing.
---
# Generate GitLab CI/CD + Helm chart
Generate a GitLab CI/CD pipeline and a Helm chart that builds the current project with kaniko, pushes to the GitLab Container Registry, and deploys to a Kubernetes namespace via a user-provided kubeconfig. Reusable across apps: inspect the repo first, ask for the unknowns, then generate only the stages and templates that apply. If no Dockerfile exists, generate one tailored to the detected runtime.
## When this fires
The user wants to set up CI/CD for a project that will deploy to a Kubernetes namespace they have access to via a kubeconfig file. They will provide that kubeconfig as a base64-encoded GitLab CI variable named `KUBE_CONFIG_B64`. Build is **kaniko**, registry is **GitLab Container Registry**, deploy is **Helm**.
If the user wants something different (Argo CD, Flux, plain `kubectl apply`, a cloud-native registry, Docker Hub, GitHub Actions), stop and confirm before proceeding. This skill is opinionated.
## Step 1: Inspect the repo first (always)
Before writing a single file, read these. Then summarize what you found in 4-8 lines and ask the user about anything ambiguous **before** generating files.
1. **Runtime & build**
- `Dockerfile`: base image, exposed port, `CMD`/`ENTRYPOINT`, `HEALTHCHECK`. **If missing, plan to generate one in Step 4.**
- `package.json` / `pyproject.toml` / `go.mod` / `Cargo.toml` / `composer.json`: language, framework, scripts, runtime version (engines, python_requires, go directive).
- Lockfile: package manager (npm, pnpm, yarn, bun, poetry, pip, uv, etc.).
2. **Project shape: single app, monorepo, or backend + frontend?**
- Look for top-level dirs like `backend/`, `server/`, `api/` **paired with** `frontend/`, `client/`, `web/`, `ui/`, `dashboard/`: a strong signal of a split repo that needs **two separate deployables**.
- Look for workspace files (`pnpm-workspace.yaml`, `package.json` `workspaces`, Turborepo `turbo.json`, Nx `nx.json`, Lerna): also a monorepo signal.
- Multiple Dockerfiles (`Dockerfile.backend`, `Dockerfile.frontend`, or one per package) mean a multi-image build.
- Frontend signals: a build that emits static assets (`vite.config.*`, `next.config.*`, `vue.config.*`, `angular.json`, `dist/`, `build/`), a different runtime (nginx for static, Node for SSR).
- Backend signals: API entrypoint (Express, FastAPI, Gin, Rails, Spring), DB/queue clients, server-only env vars.
- **Decision rule**: if both a deployable backend **and** a deployable frontend exist, plan **two charts** (`helm/backend/`, `helm/frontend/`) and **two build jobs** (`build_backend`, `build_frontend`). If only one exists, single chart at `helm/`. Confirm with the user in Step 2 before generating.
3. **Routing layer of the target cluster.** Different clusters use different ingress mechanisms. **You must know which** before generating templates:
- **GKE Gateway API** (or any Gateway API cluster): generate a `templates/httproute.yaml` referencing a shared parent `Gateway`, not an `Ingress`.
- **ingress-nginx**: generate a standard `templates/ingress.yaml` with `ingressClassName: nginx`.
- If unsure which the cluster uses, ask in Step 2. Don't pick by guessing. A quick check the user can run: `kubectl get gateways.gateway.networking.k8s.io -A` (Gateway API) vs `kubectl get ingressclass` (look for `nginx`).
4. **Topology & deps**
- `docker-compose.yml`: which external services the app talks to (Postgres, Redis, RabbitMQ, ...) **and the inter-service wiring** (e.g. how the frontend reaches the backend: same-origin via ingress path, separate subdomain, or env-injected URL).
- Entrypoint source: confirm health endpoint path, port, and **which env vars are actually read**. Do this for each service if there are multiple.
5. **Configuration**
- `.env.example`: enumerate every env var. Classify each as **secret** (API keys, passwords, tokens, signing keys) or **non-secret** (hosts, ports, URLs, feature flags, model names). If backend/frontend are split, **partition the env vars per service**. The frontend usually only needs public URLs and feature flags; secrets belong to the backend.
6. **Existing CI / chart**
- If `.gitlab-ci.yml` or `helm/` already exists, plan to replace cleanly. Don't dual-maintain. Preserve any legacy job that produces an artifact other teams depend on.
## Step 2: Ask the user for the unknowns
Don't guess. Ask up front for anything you can't infer:
- **App name**: drives image name, Helm release, k8s resource names. If the repo has both backend and frontend, ask for **two names** (e.g. `myapp-api`, `myapp-web`). They become two separate Helm releases and two separate images.
- **Project shape**: confirm what Step 1 detected: single app, **backend + frontend (two charts, two images, two deploys)**, or monorepo with N services. If split, also confirm the inter-service wiring (does the frontend call the backend via same-host `/api`, a separate subdomain like `api.example.com`, or an env-injected URL baked at build time?).
- **GitLab group**: which group/namespace the project lives under. On self-hosted GitLab, runners are often attached at the group level. If the project sits in the wrong group, stop and tell the user to move it first: CI will hang otherwise.
- **Runner tag(s)**: the GitLab runner tag that can reach the target cluster. The user gets this from their platform team or GitLab admin. If they have separate tags for heavy jobs (kaniko) vs light jobs (helm/kubectl), capture both. On gitlab.com shared runners, no tag is needed; drop the `tags:` blocks.
- **Namespace**: the Kubernetes namespace the user can deploy to (e.g. `team-alpha-dev`).
- **Environment label**: `staging` / `prod` / `dev`.
- **Public host** (optional): FQDN for the routing object, e.g. `myapp.example.com`. If none, skip the routing template entirely.
- **Routing layer**: confirm from Step 1:
- **Gateway API**: ask for the parent Gateway name + namespace (commonly something like `external-gateway` in `gateway-system` or a platform-owned namespace) and the listener section name.
- **ingress-nginx**: ask for the TLS secret name if HTTPS terminates at the ingress.
- **Health endpoint**: path + port if not obvious from the entrypoint.
- **Migrations**: does the app run DB migrations on deploy?
- **Dockerfile preference** (only if no Dockerfile exists): confirm the user wants you to generate one, or whether they'd rather write it themselves first.
Confirm the user has already added the kubeconfig as `KUBE_CONFIG_B64` (Masked + Hidden CI/CD variable, Settings > CI/CD > Variables). Hidden prevents it from being echoed in job logs; Masked keeps it redacted if it ever leaks into an echo. If not set yet, give them the encode commands:
```bash
# macOS
base64 -i ~/Downloads/my.kubeconfig | pbcopy
# Linux (no line wrapping: masked vars must be single-line)
base64 -w0 ~/Downloads/my.kubeconfig
```
## Step 3: Pick stages based on what the repo supports
No fixed stage list. Include only what applies.
| Stage | Include when ... |
|---|---|
| `test` | a runnable test command exists (`npm test`, `pytest`, `go test`, `bun test`, ...) and tests don't need heavy infra |
| `lint` | a linter config exists (`.eslintrc*`, `ruff.toml`, `.golangci.yml`, ...). Don't invent one. |
| `typecheck` | TS project that doesn't emit JS (Bun/ts-node) and tests don't already typecheck. Skip for Python/Go/Rust: their build typechecks. |
| `build` | **always**: kaniko to GitLab Container Registry |
| `migrate` | app ships migrations (Django, Alembic, Prisma, Rails, ...). Run as a Helm pre-upgrade hook, not a CI job. |
| `deploy` | **always**: `helm upgrade --install` against the user's namespace |
| `smoke` | a smoke script exists that can run against the deployed URL |
Don't duplicate work. If `npm test` already typechecks, drop the standalone typecheck stage.
## Step 4: Generate a Dockerfile if missing
If Step 1 found a Dockerfile, **leave it alone**. Only suggest tweaks if it's missing the health endpoint, doesn't expose the port the entrypoint binds to, or runs as root when the runtime allows non-root.
If there is no Dockerfile, generate one matching the detected runtime. Always:
- **Multi-stage build** to keep the runtime image small (deps install + optional compile in stage 1, slim runtime in stage 2).
- **Pin the base image to a minor version**, never `latest` (`node:20-alpine`, `python:3.12-slim`, `golang:1.23-alpine`).
- **Run as a non-root user** (`USER 1000:1000` or `USER node` / `USER nobody`) where the runtime image allows it.
- **Set `WORKDIR /app`** consistently.
- **Copy lockfile + manifest first**, install deps, **then** copy source, so layer caching works on code-only changes.
- **`EXPOSE` the port the app actually binds to** (read it from the entrypoint, don't guess).
- **No secrets, no `.env` files, no credentials** copied in. Add them to `.dockerignore`.
- Generate a matching `.dockerignore` (`.git`, `node_modules`, `__pycache__`, `dist`, `build`, `.env*`, `*.log`, `.venv`, `target/`, `coverage/`, IDE files).
### Templates by runtime
**Node.js / TypeScript (npm)**
```dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
Adjust for **pnpm** (`corepack enable && pnpm install --frozen-lockfile`), **yarn** (`yarn install --frozen-lockfile`), or **bun** (`oven/bun:1-alpine`, `bun install --frozen-lockfile`, `bun run`).
**Python (poetry)**
```dockerfile
FROM python:3.12-slim AS deps
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 POETRY_VIRTUALENVS_CREATE=false
WORKDIR /app
RUN pip install --no-cache-dir poetry==1.8.3
COPY pyproject.toml poetry.lock ./
RUN poetry install --no-root --only main
FROM python:3.12-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH="/usr/local/bin:$PATH"
WORKDIR /app
COPY --from=deps /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=deps /usr/local/bin /usr/local/bin
COPY . .
RUN useradd --create-home --uid 1000 app && chown -R app:app /app
USER app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```
For **uv** swap the deps stage to `pip install uv && uv sync --frozen --no-dev`. For **pip + requirements.txt** drop poetry and use `pip install --no-cache-dir -r requirements.txt`.
**Go**
```dockerfile
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
```
**Rust**
```dockerfile
FROM rust:1.82-slim AS build
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main(){}' > src/main.rs && cargo build --release && rm -rf src
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/* && useradd --create-home --uid 1000 app
WORKDIR /app
COPY --from=build /src/target/release/<binary-name> /app/server
USER app
EXPOSE 8080
CMD ["/app/server"]
```
**Frontend, static SPA (Vite / CRA / Vue) served by nginx**
```dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Bake the API URL at build time if the SPA needs it (Vite: VITE_*, CRA: REACT_APP_*).
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080
USER nginx
CMD ["nginx", "-g", "daemon off;"]
```
Pair with an `nginx.conf` that listens on `8080` (so the container can run as non-root), serves `index.html` as the SPA fallback (`try_files $uri /index.html`), and adds a `/healthz` location returning `200`. Output dir is `dist` for Vite, `build` for CRA. Adjust the `COPY --from=build`.
**Frontend, Next.js (SSR / standalone)**
```dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "server.js"]
```
Requires `output: 'standalone'` in `next.config.js`. For a non-standalone Next deployment, fall back to the generic Node template above with `npm start`.
After writing the Dockerfile, **build it locally if Docker is available** (`docker build -t <app>:test .`) to catch syntax errors before CI runs it. If Docker isn't available, at minimum re-read the file and verify the `EXPOSE`d port matches what the entrypoint binds to.
## Step 5: Generate the Helm chart(s)
**One chart per deployable.** Decide the layout based on what Step 1/2 confirmed:
- **Single app**: one chart at `helm/`. Release name = app name.
- **Backend + frontend**: two **independent** charts at `helm/backend/` and `helm/frontend/`. Two releases (e.g. `myapp-api`, `myapp-web`), two images, two `values-<env>.yaml` files per chart. **Do not** put backend and frontend templates inside the same chart: they have different lifecycles, scale independently, and split charts let you redeploy one without churning the other.
- **Monorepo with N services**: one chart per service (`helm/<service-a>/`, `helm/<service-b>/`, ...) using the same single-app template.
Why separate charts (not a single chart with sub-deployments): independent versioning, independent rollbacks, independent `--wait` timeouts, and the ability to skip/override per-service via the CI `SKIP_*` toggles.
### Backend <-> frontend wiring
Cover this explicitly in the generated charts based on what Step 2 told you. Translate "routing object" below into the right kind for the cluster (`Ingress` for nginx clusters, `HTTPRoute` for Gateway API clusters):
- **Same-host `/api`**: the public-facing chart (frontend) gets two routing rules: `/api` to the backend service, `/` to the frontend service. The backend chart's routing template stays disabled. On Gateway API this is two `rules[]` entries on one `HTTPRoute` with `matches: [{ path: { type: PathPrefix, value: /api } }]` and `matches: [{ path: { type: PathPrefix, value: / } }]` respectively.
- **Separate subdomain** (`api.example.com` + `app.example.com`): each chart owns its own routing object + TLS. On Gateway API both charts produce their own `HTTPRoute` referencing the same shared `Gateway` via `parentRefs`.
- **Build-time baked URL** (Vite `VITE_API_URL`, CRA `REACT_APP_API_URL`): pass via a build arg in the **frontend's** `build_image` job. Don't put it in `values.yaml` `env:`. It's baked into the bundle, not read at runtime.
#### `HTTPRoute` template shape (Gateway API clusters)
```yaml
{{- if .Values.httpRoute.enabled }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "<chart>.fullname" . }}
labels: {{- include "<chart>.labels" . | nindent 4 }}
spec:
parentRefs:
- name: {{ .Values.httpRoute.parentRef.name }}
namespace: {{ .Values.httpRoute.parentRef.namespace }}
{{- with .Values.httpRoute.parentRef.sectionName }}
sectionName: {{ . }}
{{- end }}
hostnames:
{{- toYaml .Values.httpRoute.hostnames | nindent 4 }}
rules:
{{- range .Values.httpRoute.rules }}
- matches:
{{- toYaml .matches | nindent 8 }}
backendRefs:
- name: {{ include "<chart>.fullname" $ }}
port: {{ $.Values.service.port }}
{{- end }}
{{- end }}
```
The parent `Gateway` is **shared cluster infrastructure**. Don't create one from this chart. Ask the platform team for its name + namespace (e.g. `external-gateway` in `gateway-system`). `sectionName` usually points at the HTTPS listener, which is what you almost always want. TLS termination happens at the `Gateway`, not the `HTTPRoute`, so you don't ship a `tls` block in the chart for Gateway API clusters.
### Standard chart layout (per chart, omit files that don't apply)
- `Chart.yaml`: `apiVersion: v2`, type `application`, version `0.1.0`, appVersion from package metadata.
- `values.yaml` defaults:
- `image.repository`, `image.tag` (empty falls back to `.Chart.AppVersion`), `image.pullPolicy: IfNotPresent`
- `imagePullSecrets: [{ name: gitlab-registry }]` (see **Step 7** for how this secret is created)
- `replicaCount: 1`, sensible `resources` per language
- `service` (ClusterIP), routing block (disabled by default), `autoscaling` (disabled)
- `livenessProbe` / `readinessProbe` using the health endpoint
- `podSecurityContext`, `securityContext` (non-root where the image allows)
- `env:` every **non-secret** env var as key/value
- `secrets.create: true`, `secrets.existingSecret: ""`, `secrets.data:` every **secret** key with empty default
- `values-<env>.yaml`: per-env overrides (host, TLS secret, resource limits).
- `templates/_helpers.tpl`: `name`, `fullname`, `chart`, `labels`, `selectorLabels`, `serviceAccountName`, `secretName`.
- `templates/deployment.yaml`: one container, `envFrom` pointing at a ConfigMap (non-secret) + Secret (app secrets), probes, resources, **`imagePullSecrets` from `.Values.imagePullSecrets`**.
- `templates/service.yaml`.
- **Routing template, pick exactly one based on the cluster** (see Step 1's routing-layer rule):
- `templates/ingress.yaml` for **ingress-nginx** clusters: gated on `.Values.ingress.enabled`, sets `ingressClassName: nginx`. For SSE / long-poll apps include `nginx.ingress.kubernetes.io/proxy-buffering: "off"` and 300s read/send timeouts.
- `templates/httproute.yaml` for **Gateway API** clusters: gated on `.Values.httpRoute.enabled`, references a parent `Gateway` via `parentRefs:` (name + namespace come from `.Values.httpRoute.parentRef`). Use `gateway.networking.k8s.io/v1`. Do **not** also generate an `Ingress`. Pick one.
- `values.yaml` should reflect whichever was generated: either an `ingress:` block (`enabled`, `className`, `host`, `tls`, `annotations`) **or** an `httpRoute:` block (`enabled`, `parentRef.name`, `parentRef.namespace`, `parentRef.sectionName`, `hostnames[]`, `rules[]`). Don't ship both stubs commented out. Leave the chart unambiguous.
- `templates/configmap.yaml`: renders `.Values.env`.
- `templates/secret.yaml`: gated on `.Values.secrets.create`, `Opaque`, `stringData` from `.Values.secrets.data` (skip empty keys).
- `templates/serviceaccount.yaml`, `templates/hpa.yaml` (gated).
- `templates/migration-job.yaml`: only if migrations exist. Helm hook `pre-upgrade,pre-install`, `ttlSecondsAfterFinished: 300`.
- `.helmignore`.
**Classification reminder**
- **Non-secret** (`values.yaml` `env:`): DB host/port/user/db-name, Redis URL, upstream URLs, model names, feature flags, ports, timeouts, CORS origins.
- **Secret** (`values.yaml` `secrets.data:`): API keys, DB passwords, bearer tokens, basic-auth creds, webhook signing keys.
Don't bundle Postgres/Redis subcharts unless asked. Assume external services reachable via env values.
After generating, run:
```bash
helm lint helm/
helm template test helm/ -f helm/values.yaml -f helm/values-<env>.yaml
```
Fix any errors before moving on.
## Step 6: Generate `.gitlab-ci.yml`
> **Every job must have a `tags:` block** matching the runner tag from Step 2 (skip this only on gitlab.com shared runners). Without the right tag, jobs sit `pending` indefinitely. Convention: heavy jobs (kaniko build) on the heavy-pool tag if one exists, light jobs (helm/kubectl/lint/test) on the cluster-reachable tag. If only one tag exists, use it for everything.
On Kubernetes-executor runners you can request more resources per job (the runner enforces ceilings):
```yaml
variables:
KUBERNETES_CPU_REQUEST: "1"
KUBERNETES_CPU_LIMIT: "2"
KUBERNETES_MEMORY_REQUEST: "2Gi"
KUBERNETES_MEMORY_LIMIT: "3Gi"
```
### Required CI/CD variables the user must set in GitLab
| Variable | Purpose | Masked | Protected |
|---|---|---|---|
| `KUBE_CONFIG_B64` | base64-encoded kubeconfig | yes + Hidden | optional |
| `GITLAB_DEPLOY_TOKEN_USER` | username of the deploy token (Step 7) | yes | optional |
| `GITLAB_DEPLOY_TOKEN` | password of the deploy token (Step 7) | yes | optional |
| one row per app secret | injected via `--set-string secrets.data.<KEY>=$<KEY>` | yes | optional |
`$CI_REGISTRY`, `$CI_REGISTRY_IMAGE`, `$CI_REGISTRY_USER`, `$CI_REGISTRY_PASSWORD`, `$CI_COMMIT_SHORT_SHA` are GitLab built-ins. Don't define them.
### File skeleton
```yaml
stages:
- <only the stages picked in Step 3>
variables:
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
K8S_NAMESPACE: <namespace>
HELM_RELEASE: <app-name>
HELM_CHART_DIR: helm
HELM_VALUES_FILE: helm/values.yaml
HELM_ENV_VALUES_FILE: helm/values-<env>.yaml
# One SKIP_* dropdown per job.
SKIP_BUILD_IMAGE:
value: "false"
options: ["false", "true"]
description: "Skip the image build."
SKIP_DEPLOY:
value: "false"
options: ["false", "true"]
description: "Skip the deploy."
```
**Rules block on every job, in this order:**
```yaml
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: never
- if: '$SKIP_<JOB> == "true" || $CI_COMMIT_MESSAGE =~ /\[skip <job>\]/'
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
```
### Build job (kaniko to GitLab Container Registry)
```yaml
build_image:
stage: build
tags:
- <heavy-runner-tag> # fall back to the deploy tag if no heavy pool
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- |
cat > /kaniko/.docker/config.json <<EOF
{
"auths": {
"$CI_REGISTRY": {
"username": "$CI_REGISTRY_USER",
"password": "$CI_REGISTRY_PASSWORD"
}
}
}
EOF
- /kaniko/executor
--context "$CI_PROJECT_DIR"
--dockerfile "$CI_PROJECT_DIR/Dockerfile"
--destination "$CI_REGISTRY_IMAGE:$IMAGE_TAG"
--destination "$CI_REGISTRY_IMAGE:latest"
--cache=true --cache-ttl=168h --snapshot-mode=redo
```
> Kaniko on a default GitLab runner usually needs a 2 Gi memory limit; bump to 4 Gi for large images.
### Deploy job (helm)
```yaml
deploy:
stage: deploy
tags:
- <deploy-runner-tag> # must reach the target cluster
image: alpine/helm:3.14.4
needs:
- job: build_image
optional: true # so chart-only redeploys work with SKIP_BUILD_IMAGE=true
before_script:
- apk add --no-cache kubectl
- mkdir -p $HOME/.kube
- echo "$KUBE_CONFIG_B64" | base64 -d > $HOME/.kube/config
- chmod 600 $HOME/.kube/config
# Create / refresh the GitLab registry pull secret in the user's namespace.
- |
kubectl -n "$K8S_NAMESPACE" create secret docker-registry gitlab-registry \
--docker-server="$CI_REGISTRY" \
--docker-username="$GITLAB_DEPLOY_TOKEN_USER" \
--docker-password="$GITLAB_DEPLOY_TOKEN" \
--docker-email="ci@example.com" \
--dry-run=client -o yaml | kubectl apply -f -
script:
- helm upgrade --install "$HELM_RELEASE" "$HELM_CHART_DIR"
--namespace "$K8S_NAMESPACE"
-f "$HELM_VALUES_FILE"
-f "$HELM_ENV_VALUES_FILE"
--set image.repository="$CI_REGISTRY_IMAGE"
--set image.tag="$IMAGE_TAG"
# one --set-string per app secret:
# --set-string secrets.data.MY_API_KEY="$MY_API_KEY"
--wait --timeout 5m
environment:
name: <env>
url: https://<host-if-any>
```
If the kubeconfig holds one context per namespace, either use `kubectl -n <namespace>` on each command (as above) or pin a context with `kubectl config use-context <cluster>-<namespace>`.
Optional jobs (`test`, `lint`, `typecheck`, `smoke`): only add the ones Step 3 selected. Each gets its own `SKIP_*` variable and `[skip <name>]` commit token.
### Backend + frontend: duplicate the build and deploy jobs
When Step 1/2 confirmed two deployables, **don't try to share one build/deploy job**. Generate a parallel pair per service. Naming: `build_backend` / `deploy_backend` and `build_frontend` / `deploy_frontend`. Concrete differences:
- **Per-service image name**: push to `$CI_REGISTRY_IMAGE/backend:$IMAGE_TAG` and `$CI_REGISTRY_IMAGE/frontend:$IMAGE_TAG` so the two images are distinct in the registry. Set kaniko `--destination` accordingly.
- **Per-service Dockerfile context**: point kaniko at the right subdir (`--context "$CI_PROJECT_DIR/backend" --dockerfile "$CI_PROJECT_DIR/backend/Dockerfile"`), or use root-level `Dockerfile.backend` / `Dockerfile.frontend` with `--context "$CI_PROJECT_DIR"`.
- **Per-service Helm release**: separate `HELM_RELEASE_BACKEND` / `HELM_RELEASE_FRONTEND` and chart dirs `HELM_CHART_DIR_BACKEND=helm/backend` / `HELM_CHART_DIR_FRONTEND=helm/frontend`. Two `helm upgrade --install` calls.
- **Per-service skip toggles**: `SKIP_BUILD_BACKEND`, `SKIP_BUILD_FRONTEND`, `SKIP_DEPLOY_BACKEND`, `SKIP_DEPLOY_FRONTEND`. Plus a global `SKIP_BUILD` and `SKIP_DEPLOY` that short-circuit both. Each job's rules block checks both its own toggle and the global one.
- **Path-based skip (optional but recommended)**: add a `changes:` rule so a backend-only commit doesn't rebuild the frontend image:
```yaml
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: never
- if: '$SKIP_BUILD_BACKEND == "true" || $SKIP_BUILD == "true"'
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- backend/**/*
- Dockerfile.backend
```
- **Frontend build args**: if the SPA bakes the API URL at build time, pass it to kaniko: `--build-arg VITE_API_URL=$VITE_API_URL` (with `VITE_API_URL` set as a CI/CD variable per environment).
- **Deploy ordering**: by default both deploy jobs run in parallel. If the frontend's runtime config depends on backend being healthy, add `needs: [{ job: deploy_backend }]` to `deploy_frontend`. Don't add a hard ordering unless there's a real reason: parallel deploys are faster.
## Step 7: Pulling the image from GitLab Container Registry
The image lives in a **private** GitLab project registry. Most Kubernetes clusters have no native integration for GitLab's registry, so the standard path is a Kubernetes `docker-registry` secret referenced via `imagePullSecrets`.
### a) Create a Deploy Token in GitLab
Tell the user to go to GitLab project > **Settings > Repository > Deploy tokens > Add deploy token**:
- **Name**: `k8s-pull`
- **Username**: leave blank (GitLab generates one like `gitlab+deploy-token-12345`) or set e.g. `gitlab+deploy-token-k8s`
- **Scopes**: `read_registry` only, nothing else
They save the generated **username** and **token** as GitLab CI/CD variables `GITLAB_DEPLOY_TOKEN_USER` and `GITLAB_DEPLOY_TOKEN` (Masked). The token is not viewable again after the page closes.
> Why a deploy token, not a personal access token? Deploy tokens are scoped to one project and one purpose, survive when team members leave, and can be revoked independently.
### b) The deploy job creates / refreshes the secret each run
Already wired in the `deploy` job above: `kubectl create secret docker-registry ... --dry-run=client -o yaml | kubectl apply -f -` is idempotent and keeps the credential current if the user ever rotates the token.
### c) The chart references it
`values.yaml`:
```yaml
imagePullSecrets:
- name: gitlab-registry
```
`templates/deployment.yaml`:
```yaml
spec:
template:
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
```
### Alternative: create the pull secret once, by hand
If the user deploys many apps from the same GitLab group to the same namespace, they can create the secret **once manually** and drop the `kubectl create secret` step from CI. Use the registry hostname of their GitLab instance as `--docker-server` (`registry.gitlab.com` for gitlab.com, or the self-hosted registry host):
```bash
kubectl -n <NAMESPACE> create secret docker-registry <APPNAME>-reg-secret \
--docker-server=<registry-host> \
--docker-username='<deploy-token-username>' \
--docker-password='<deploy-token-value>'
```
Then set `imagePullSecrets: [{ name: <APPNAME>-reg-secret }]` in `values.yaml` (instead of `gitlab-registry`). If the token ever rotates, re-run the command with `--dry-run=client -o yaml | kubectl apply -f -` appended so the existing secret is replaced in place.
Trade-off: less self-healing if the token rotates, but one less moving part in CI. Recommend the in-CI version unless the user says otherwise.
## Step 8: Report back to the user
After generating, summarize:
1. **Files created**: bulleted list with clickable paths (include the Dockerfile + `.dockerignore` if you generated them).
2. **GitLab CI/CD variables to set**: table grouped into:
- *Infra* (`KUBE_CONFIG_B64`, `GITLAB_DEPLOY_TOKEN_USER`, `GITLAB_DEPLOY_TOKEN`)
- *App secrets* (one row per secret key, marked required/optional)
3. **Assumptions to verify**: runner tag correct?, health endpoint, exposed port matches entrypoint, migrations present?, host/TLS, resource sizing.
4. **First-deploy checklist**: push to default branch, watch the pipeline, `kubectl -n <ns> get pods,svc,ingress` (or `httproute`), hit the URL.
### Common first-deploy issues
| Symptom | Likely cause |
|---|---|
| Pipeline sits pending forever | No runner matches the job's `tags:` (wrong tag, or project in a group without runners). Fix the tag or move the project. |
| `ImagePullBackOff` | Pull secret missing or wrong. Re-create the `docker-registry` secret in the namespace and check `imagePullSecrets` in the chart. |
| `kubectl` says forbidden | The kubeconfig's permissions are namespace-scoped. Always pass `-n <your-namespace>` and confirm the namespace is one the credentials cover. |
| Routing object healthy but the hostname 404s or doesn't resolve | The DNS record for the host doesn't exist yet, or points at the wrong load balancer. Create/fix DNS first. |
| App is up but health checks fail | The container's `EXPOSE`d port doesn't match what the app binds to, or the probe path is wrong. Check `livenessProbe` / `readinessProbe` in Helm values. |
## House rules
- **Project must be under the correct GitLab group** when runners are attached at group level. Confirm with the platform team or GitLab admin before generating CI.
- **Every job needs a `tags:` block** with the team's runner tag (except gitlab.com shared runners). No tag = stuck pipelines. Get the tag from the platform team; do not guess.
- **Generate a Dockerfile only if one is missing.** Don't overwrite an existing Dockerfile. Flag concerns and let the user decide.
- **Multi-stage, pinned base images, non-root user, no secrets baked in**: non-negotiable for any Dockerfile you generate.
- **One chart per deployable.** If the repo has both backend and frontend (or N services in a monorepo), generate separate Helm charts (`helm/backend/`, `helm/frontend/`, ...) and separate build/deploy job pairs in CI. Never bundle multiple services into a single chart: it breaks independent rollback, scaling, and skip toggles.
- **Routing layer is cluster-determined, not optional.** Gateway API clusters get an `HTTPRoute` (no `Ingress`); ingress-nginx clusters get an `Ingress` (no `HTTPRoute`). Never ship both. If the cluster's routing layer is unknown, ask before generating.
- **Never commit secret values.** Secrets go to GitLab CI/CD variables, injected at deploy via `--set-string secrets.data.<KEY>=$<KEY>`.
- **Don't bundle Postgres/Redis subcharts** unless asked. Assume external managed services.
- **Long-poll / SSE apps on ingress-nginx** need `proxy-buffering: "off"` + extended (300s) ingress timeouts. On Gateway API, configure equivalent timeouts via `BackendTrafficPolicy` / `HTTPRoute` `timeouts` instead; ask the platform team if the cluster has a standard policy.
- **Replace existing CI cleanly.** Don't dual-maintain old SSH-based or hand-rolled deploys.
- **Preserve legacy artifacts** other teams consume (browser extension bundles, SDKs, docs sites) under their own `build_<name>` job with a dedicated skip toggle.
- **Match conventions of sibling repos** when they conflict with these defaults.
Scanned 8/31/2026
No comments yet. Be the first to comment!