In this post, we’ll dive into Trunk-Based Development and Gitflow, two popular branching strategies in software development. Through practical examples, we’ll set up GitLab CI/CD pipelines and deploy a sample application on a Kubernetes cluster to showcase how each strategy can be effectively implemented.

Prerequisites

  1. Basic Knowledge: Familiarity with Git, GitLab, Docker, Python, and Kubernetes.
  2. Environment Setup:
    • This demonstration uses GitLab as the version control system and CI/CD platform. Follow this tutorial to install and configure GitLab locally.

    • Kubernetes is used to host the Python application. Refer to this guide for setting up CI/CD pipelines and deploy python app to a local Kubernetes cluster using KIND (Kubernetes IN Docker).

    • The application will be deployed to multiple environments: sandbox, dev, qa, stg, prod To represent each environment, we will create corresponding Kubernetes namespaces.

      1
      2
      3
      4
      5
      
      kubectl create ns sandbox
      kubectl create ns develop
      kubectl create ns qa
      kubectl create ns stg
      kubectl create ns prod
      

Trunk based development

Trunk-Based Development is a streamlined version control strategy where all developers work on a single branch, known as the trunk and frequently integrate their changes. It minimizes long-lived branches, reducing merge conflicts and promoting continuous integration. This approach enables faster delivery cycles, making it ideal for teams practicing DevOps and Continuous Delivery.

Trunk ci/cd pipeline

Assuming you already have a hello-world Python application repository set up in GitLab and KIND Kubernetes cluster is up & running as outlined in the prerequisites section. Lets setup the ci/cd pipeline required for the Trunk based development workflow.

Navigate to the Python app folder, add the following .gitlab-ci.yml file at the root level, and commit the changes directly to the trunk branch.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
.deploy_template: &deploy_template 
  before_script:
    - apk update && apk add --no-cache curl bash
    - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
    - chmod +x kubectl
    - mv kubectl /usr/local/bin/kubectl
    - kubectl version --client
    - mkdir -p /root/.kube
    - echo "$KUBECONFIG_CONTENT" > /root/.kube/config
    - export KUBECONFIG=/root/.kube/config
  when: manual
  needs: [ push ]
  allow_failure: false

stages:
  - build
  - test
  - push  
  - deploy-sandbox
  - deploy-dev
  - deploy-qa
  - deploy-stg
  - deploy-prod

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  IMAGE_NAME: ${CI_REGISTRY_IMAGE##*/}
  KUBE_DEPLOYMENT_NAME: hello-world

build:
  stage: build
  before_script:
    - apk update && apk add --no-cache docker openssl bind-tools
    - mkdir -p /etc/docker/certs.d/registry.gitlab.example.com:5005
    - openssl s_client -showcerts -connect registry.gitlab.example.com:5005 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.crt
    - cp ca.crt /etc/docker/certs.d/registry.gitlab.example.com:5005/ca.crt
  script:
    - mkdir -p ./build
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker save $IMAGE_TAG > ./build/$IMAGE_NAME.tar
  artifacts:
    paths: [ build ]
    expire_in: 1 hour   
  only:
    - /^feature\/.*$/
    - main
    
test:
  stage: test
  image: python:3.9
  script:
    - pip install -r requirements.txt
    - pytest ./test_app.py
  only:
    - /^feature\/.*$/
    - main
  needs: [ build ]

push:
  stage: push
  before_script:
    - apk update && apk add --no-cache docker openssl bind-tools
    - mkdir -p /etc/docker/certs.d/registry.gitlab.example.com:5005
    - openssl s_client -showcerts -connect registry.gitlab.example.com:5005 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.crt
    - cp ca.crt /etc/docker/certs.d/registry.gitlab.example.com:5005/ca.crt
  script:
    - echo "Pushing image to registry"
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker load < ./build/$IMAGE_NAME.tar
    - docker push $IMAGE_TAG
  only:
    - /^feature\/.*$/
    - main
  needs: [ build ]

deploy_sandbox:
  extends:
    - .deploy_template
  stage: deploy-sandbox
  script:    
    - kubectl apply -f deployment.yaml -n sandbox
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n sandbox
  only:
    - /^feature\/.*$/
  when: on_success

deploy_dev:
  extends:
    - .deploy_template
  stage: deploy-dev
  script:
    - kubectl apply -f deployment.yaml -n dev
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n dev
  only:
    - main

deploy_qa:
  extends:
    - .deploy_template
  stage: deploy-qa
  script:
    - kubectl apply -f deployment.yaml -n qa
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n qa
  only:
    - main

deploy_stg:
  extends:
    - .deploy_template
  stage: deploy-stg
  script:
    - kubectl apply -f deployment.yaml -n stg
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n stg
  only:
    - main

deploy_prod:
  extends:
    - .deploy_template
  stage: deploy-prod
  script:
    - kubectl apply -f deployment.yaml -n prod
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n prod
  only:
    - main

Trunk workflow

  1. The developer begins working on the user story/ticket, creates a feature branch from the main branch and uses the naming pattern feature/*.

    1
    2
    3
    
    git checkout main
    git pull origin main
    git checkout -b feature/feature-1 main
    
  2. The developer starts developing the feature. Modify the app.py file to include the new feature.

    1
    2
    3
    
    @app.route('/greet/<name>')
    def greet_user(name):
        return f'Hello, {name}!'
    
  3. The developer commits and pushes the changes to feature branch, the pipeline builds, tests, pushes the image to registry and deploys the application to the sandbox environment.

    1
    2
    3
    
    git add .
    git commit -m "Add user greeting feature"
    git push origin feature/add-user-greeting
    

    Trunk based development sandobx pipeline

  4. Once the feature is complete, the developer push the changes to feature branch and create a merge request (MR) from the feature branch into the main branch by using a squash merge.

  5. When the MR is approved and merged into main branch, the pipeline runs, builds, tests, pushes the image to registry. Also the feature branch is deleted once the changes are merged into main branch.

    Trunk based development main pipeline

  6. An approver manually triggers the deployment to the dev environment, where the app is deployed using the image created in the previous pipeline stage, with the image tag set to CI_COMMIT_SHORT_SHA.

  7. An approver manually triggers the deployment to the qa environment, deploying the app with the same image and tag as in the dev environment.

  8. An approver manually triggers the deployment to the stg environment, deploying the app with the same image and tag as in the dev environment.

  9. An approver manually triggers the deployment to the prod environment, deploying the app with the same image and tag as in the dev environment.

Gitflow branching strategy

Gitflow uses two main branches, main and develop, along with feature, release, and hotfix branches for specific purposes.

  • mainormaster: The primary branch where stable and production-ready code lives.
  • develop: The branch where ongoing development happens. It is the integration branch for features, meaning that all new features are first merged here before being released.
  • feature/*: Feature branches created from develop for new features.
  • release/*: Release branches created from develop when a version is ready to be built, tested and deployed to production.
  • hotfix/*: Hotfix branches created from main branch to address critical issues in production.

Gitflow ci/cd pipeline

Navigate to the Python app folder and add the following .gitlab-ci.yml file to the root of the folder. Then, push the changes to the main branch.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
.deploy_template: &deploy_template 
  before_script:
    - apk update && apk add --no-cache curl bash
    - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
    - chmod +x kubectl
    - mv kubectl /usr/local/bin/kubectl
    - kubectl version --client
    - mkdir -p /root/.kube
    - echo "$KUBECONFIG_CONTENT" > /root/.kube/config
    - export KUBECONFIG=/root/.kube/config
  when: manual
  needs: [ push ]
  allow_failure: false

stages:
  - validate
  - build
  - test
  - push  
  - deploy-sandbox
  - deploy-dev
  - deploy-qa
  - deploy-stg
  - deploy-prod

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
  IMAGE_NAME: ${CI_REGISTRY_IMAGE##*/}
  KUBE_DEPLOYMENT_NAME: hello-world

validate_branch_name:
  stage: validate
  script:
    - echo "Validating branch name..."
    - if [[ "$CI_COMMIT_REF_NAME" != release/* ]]; then
        echo "Only branches with names starting with 'release/' can be merged into main.";
        exit 1;
      fi
  rules:
    - if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
      when: always

build:
  stage: build
  before_script:
    - apk update && apk add --no-cache docker openssl bind-tools
    - mkdir -p /etc/docker/certs.d/registry.gitlab.example.com:5005
    - openssl s_client -showcerts -connect registry.gitlab.example.com:5005 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.crt
    - cp ca.crt /etc/docker/certs.d/registry.gitlab.example.com:5005/ca.crt
  script:
    - mkdir -p ./build
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker build -t $IMAGE_TAG .
    - docker save $IMAGE_TAG > ./build/$IMAGE_NAME.tar
  artifacts:
    paths: [ build ]
    expire_in: 1 hour
  only:
    - /^feature\/.*$/
    - develop
    - /^release\/.*$/
    - /^hotfix\/.*$/
    - /^bugfix\/.*$/

test:
  stage: test
  image: python:3.9
  script:
    - pip install -r requirements.txt
    - pytest ./test_app.py
  only:
    - /^feature\/.*$/
    - develop
    - /^release\/.*$/
    - /^hotfix\/.*$/
    - /^bugfix\/.*$/
  needs: [ build ]

push:
  stage: push
  before_script:
    - apk update && apk add --no-cache docker openssl bind-tools
    - mkdir -p /etc/docker/certs.d/registry.gitlab.example.com:5005
    - openssl s_client -showcerts -connect registry.gitlab.example.com:5005 </dev/null 2>/dev/null | openssl x509 -outform PEM > ca.crt
    - cp ca.crt /etc/docker/certs.d/registry.gitlab.example.com:5005/ca.crt
  script:
    - echo "Pushing image to registry"
    - echo $CI_REGISTRY_PASSWORD | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY
    - docker load < ./build/$IMAGE_NAME.tar
    - docker push $IMAGE_TAG
  only:
    - /^feature\/.*$/
    - develop
    - /^release\/.*$/
    - /^hotfix\/.*$/
    - /^bugfix\/.*$/
  needs: [ build ]

deploy_sandbox:
  extends:
    - .deploy_template
  stage: deploy-sandbox
  script:
    - kubectl apply -f deployment.yaml -n sandbox
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n sandbox
  only:
    - /^feature\/.*$/
    - /^hotfix\/.*$/
    - /^bugfix\/.*$/
  when: on_success

deploy_dev:
  extends:
    - .deploy_template
  stage: deploy-dev
  script:
    - kubectl apply -f deployment.yaml -n dev
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n dev
  only:
    - develop
  
deploy_qa:
  extends:
    - .deploy_template
  stage: deploy-qa
  script:
    - kubectl apply -f deployment.yaml -n qa
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n qa
  only:
    - /^release\/.*$/

deploy_stg:
  extends:
    - .deploy_template
  stage: deploy-stg
  script:
    - kubectl apply -f deployment.yaml -n stg
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n stg
  only:
    - /^release\/.*$/

deploy_prod:
  extends:
    - .deploy_template
  stage: deploy-prod
  script:
    - kubectl apply -f deployment.yaml -n prod
    - kubectl set image deployment/$KUBE_DEPLOYMENT_NAME $KUBE_DEPLOYMENT_NAME=$IMAGE_TAG -n prod
  only:
    - /^release\/.*$/

Gitflow workflow

Gitflow relies on two long-running branches: main and develop. Since the main branch already exists, we need to create the develop branch

1
2
git checkout -b develop main
git push -u origin develop

This establishes the develop branch as the primary branch for ongoing development work, separate from the stable main branch.

Let’s walk through how the Gitflow workflow would apply to developing and managing your simple Python application hello-world

  1. The developer wants to add a new feature to python app, creates a feature branch from the develop branch and uses the naming pattern feature/*.

    1
    
    git checkout -b feature/add-user-greeting develop
    
  2. The developer starts developing the feature. Modify the app.py file to include the new feature.

    1
    2
    3
    
    @app.route('/farewell/<name>')
    def farewell_user(name):
        return f'Goodbye, {name}! Have a great day!'
    
  3. The developer commits and pushes the changes to feature branch, the pipeline builds, tests, and deploys the application to the sandbox environment.

    1
    2
    3
    
    git add .
    git commit -m "Add user greeting feature"
    git push origin feature/add-user-greeting
    

    Gitflow sandbox pipeline

  4. Once the feature is complete, the developer push the changes to feature branch and creates a merge request from the feature branch into the develop branch by using a squash merge.

  5. When the MR is approved and merged into develop branch, the pipeline builds, tests, pushes the image to registry and deploys the application to the dev environment.

    Gitflow dev pipeline

  6. (Optional) A developer integrates additional feature branches into the develop branch prior to continuing with release activities.

  7. When the developer is ready to release the features in the develop branch, the developer creates a release branch using the naming pattern release/* from the develop branch and push the release branch.

    1
    2
    
    git checkout -b release/v1.0.0 develop
    git push origin release/v1.0.0
    
  8. The release pipeline is triggered and automatically builds, tests, pushes the image to registry(artifact) for reuse across other environments.

    Gitflow release pipeline

  9. An approver manually triggers the deployment to the qa environment, where the app is deployed using the image created in the previous pipeline stage, with the image tag set to CI_COMMIT_SHORT_SHA.

  10. An approver manually triggers the deployment to the stg environment, deploying the app with the same image and tag as in the qa environment.

  11. An approver manually triggers the deployment to the prod environment, deploying the app with the same image and tag as in the qa environment.

  12. The developer creates a merge request from the release branch to the main branch. Once the MR is approved, it is merged into the main branch using a fast-forward merge, avoiding squash merges.

  13. After release branch is merged into main, the developer commit the tag on the main branch.

1
2
3
4
git checkout main
git pull origin main
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin main v1.0.0
  1. The developer creates a merge request from the release branch to the develop branch. Once the MR is approved, it is merged into the develop branch using a fast-forward merge, avoiding squash merges. The release branch can be deleted after the code is merged successfully.

Hotfix process

A hotfix in Gitflow is a dedicated branch created to address critical issues or bugs found in the production environment after the release branch has been merged into the main branch and the release branch is deleted. It allows developers to quickly patch the issue without disrupting ongoing development work in the develop branch or other feature branches.

  1. The developer creates a hotfix branch from main branch with the naming pattern hotfix/*

    1
    2
    3
    
    git checkout main
    git pull origin main
    git checkout -b hotfix/v1.0.1 main
    
  2. The developer fixes the issue, push the changes to hotfix branch, The hotfix pipeline is triggered automatically which builds, test and deploys the application to sandbox environment(to test if the fix is working properly).

    1
    2
    3
    
    git add .
    git commit -m "fix issue"
    git push origin hotfix/v1.0.1
    
  3. The developer creates a release branch from the main branch with naming pattern release/*

    1
    2
    3
    4
    5
    
    git checkout main
    git pull origin main
    git checkout -b release/v1.0.1 main
    git commit -m "[skip ci] create release/v1.0.1 branch" # [skip ci] to avoid trigger release pipeline before MR.
    git push origin release/v1.0.1
    
  4. The developer creates a merge request from the hotfix branch into the release/v1.0.1 branch by using a squash merge.

  5. When the MR is approved and merged into release branch, the pipeline builds, tests, pushes the image to registry.

  6. An approver manually triggers the deployment to the qa environment, where the app is deployed using the image created in the previous pipeline stage, with the image tag set to CI_COMMIT_SHORT_SHA.

  7. An approver manually triggers the deployment to the stg environment, deploying the app with the same image and tag as in the qa environment.

  8. An approver manually triggers the deployment to the prod environment, deploying the app with the same image and tag as in the qa environment.

  9. The developer creates a merge request from the release branch to the main branch. Once the MR is approved, it is merged into the main branch using a fast-forward merge, avoiding squash merges.

  10. After release branch is merged into main, the developer commit the tag on the main branch.

    1
    2
    3
    4
    
    git checkout main
    git pull origin main
    git tag -a v1.0.1 -m "Release version 1.0.1"
    git push origin main v1.0.1
    
  11. The developer creates a merge request from the release branch to the develop branch. Once the MR is approved, it is merged into the develop branch using a fast-forward merge, avoiding squash merges.

Bugfix process

A bugfix is performed during the release process before the code is merged into the main branch, This is a common scenario in Gitflow when an issue is discovered in the release branch during testing or validation.

  1. The developer creates a bugfix branch from the current release/v1.0.0 branch and uses the naming pattern bugfix/*.

    1
    2
    3
    
    git checkout release/v1.0.0
    git pull origin release/v1.0.0
    git checkout -b bugfix/bug-1 release/v1.0.0
    
  2. The developer fixes the issue, push the changes to bugfix branch, The bugfix pipeline is triggered automatically which builds, test and deploys the application to sandbox environment(to test if the fix is working properly).

  3. The developer creates a merge request from the bugfix branch into the release/v1.0.0 branch by using a squash merge.

  4. When the MR is approved and merged into release branch, the pipeline builds, tests, pushes the image to registry.

  5. An approver manually triggers the deployment to the qa environment, where the app is deployed using the image created in the previous pipeline stage, with the image tag set to CI_COMMIT_SHORT_SHA.

  6. An approver manually triggers the deployment to the stg environment, deploying the app with the same image and tag as in the qa environment.

  7. An approver manually triggers the deployment to the prod environment, deploying the app with the same image and tag as in the qa environment.

  8. The developer creates a merge request from the release branch to the main branch. Once the MR is approved, it is merged into the main branch using a fast-forward merge, avoiding squash merges.

  9. After release branch is merged into main, the developer commit the tag on the main branch.

    1
    2
    3
    4
    
    git checkout main
    git pull origin main
    git tag -a v1.0.0 -m "Release version 1.0.0"
    git push origin main v1.0.0
    
  10. The developer creates a merge request from the release branch to the develop branch. Once the MR is approved, it is merged into the develop branch using a fast-forward merge, avoiding squash merges.

Trunk vs Gitflow

AspectTrunk-Based DevelopmentGitflow
Workflow StructureRelies on a single main branch with short-lived feature branches or direct commits.Uses long-lived branches (develop, main) with release and feature branches for parallel development.
Release CadenceIdeal for frequent, continuous releases.Suited for planned, periodic releases.
ComplexitySimpler workflow, but requires discipline to keep main stable.More complex due to multiple branches and merges, suitable for larger teams.
Pros- Faster release cycles.
- Easier to automate with CI/CD.
- Minimal branch management overhead.
- Clear structure for managing large projects.
- Safer for parallel work and strict release cycles.

Important notes

  • Restrict developers from directly pushing to the main branch to maintain its integrity. Use merge requests and appropriate approvals to update it.
  • In Trunk based development, when integrating incomplete features into the main branch during development, ensure they remain hidden or non-functional in production by using feature flags.
  • In Gitflow, production deployments can be made from either the release branch or the main branch. I recommend deploying from the release branch and merging into the main branch only after verifying that everything works fine in the production environment. However, you can adjust the pipeline if you prefer deploying from the main branch.
  • In Gitflow, always ensure a fast-forward merge from the release branch to the main branch to avoid creating an extra merge commit and maintain a clean, linear history in the main branch.
  • Image promotion:
    • For demonstration purposes, I used a single GitLab registry for all environments. In a real-world setup, you might use a separate image registry for each environment. You can modify the pipeline to support image promotion by adding push jobs for each environment to push images to the appropriate registry before app deployment.
    • If you’re using AWS Elastic Container Registry (ECR), consider enabling cross-account and cross-region image replication to replicate images automatically across environments instead of creating multiple push/promote jobs in the pipeline.

Happy deploying! 🚀