Why CI/CD is essential ?

CI/CD ( Continuous Integration and Continuous Deployment/Delivery ) is a cornerstone of modern software development, enabling teams to deliver high-quality applications faster and more reliably.

By automating the build, test and deployment processes, CI/CD pipelines eliminate manual errors, provide rapid feedback on code changes, and ensure that every new feature or bug fix is thoroughly vetted before reaching users.

This not only accelerates the development lifecycle but also fosters a culture of collaboration and continuous improvement, allowing developers to focus on writing code while the pipeline handles the rest.

For example: Think about your mobile apps like WhatsApp or Instagram. Developers update them regularly with new features or bug fixes. CI/CD ensures these updates are built, tested, and safely deployed automatically, so you get smooth updates without the app breaking.

ci/cd

Tech Stack Overview

  • Laravel
  • Docker
  • Gitlab (For Repository)
  • Gitlab CI/CD
  • Gitlab Container registry
  • Kubernetes (K3S distribution)
  • ArgoCD
  • Hetzner Server (4GB RAM / 2 CPU cores) (Price $5)
  • Debian OS
  • Cloudflare and 1 domain
  • Portainer (For container management)

Our Goal

  • Containerize the Laravel application for consistent environments across development and production.
  • Deploy and manage the application on a lightweight Kubernetes cluster (K3s) running on a low-cost Hetzner VPS ( Virtual private server ).
  • Automate the entire container image build, test process on every code push using Gitlab CI.
  • Automate deployment via ArgoCD.
  • Custom domain with HTTPS using Cloudflare DNS and SSL.
  • Infrastructure visibility using Portainer and ArgoCD dashboard.

Repository

git clone https://gitlab.com/9ovindyadav/laravel-cicd.git

Project structure

  • laravel-app folder contains our application code which we are going to deploy.

    laravel-app/
    ├── app
    ├── artisan
    ├── bootstrap
    ├── components.json
    ├── composer.json
    ├── composer.lock
    ├── config
    ├── database
    ├── eslint.config.js
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── phpunit.xml
    ├── public
    ├── resources
    ├── routes
    ├── storage
    ├── tests
    ├── tsconfig.json
    ├── vendor
    └── vite.config.ts
    
  • docker folder contains all the configs for building the container image.

    docker/
    ├── compose.yaml    # docker compose file
    ├── Dockerfile      # file to build laravel app container images
    ├── entrypoint.sh   # bash script which runs during container boot
    ├── initdb          # sql script which runs during mysql boot
    │   └── default.sql
    ├── mysql.cnf       # mysql config
    ├── nginx.conf      # nginx config
    └── services        
        ├── mysql.yaml
        ├── networks.yaml
        ├── phpmyadmin.yaml
        ├── production-app.yaml  # laravel in production environment
        ├── local-app.yaml       # laravel local development 
        ├── redis.yaml 
        ├── selenium.yaml
        └── volumes.yaml
    
  • kubernetes this folder contains all the kubernetes configuration needed for deployment.

    kubernetes/
    ├── apps        # all running apps in k8s
    ├── argocd      # argocd configs
    ├── proxy       # gateway api config
    └── README.md
    
  • .gitlab-ci.yml this file contains our entire CI pipeline code for build, test and deploy.

About our application

  • This web application is built using laravel 12
  • Have Login and Register and Dashboard page
  • Need following dependecies to run
    1. PHP and Composer
    2. Nginx
    3. Node and npm
    4. Mysql
    5. Redis
  • Prerequisite for running the application locally
    1. Docker ( Installation script for Linux )
  • To run the application
    1. cd into laravel-cicd
    2. copy .env file
      cp laravel-app/.env.example laravel-app/.env
      
    3. Start the app
      docker compose -f docker/compose.yaml --env-file laravel-app/.env up
      
    4. In Browser open localhost:9000
    5. Sign Up with new user in local running application and check health status

App containerization

To containerize the application let’s see what we need to run the application first.

  • Services needed for our application

    1. nginx container
    2. app container
    3. job container
    4. scheduler container
    5. redis container
    6. mysql container
  • For nginx, redis and mysql container we are gonna pull the images from hub.docker.com and pass the config via volume

  • For app,job and scheduler we are gonna build a image using Dockerfile. We have also set a entrypoint.sh to run container dynamically as app,job or scheduler by passing APP_ROLE enviroment variable

  • Building the image using Dockerfile

    docker build -f ./docker/Dockerfile --target prod -t <image-name>:<image-tag> ./
    
  • View the build image by running docker images in terminal

Container registry

Now our image is built and ready to use. let’s push it to a container registry so that we can pull it in our kubernetes cluster and run.

The registry we are gonna use is provided by gitlab per project.

Our project Gitlab Container registry

Now to build and push the image follow these steps:

  • Get the value for following variables and store in a file we will need all this later.

    CI_REGISTRY=registry.gitlab.com
    CI_REGISTRY_USERNAME=<username> // Gitlab username
    CI_REGISTRY_TOKEN=<access-token> // Create new access token with read_registry and write_registry permission
    
  • For access token go to Profile > Edit profile > Access tokens and Create new access token with read_registry and write_registry permission.

  • Docker Login to gitlab registry

    docker login registry.gitlab.com
    
  • Build the image with registry name and tag

    docker build -f ./docker/Dockerfile --target prod -t registry.gitlab.com/<username>/laravel-cicd:v0.0.1 ./
    
  • push the image to registry

    docker push registry.gitlab.com/<username>/laravel-cicd:v0.0.1
    

Gitlab CICD Setup

So what we are setting up in this CICD pipeline is build and deploy stage.

  1. Build
    • Build a laravel-cicd docker image
    • Push build image to Gitlab container registry
  2. Deploy
    • Change new build image tag to kubernetes/apps/lci/values.yaml file
    • make a commit and push in the gitlab repository
    • ArgoCD will monitor the repository for configuration changes and apply to the Kubernetes cluster.

Pipeline configuration is written in gitlab-ci.yml file placed at the root of the repository.

Whenever you make new commit in repository and push to the main branch pipeline will get triggered which will start build and deploy jobs.

Since these jobs are running in a docker container they need access to Gitlab container registry to push build images and Gitlab repository to new commit changes.

These variables need to be create in gitlab so that pipeline can access credentials and run the jobs successfuly.

Go to: Gitlab Repository > Settings > CI/CD > Variables > Project variables

Create these project variables with visibility masked and flags expanded.

DOCKER_REGISTRY: registry.gitlab.com
DOCKER_REGISTRY_USER: <gitlab username>
DOCKER_REGISTRY_TOKEN: <personal-access-token> # token with read and write registry access

GIT_REPO_USER: <gitlab username>
GIT_REPO_TOKEN: <personal-access-token> # token with read and write repository access

Now whenever you make new commits and push to gitlab pipeline will get triggered and build the image and push it to container registry in your project at Gitlab Repository > Deploy > Container registry > laravel-cicd location.

Server setup

Requirement:

  • 2GB RAM and 2 core amd64 CPU
  • Available cloud providers - AWS, GCP, Azure, Hetzner

AWS is good with free tier but billing is too complex here. which may get us in a trouble. So for simplicity and fix charges/month i am gonna use hetzner.

Go get a hetzner cloud account and login to the dashboard.

  1. Create a new project named laravel-cicd

  2. Go to project dashboard

  3. Go to servers section and click add server

  4. Choose

    • Location: Helsinki
    • Image: Debian 12
    • Type: Shared vCPU (x86) CX22
    • SSH Keys: Add or else you will get credentials in email and update latter in server
    • Firewalls: Create and allow port 22, 80, 443, 6443
    • Name: name your server and create
  5. Login into server and update OS

    • Get the public ip of the server
    • Check your email and get credentials for root user
    • Get into your terminal and enter the following details to ssh into created server
      ssh root@<server public ip>
      
    • Check the os details cat /etc/os-release
    • Update the OS
      apt update && apt upgrade
      
  6. Setup password less access using ssh keys

    • Open a new terminal into your local system
    • Create a ssh private and public key pair
      ssh-keygen -t ed25519 -C "Hetzner server access" -f ~/.ssh/hetzner -N ""
      
    • Copy the content of file ~/.ssh/hetzner
      cat ~/.ssh/hetzner
      
    • Open a new terminal and SSH into your server with password
    • Create a file ~/.ssh/authorized_keys and paste the content copied from ~/.ssh/hetzner file located in your local system
    • Now exit out of your server and try to ssh without password
      ssh -i ~/.ssh/hetzner root@<server public ip>
      
    • Set up ssh alias for this long command into ~/.ssh/config to make your life go easy
      Host server-238
          HostName <server public ip>
          User root
          Port 22
          AddKeysToAgent yes
          Identityfile ~/.ssh/hetzner
      
    • Now you can login into your server like this
      ssh server-238
      
    • If all this works out. you can disable password authentication to enhance security of your server.
      # Edit file
      sudo vim /etc/ssh/sshd_config
      
      # Enable
      PubkeyAuthentication yes
      
      # Disbale
      PasswordAuthentication no
      
      # Restart sshd
      sudo systemctl restart sshd
      

DNS setup

To make accessing our applications easier and more secure, we’ll configure a domain name with a wildcard DNS and set up TLS encryption using Let’s Encrypt.

  1. Purchase a Domain

    • Buy a domain from any provider like Hostinger or GoDaddy. let’s assume you purchased 9ovind.in
    • We’ll be using wildcard DNS to allow subdomains like app1.9ovind.in, admin.9ovind.in.
  2. Configure DNS Records

    • Login to your DNS provider’s dashboard and add the following record like this pointing to your server Public IP. DNS config
  3. Get a TLS certificate from Let's Encrypt

    • Install Certbot on your server. We’ll use DNS-based validation since we’re generating a wildcard certificate.

      # Install certbot
      sudo apt install certbot
      
      # Request Wildcard Certificate (Manual Challenge)
      sudo certbot certonly --manual \
      --preferred-challenges=dns \
      -d "*.9ovind.in" -d "9ovind.in" \
      --agree-tos --no-eff-email --email you@example.com   
      
    • Follow the On-Screen Instructions

      Certbot will ask you to create a TXT record something like this.

      Please deploy a DNS TXT record under the name
      _acme-challenge.mylaravelblog.com with the following value:
      
      AbC123xYzSuperSecretChallengeString
      
    • Go to your DNS provider’s panel and add a TXT record. wait a few minutes for DNS to propagate and check with this.

      dig TXT _acme-challenge.9ovind.in +short
      
    • Once Propagated, Certbot Will Complete the Process and The certificate files will be saved at /etc/letsencrypt/live/9ovind.in/

  4. Copy the TLS certificate and private key and save in the repo at kubernetes/proxy/certs/9ovind.in/.

    • Certificate in file tls.cert
    • Private key in file tls.key

We will need these files for setting up TLS in our kubernetes cluster at Gateway API.

Kubernetes Cluster

Local system setup

Install these tools on your local system for accessing and managing the cluster.

  1. Kubectl
    curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/$(uname -s | tr '[:upper:]' '[:lower:]')/$(uname -m)/kubectl"
    chmod +x kubectl
    sudo mv kubectl /usr/local/bin/
    
  2. Helm
    curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
    

Common steps on all VMs

  1. Turn off swap
    sudo swapoff -a
    
    sudo vim /etc/fstab
    # look for /swapfile none swap sw 0 0 and comment
    
    sudo rm -f /swapfile
    
    df -kh # look for space
    
  2. Change hostname
    sudo vim /etc/hostsname // cp-<last ip digit> or worker-<last ip digit> 
    sudo vim /etc/hosts
    
    sudo reboot
    

Control plane

  1. First Control plane node setup
    curl -sfL https://get.k3s.io | sh -s - server \
        --disable traefik \
        --disable servicelb \
        --cluster-init \
        --tls-san=Public-IP
    
  2. Copy /etc/rancher/k3s/k3s.yaml to local ~/.kube/config and update Public IP of server for local kubectl
  3. get the worker node registration token
    sudo cat /var/lib/rancher/k3s/server/node-token
    

Join nodes

If you have multiple servers available you can join them all using below commands as worker or Control plane nodes.

  • Joining Worker Node
    curl -sfL https://get.k3s.io | K3S_TOKEN=<first-cp-token> sh -s - agent --server https://<first-cp-ip>:6443
    
  • Joining Another Control plane Node
    curl -sfL https://get.k3s.io | K3S_TOKEN=<first-cp-token> sh -s - server \
        --server https://<first-cp-ip>:6443 \
        --tls-san=Public-IP
    

Taint Control plane

For restricting the scheduling of pods on Control plane we can tant the nodes.

# Apply taint
kubectl taint nodes <node-name> node-role.kubernetes.io/control-plane=:NoSchedule

# Remove taint
kubectl taint nodes <node-name> node-role.kubernetes.io/control-plane-

Gateway API setup

  1. Label node on which you want to schedule Gateway API

    kubectl label node <node-name> gateway=true
    
  2. Add helm repo and Gateway CRDs

    helm repo add traefik https://traefik.github.io/charts
    helm repo update
    
    # Standard CRDs for GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant
    kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml
    
    # Experimental CRDs for TCPRoute, UDPRoute, TLSRoute
    kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/experimental-install.yaml
    
  3. Create namespace

    kubectl create namespace traefik
    
  4. Create TLS for your domain

    # Change directory
    cd kubernetes/proxy/certs/<domain name>/
    
    # Create TLS secret
    kubectl create secret tls <domain name>-tls --cert=tls.crt --key=tls.key -n traefik
    
  5. Update kubernetes/proxy/traefik/values.yaml. Replace example.com with your <domain-name>

  6. Upgrade or Install treafik

    # Change directory
    cd kubernetes/proxy/traefik
    
    # Install Traefik gateway
    helm upgrade --install traefik traefik/traefik \
    --namespace traefik \
    -f values.yaml
    

demo-nginx app for testing the cluster setup

Change your directory to kubernetes/apps/demo-nginx and apply all these files.

Update route.yaml file with your <domain name>

  1. Deployment
    kubectl apply -f deployment.yaml
    
  2. Service
    kubectl apply -f service.yaml
    
  3. HTTPRoute
    kubectl apply -f route.yaml
    

If your DNS setup was working. when nginx-demo.example.com you will get an html response like this.

Nginx demo

ArgoCD setup

Note: first make sure you are in cd kubernetes/argocd folder

  1. Installation

    kubectl create namespace argocd
    
    kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
    
  2. Edit configmap to disable https in argocd for nginx-gateway

    kubectl edit configmap argocd-cmd-params-cm -n argocd
    
  3. Place the following line

    data:
    server.insecure: "true"
    
  4. Rollout the deployment

    kubectl rollout restart deployment argocd-server -n argocd
    
  5. Create a route

    cd kubernetes/argocd
    
    kubectl apply -f route.yaml
    
  6. Login info

    • Go to browser and open the argocd url

      User - admin
      Password - <we have to get from secrets>
      
    • Get the password from secrets

      kubectl get secrets/argocd-initial-admin-secret -n argocd -o yaml
      
    • Copy the password , it’s a base64 encoded string we have decode it first

      echo "<password>" | base64 --decode
      
    • Update admin password in argoCD for more security.

  7. Setup repository

    # Repositories
    kubectl apply -f repositories.yaml
    
  8. Create root-app for App of Apps pattern.

    # Root app
    kubectl apply -f root-app.yaml
    

    This root-app will track kubernetes/argocd/applications folder and create all apps in argocd automatically. you just have to make a application file and commit changes to git.