Once an application runs on Kubernetes, the manual process of "build the image on a personal machine, push it to Docker Hub, edit the manifest, then kubectl apply" soon becomes a bottleneck. If the source code lives on Bitbucket, the natural CI/CD choice is Bitbucket Pipelines. This guide builds a pipeline that builds, pushes and deploys to the cluster: the structure of the bitbucket-pipelines.yml file, how to replace the image in a manifest, how to force Kubernetes to pull a new image, the model of moving Kubernetes configuration into its own repo, and the common mistakes.
1. How Bitbucket Pipelines works
Bitbucket Pipelines is the CI/CD service built into Bitbucket Cloud. Put a bitbucket-pipelines.yml file at the root of the repo, and on every push Bitbucket runs the matching pipeline. The concepts to know:
- pipeline: a set of steps that run on an event, for example a push to a branch (
branches:) or by default for every branch (default:). - step: one step, running in its own container, with the repo already cloned into
$BITBUCKET_CLONE_DIR. Two different steps share neither disk nor environment variables. - stage: groups several steps into a logical unit, for example "Build" and "Deploy".
- services: docker: enables a Docker daemon for the step so it can run
docker build. - artifacts: files produced by an earlier step; to use them in a later step, they must be declared here.
- pipe: a prepackaged step, called by name with variables, for example
atlassian/kubectl-run. - repository variables: secret variables (registry password, kubeconfig) configured in the UI and marked secured so they do not appear in logs.
The full syntax is in Atlassian's documentation on configuring bitbucket-pipelines.yml.
2. A minimal Bitbucket Pipelines template: build, push, apply
The idea behind a minimal Bitbucket Pipelines setup is very direct: step 1 builds the image and pushes it to the registry, step 2 replaces the image name in the manifest and calls the kubectl-run pipe to apply it.
image: atlassian/default-image:4
pipelines:
branches:
main:
- step:
name: Build
services: [docker]
script:
- export TAG=$BITBUCKET_COMMIT
- docker build -t $IMAGE_REPO:$TAG .
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin
- docker push $IMAGE_REPO:$TAG
- step:
name: Deploy
deployment: production
script:
- sed -i "s|image:.*|image: $IMAGE_REPO:$BITBUCKET_COMMIT|" .k8s/app-deployment.yaml
- pipe: atlassian/kubectl-run:1.3.3
variables:
KUBE_CONFIG: $KUBE_CONFIG
KUBECTL_COMMAND: 'apply'
RESOURCE_PATH: '.k8s/app-deployment.yaml'IMAGE_REPO, REGISTRY_USER, REGISTRY_PASSWORD and KUBE_CONFIG (a base64-encoded kubeconfig, as the pipe requires) are repository variables. In Bitbucket Pipelines, the atlassian/kubectl-run pipe takes care of installing kubectl and loading the kubeconfig, so the Deploy step needs no image of its own. Use --password-stdin instead of --password so the password does not end up in the command-line arguments.
The first common mistake with Bitbucket Pipelines is wrong YAML indentation: name: aligned with - step: instead of indented inside it, or a string missing its closing quote. Bitbucket Pipelines reports a configuration error as soon as you push. Validate the YAML before pushing with Bitbucket's validator or a YAML lint tool.
3. Replacing the image in the manifest: common sed mistakes
The sed line that replaces the image looks simple, but it is where the most pushes get wasted, because every mistake means waiting for Bitbucket Pipelines to run and reading the log. Typical mistakes:
| Command | Why it is wrong |
|---|---|
sed -i "s|{{image}}|$IMAGE|g" | The manifest has no {{image}} placeholder; sed finishes without changing anything and without reporting an error |
sed -i "s/^image:.*/image:$IMAGE/g" | ^image: only matches lines starting with image:, while the real line is indented inside containers. The image name contains /, the same character as the delimiter |
sed -i 's/.../image:$IMAGE/g' | Inside single quotes the shell does not expand variables: the file gets the literal string $IMAGE |
sed -i 's/^\ +image:.*/.../g' | In sed's basic regex, + is a literal character, not "one or more" |
sed -i 's|^\ *image:.*|image: ...|g' | It matches, but the replaced part includes the leading whitespace: the new line loses its indentation and the YAML breaks |
sed -i "s|image:.*|image: $IMAGE|" | Correct: | as the delimiter, double quotes so the shell expands the variable, indentation left untouched |
This whole chain of mistakes can be avoided by testing the sed command on a personal machine against a copy of the manifest before putting it into Bitbucket Pipelines. A feedback loop of a few seconds locally is far cheaper than a few minutes per pipeline run. In the long run, replace sed with YAML-aware tools such as kubectl set image, kustomize edit set image or Helm.
4. When the tag does not change, how do pods pull the new image
A Deployment only rolls out when the pod template changes. If the pipeline always pushes the image with the same tag (for example latest), the manifest after sed is identical to last time, kubectl apply reports unchanged, and no pods are recreated. There are two ways to handle this:
- Tag by commit hash (the template in section 2): every build gets a new tag, the pod template changes, and the rollout happens naturally. The tag tells you which build a pod runs, and rollback only needs to point back to an old tag. This is the recommended approach.
- Keep a fixed tag and change a label in the pod template: on each run, sed the label value to
$BITBUCKET_COMMIT, combined withimagePullPolicy: Alwaysso kubelet pulls the same tag again.
# option 2: the label must be in the pod template, not in the Deployment's metadata
spec:
template:
metadata:
labels:
app: web
commit: <commit-sha> # changes on every deploy to force a rollout
# in the pipeline
- sed -i "s|commit:.*|commit: $BITBUCKET_COMMIT|" .k8s/app-deployment.yamlA common mistake with option 2 is putting the label in the Deployment's own metadata.labels: changing that label does not touch the pod template, so nothing happens. Option 2 also has a cost: you cannot tell which image a pod runs just by looking at the tag, and going back to an old version means rebuilding. The rollout mechanism is described in the Kubernetes Deployment documentation.
5. Automatic or manual approval in Bitbucket Pipelines
A common requirement with Bitbucket Pipelines: a push only builds, and deploy runs only when someone approves it. Three home-made approaches usually do not work well:
- An
ifin the script based on a variable saying who triggered the pipeline: there is no variable that reliably distinguishes a push from a manual run. - A step's
conditionblock: it only filters on the list of changed files (changesets), and does not accept expressions like$AUTO_DEPLOY == "1". - An earlier step sets a flag, a later step reads it: an
exportin an earlier step does not carry over to the next step, because each step is a new container. The flag has to be written to a file and declared as an artifact.
The cleanest way is to use the built-in feature: split the pipeline into two stages, "Build" and "Deploy", and set trigger: manual on the Deploy stage when you need manual approval. Remove that line and the pipeline deploys automatically. Before building a mechanism out of shell, check whether Bitbucket Pipelines already has a matching feature.
6. Moving Kubernetes configuration into its own repo
A common model is to move the Dockerfile, the nginx, PHP-FPM and supervisor configuration and the manifest directory into a separate configuration repo, apart from the application repo. The reason: many developers work on the application repo, while infrastructure configuration deserves its own lifecycle and access control. Changing manifests does not require touching the code, and vice versa; the application repo's Bitbucket Pipelines only needs read access to the configuration repo. The Bitbucket Pipelines file gains a first step: clone the configuration repo and pass it to later steps as an artifact.
pipelines:
branches:
main:
- stage:
name: Build
steps:
- step:
name: Fetch config repo
script:
- git clone --depth 1 "$CONFIG_REPO_URL" deploy-config
- rm -rf deploy-config/.git
artifacts:
- deploy-config/**
- step:
name: Build image
services: [docker]
script:
- cp -r deploy-config/. ./
- docker build -t $IMAGE_REPO:$BITBUCKET_COMMIT .
- echo "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin
- docker push $IMAGE_REPO:$BITBUCKET_COMMIT
- stage:
name: Deploy
trigger: manual # remove this line to deploy automatically
deployment: production
steps:
- step:
name: Apply
script:
- sed -i "s|image:.*|image: $IMAGE_REPO:$BITBUCKET_COMMIT|" deploy-config/.k8s/app-deployment.yaml
- pipe: atlassian/kubectl-run:1.3.3
variables:
KUBE_CONFIG: $KUBE_CONFIG
KUBECTL_COMMAND: 'apply'
RESOURCE_PATH: 'deploy-config/.k8s/'Passing directories between steps is the most error-prone part of Bitbucket Pipelines:
- Cloning outside the working directory (
cd ../then clone): artifacts are only collected from inside$BITBUCKET_CLONE_DIR, so the next step sees nothing. - Declaring the artifact as a directory name:
artifacts: [deploy-config]does not pick up the contents. Use the globdeploy-config/**. - Shell globs skip hidden files:
cp -rf deploy-config/* ./copies theDockerfilebut misses.docker/and.k8s/. The correct form iscp -r deploy-config/. ./. - Paths in the Deploy step: artifacts are extracted again in each step, so the Deploy step must point directly at
deploy-config/.k8s/rather than the copy made in the Build step.
7. Security and operations recommendations
- Apply the whole directory, not just the Deployment. If Bitbucket Pipelines applies only one file, the ConfigMap, Secret and Ingress will drift because they have to be applied by hand.
- Do not put an admin kubeconfig in repo variables. Anyone who can edit the pipeline then has power over the whole cluster. Create a ServiceAccount with permissions only in the namespace being deployed, and generate a kubeconfig from its token.
- Mark every secret variable as secured, including the configuration repo URL if it contains a token.
- Tag by commit hash instead of a fixed tag, so you know which build a pod runs and can roll back.
- Experimental pipelines run on their own branch; merge into the main branch only once they are stable and have an approval step.
8. Checklist for setting up Bitbucket Pipelines for Kubernetes
- Test each shell command (sed, cp, globs) on a personal machine before putting it into Bitbucket Pipelines.
- Remember that each step is a new container: environment variables are lost, and files survive only as artifacts.
- Image changes must affect the pod template, as in Your first Kubernetes manifest: namespace, Deployment and MySQL StatefulSet.
- Separate infrastructure configuration from application code early; this also prepares the move to a manifest packaging tool.
- The settings from Requests and limits, QoS and nodeAffinity: placing pods on the right nodes are reapplied on every run, so they must be correct in the repo, not edited by hand on the cluster.
Next article
What is a Helm chart: packaging Laravel websites for repeatable deploys. As the number of sites grows, running sed line by line on manifests is no longer enough: how Helm packages manifests into templates, values and releases.
