A pipeline can be simplified to a set of files that exist in your repository to direct the runner on how to compile and deploy your application.
For the purposes of this tutorial we will be focusing on getting a simple helloworld webpage to display in your web browser from your VM and for that webpage to automatically update the VM when a new change is pushed to the repository.
These are the files we will be working with:
my-hello-world/
├── Dockerfile
├── index.html
├── k8s/
│ ├── deployment.yaml
│ └── service.yaml
└── .gitlab-ci.yml
Import all of the files from the sample repository and commit them to your main branch from the project we setup in Installing Gitlab Runner
Substitute your VM IP Address for <YOUR_IP> in the configuration files!
The Dockerfile is simply the container you wish to use to run the application you are deploying.
In this case it is a simple nginx container:
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
The index.html will contain the home page we are trying to serve from this container.
<!DOCTYPE html>
<html>
<body>
<h1>Assignment 3 - RLO</h1>
<p> You have successfully deployed a nginx web server with Ubuntu, Docker, Kubernetes, Gitlab, and Gitlab Runners!</p>
</body>
</html>
A deployment.yaml is a Kubernetes manifest that tells kubernetes how to run and manage the application. It also tells Kubernetes what to run, how many to run, and how to keep them up to date.
apiVersion: apps/v1
kind: Deployment #type of resource
metadata:
name: hello-world #name of the deployment
namespace: default
spec:
replicas: 1 #how many pod copies to run
selector: #Links the deployment to its pods via labels
matchLabels:
app: hello-world #which pods this deployment manages
template: #The blueprint for each pod (what image, ports, env vars, resources, etc.)
metadata:
labels:
app: hello-world
spec:
containers:
- name: hello-world
image: <YOUR_IP>:5000/hello-world:latest #container image to run
imagePullPolicy: Always #Controls when to pull a new image (Always, IfNotPresent, Never)
ports:
- containerPort: 80
For further explanation about deployments go here.
apiVersion: v1
kind: Service
metadata:
name: hello-world
namespace: default
spec:
selector:
app: hello-world #Targets pods with this label
ports:
- port: 80 #Port the service listens on
targetPort: 80 #Port the pod will listen on
type: ClusterIP #Type of service
apiVersion: networking.k8s.io/v1 #The API and version for this resource
kind: Ingress #Declares this as an Ingress resource which is a set of rules for routing inbound and outbound traffic
metadata:
name: hello-world #name of the ingress resource
namespace: default #declare name space this ingress resource is attached to, must be same as pods
annotations: #controller specific configuration, this one tells nginx to reqrite the path to the root /
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
http: #applies ingress resource to http traffic
paths:
- path: / #matches any request starting with /
pathType: Prefix #Prefix matches "/" and anything under it
backend:
service:
name: hello-world #forward traffic to this service
port:
number: 80 #on port 80
For further documentation on service.yml go here
A .gitlab-ci.yml file defines the whoel CI/CD pipeline (the automated steps GitLab runs each time code is pushed).
stages: #The stages to this build process, build then deploy
- build
- deploy
build:
stage: build
tags:
- Test-local-RLO #Targets the self-hosted Microk8s runner
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- /kaniko/executor
--context $CI_PROJECT_DIR #Build context is the repositories root
--dockerfile $CI_PROJECT_DIR/Dockerfile #Define where to find the Docker file
--destination <YOUR_IP>:5000/hello-world:latest #Define what registry this build image is going to be sent to
--insecure #allows http for the purposes of this example
--skip-tls-verify #skips certifcate checks (not for production systems)
deploy:
stage: deploy
script:
- echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/kubeconfig.yaml #Decodes the base64 kubeconfig stored in your GitLab CI variable and writes it to a temp file so kubectl can use it.
- kubectl --kubeconfig=/tmp/kubeconfig.yaml apply -f k8s/ #Applies all manifests in the k8s directory
#Force the deployment to pull new images and restart pods
- kubectl --kubeconfig=/tmp/kubeconfig.yaml rollout restart deployment/hello-world -n default
- kubectl --kubeconfig=/tmp/kubeconfig.yaml rollout status deployment/hello-world -n default
tags:
- Test-local-RLO
image: bitnami/kubectl:latest
environment:
name: production
The overall flow for this pipeline looks like:
push → build job (kaniko builds & pushes image)
→ deploy job (kubectl applies manifests & restarts pods)
→ new version live at http://<YOUR_IP>/
Once the build has succeeded you should be greated by this wonderful message!
If you see this page on http://<YOUR_IP>/index.html Congratulations! You've successfully setup a function GitLab Pipeline.
If you do not see this message you will want to go to the Jobs section of your project and inspect the failure.
I've compiled a list of my errors in Troubleshooting that will hopefully provide some guidance.
References:
[1] The Kubernetes Authors, "Deployments," Kubernetes Documentation, The Linux Foundation, 2024. [Online]. Available: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/. [Accessed: Mar. 25, 2026].
[2] The Kubernetes Authors, "Service" Kubernetes Documentation, The Linux Foundation, 2024. [Online]. Available: https://kubernetes.io/docs/concepts/services-networking/service/. [Accessed: Mar. 25, 2026].