Managing Continuous Delivery with Trunk-Based Development
In a typical software development lifecycle, teams often have tight timelines and ongoing changes to deliver regularly, sometimes leading up to the day a product launches. Traditional branching methods tend to introduce delays, merge conflicts, and slow down code deployment, making it much more difficult for teams to remain productive and agile. Trunk-based development differs. This method focuses on small, frequent updates to a shared main branch, meaning your team can quickly merge changes and ship updates.
Teams that commit code regularly avoid the dreaded “integration hell” when merging work after long stretches of non-working tests and commits, which is messy and slows things down if you are working with a production-ready CI/CD pipeline. Regular code reviews, automated testing, and proper version control hygiene enable reliable code at all times. For a more extensive breakdown of these principles, refer to the Aviator Blog.
This blog will explain why Trunk-based development is an excellent choice for those who frequently work with a CD(Continuous Delivery) pipelines. We will also understand how it utilizes workflows like short-lived branches and feature flags and emphasizes automation for frequent deployments.
How It Aligns Well with Continuous Delivery Practices
Continuous delivery focuses on making software deployment to production frequent, reliable, and automated way. While trunk-based development aligns perfectly with this goal by ensuring that every change is integrated and tested in small increments, reducing the risk of deployment failures. The approach fosters rapid feedback loops, as developers can quickly identify issues when their changes are integrated.
Moreover, using short-lived branches or direct commits ensures that the codebase remains consistent and deployable. This synergy between trunk-based practices and continuous delivery simplifies the path from code commit to production, making it easier for teams to adopt an iterative and incremental delivery model.
Continuous Integration & Continuous delivery as a Foundation for Trunk-Based Development
Continuous delivery ensures that every change pushed to the trunk is automatically built, tested, and validated. This practice reinforces trunk stability by detecting and resolving issues early in development. Teams typically use Integration & continuous delivery pipelines to enforce coding standards, test suites and monitor build performance.
However, trunk-based development isn’t the only branching pattern you can adapt to your development lifecycle. Here are four major branching patterns that are widely used and adapted by developers:
Feature Branching:
Feature branching creates a separate branch for each feature or task, allowing developers to work independently without impacting the main branch. Once the feature is complete and reviewed, the branch is merged into the main branch and typically triggers a pipeline. This approach helps maintain a clear separation of work and managing complex features. However, frequent synchronization with the main branch is required to avoid significant integration conflicts and ensure timely feedback through continuous integration.
Release Branching:
In release branching, a dedicated branch stabilizes and deploys a specific release. This branch is used exclusively for testing, bug fixes, and finalizing the version for production. Once the release is deployed, the branch is either maintained for hotfixes or archived. This pattern is valuable for managing long-lived releases and supporting multiple production versions but requires disciplined maintenance to prevent significant divergence from the main development branch.
Branching by Abstraction:
Branching by abstraction eliminates the need for separate branches by using in-code mechanisms like feature flags or abstract interfaces to develop and integrate new functionality incrementally. Developers build changes alongside existing code, hiding incomplete features until they are ready for deployment. This approach avoids the complexities of merging long-lived branches and facilitates smoother integrations but requires technical expertise and careful planning to manage abstractions effectively.
Forking Workflow:
The forking workflow involves developers creating personal copies (forks) of the central repository to work on features or bug fixes. Once the work is complete, the changes are submitted as pull requests for review and merging into the central repository. This pattern is typical in open-source projects, allowing contributors to work independently without affecting the main codebase. While it promotes isolated development, it may introduce overhead in reviewing and managing multiple forks.
For a deeper understanding of various branching workflows, look at this blog, which covers all the different branching strategies you may encounter as a developer throughout your career.
Implementing Continuous Delivery in Trunk-Based Development
By integrating trunk-based workflows with automated deployment pipelines, teams can ensure every change progresses smoothly from commit to production with proper checks in place now that could be lint or blackduck. Tools like Jenkins, GitHub Actions, Azure DevOps or CircleCI, or AWS CodePipeline are commonly used to implement Continuous delivery pipelines. Developers are responsible for the entire software life cycle, from writing code to running it in production. It promotes best practices such as trunk-based development and CI/CD. It encourages ownership from end to end and ownership for code completion on a live timeline.
Further techniques like blue-green deployments, canary releases, or rolling updates help teams deploy updates without disrupting users. These strategies align well with trunk-based development by enabling frequent deployments while ensuring service availability.
Continuous Delivery as an Example
Trunk-based development is particularly effective in microservices architectures, where teams manage independent services with frequent updates. In this example, we will understand how a team might use trunk-based practices to deliver updates to a Machine Learning recommendation service by leveraging feature flags and canary deployments to roll out changes gradually.
This is how the project structure is going to look like for our hands-on:

Depending on your use case, it is up to you to push your changes to the main branch or a separate feature branch. When managing multiple branches in a microservice architecture, you can check Aviator’s blog to manage branches in a TBD when using a microservice architecture.
Start by initializing the node project using npm:
npm init
Follow along with the prompts to provide the service name and other details.
After the initialization, we will install the express package as we would be creating a Node backend using it.
npm install express
Now, coming onto the source folder, we have two files. The first one is the index.js file, the initializer file for the source directory. It sets up a Node backend with a recommendation response upon request.
src/index.js
const express = require('express');
const { getRecommendation } = require('./recommendation');
const app = express();
const PORT = process.env.PORT || 3000;
const FEATURE_FLAG = process.env.FEATURE_FLAG === 'true';
app.get('/recommendation', (req, res) => {
const recommendation = getRecommendation(FEATURE_FLAG);
res.json({ recommendation });
});
app.listen(PORT, () => {
console.log(Recommendation service running on port ${PORT});
});
The other one is the recommendation.js file, which recommends the recommendation model based on your feature flag variable.
src/recommendation.js
function getRecommendation(featureFlag) {
if (featureFlag) {
return 'Personalized Recommendation v2';
}
return 'Standard Recommendation v1';
}
module.exports = { getRecommendation };
Now, we will create a Dockerfile for this source package.
Dockerfile
FROM node:16
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "src/index.js"]
After the Dockerfile is created, we can test it out by building a container image:
docker build -t recommendation-service .
docker run -p 3000:3000 recommendation-service
We will move to creating Helm deployments. The helm/ directory contains files that define your Kubernetes application’s Helm chart, which is used to manage and deploy the application on a Kubernetes cluster.
Start by defining your helm deployment in the Chart.yaml file.
Chart.yaml
apiVersion: v2
name: recommendation-service
description: A Helm chart for deploying the recommendation-service
type: application
version: 1.0.0
appVersion: 1.0.0
Next, define your variables, such as docker username, image name, image tags, service ports, ingress rules, and resource limits in the values.yaml file.
Replace the placeholder <your-dockerhub-username> with your docker username.
values.yaml
replicaCount: 3
image:
repository: <your-dockerhub-username>/recommendation-service
tag: "latest"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 3000
resources:
limits:
cpu: 500m
memory: 256Mi
requests:
cpu: 250m
memory: 128Mi
Then, define your helm deployment template like this. The template would pick up the image and port details from the values.yaml file only.
helm/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: recommendation-service
spec:
replicas: 2
selector:
matchLabels:
app: recommendation-service
template:
metadata:
labels:
app: recommendation-service
spec:
containers:
- name: recommendation-service
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
ports:
- containerPort: {{ .Values.service.port }}
env:
- name: FEATURE_FLAG
value: "false"
Similarly, define your helm service template as well:
helm/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
name: recommendation-service
spec:
selector:
app: recommendation-service
ports:
- protocol: TCP
port: 80
targetPort: {{ .Values.service.port }}
type: {{ .Values.service.type }}
At this point, we have set the groundwork for creating a docker image for our recommendation system and then making a kubernetes helm deployment from that image.
All that is left is to create an automated pipeline. We would now define a CI/CD pipeline that would take the newest iteration of changes, build them into a Docker image, push the image to a container registry and then deploy the new changes to the kubernetes cluster using helm. This is how the CI/CD workflow file would look like.
Remember to change the placeholder <your-dockerhub-username> with your docker username.
.github/workflows/ci-cd.yaml
name: CI/CD Pipeline
on:
push:
branches:
- main
- 'feature/*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
- name: Build Docker image
run: |
docker build -t <your-dockerhub-username>/recommendation-service:latest \
-t <your-dockerhub-username>/recommendation-service:${{ github.sha }} .
docker push --all-tags <your-dockerhub-username>/recommendation-service
- name: Deploy to Kubernetes
run: |
helm upgrade --install recommendation-service helm/ \
--set image.tag=${{ github.sha }}
Conflict Management and Build Stability
Trunk-based development relies on frequent integrations, reducing the time window for conflicts, but they can still occur, particularly in larger teams. To minimize conflicts:
- Keep changes small: Aim for minor, incremental updates rather than significant, overlapping changes. Frequent, manageable commits help reduce the chances of conflicts.
- Effective communication: Developers should stay informed about each other’s changes, especially when working on shared components or resources.
- Feature flags: Use feature flags to decouple incomplete or experimental features from the main codebase. This allows developers to merge their work without compromising system stability.
For real-time conflict resolution, tools like Aviator’s merge queue, GitLens, and Visual Studio Code’s Live Share allow developers to see and resolve conflicts immediately. Automated merge bots such as Mergify can also be configured to handle trivial disputes automatically.
Scaling Trunk-Based Development for Large Teams
For distributed teams, trunk-based workflows help maintain alignment and efficiency:
- Coding standards: Enforce consistent coding practices to avoid unnecessary complexity and ensure uniformity across team members’ work.
- Regular updates: Encourage frequent standups or asynchronous updates to keep all team members aware of ongoing work, which helps prevent conflicts and delays.
- Collaboration tools: Use platforms like GitHub, Slack, and Jira to maintain visibility and transparency in team tasks and progress.
Scaling Strategies for Microservices Environments
In a microservices architecture, trunk-based development can scale effectively by:
- Domain-based team structures: Organize teams around specific domains or services. This approach allows teams to take ownership of their services, reducing dependencies and bottlenecks.
- Independent CI pipelines: Each service should have its own CI pipeline to avoid delays caused by bottlenecks in shared pipelines.
- Service meshes: Use service meshes like Istio to manage inter-service communication, enabling features like traffic splitting during deployment and ensuring safe rollouts.
Best Practices for Effective Trunk-Based Development
- Automated Testing: Ensure comprehensive test coverage, such as unit tests, integration tests, and end-to-end tests, to validate each change before merging it into the trunk. This helps detect issues early and maintain high-quality standards.
- Peer Reviews: Foster a culture of code reviews. Peer reviews catch issues and encourage knowledge-sharing and collaboration across the team.
- Regular Refactoring: Set aside time for regular refactoring to address technical debt. This ensures that the trunk remains clean and maintainable, facilitating future development.
- Monitoring Pipeline Health: Continuously monitor CI pipeline health by analyzing build times and failure patterns. Use the insights to optimize and maintain pipeline efficiency.
Addressing Common Challenges
Resistance to trunk-based development often arises due to concerns about frequent changes or the comfort of working with long-lived branches. To address this resistance, it’s helpful to highlight success stories of organizations that have successfully adopted trunk-based development, showcasing its practical benefits and positive impact on productivity. A gradual adoption approach can also ease the transition, starting with smaller teams or projects to allow the organization to adjust before scaling the practice across the entire team. Encouraging experimentation is another key strategy, fostering a culture where developers feel empowered to make frequent changes and learn from quick wins.
Ensuring that CI pipelines remain fast and reliable becomes increasingly essential as teams grow. To achieve this, pipelines should be optimized with parallelism and caching, reducing build times and improving efficiency. Using staging environments allows for validating changes before they reach production, minimizing the risk of errors. Regular monitoring of pipeline performance using tools like CircleCI Insights or GitHub Actions Logs can help identify bottlenecks and continuously improve build efficiency.
Balancing rapid delivery with high code quality requires several critical practices. First, pre-merge testing ensures that every change is thoroughly tested before merging into the trunk, preventing broken builds. Peer reviews help maintain code quality, while feature toggles enable teams to decouple incomplete features, ensuring continuous delivery without compromising stability. Finally, defining clear quality metrics, such as minimum code coverage thresholds, helps ensure that rapid changes don’t negatively affect the system’s integrity.
Conclusion
With trunk-based development, changes are kept small and frequent, enabling a much more efficient and less error-prone method of delivering software, reducing integration time, and minimizing deployment interruption. In this post, we demonstrated how a trunk-based workflow fits well with continuous delivery and allows teams to release updates quickly without compromising on a stable code base. Short-lived branches and feature flags are essential in reducing conflicts and highly validated changes through automated testing.
Additionally, we demonstrated a more detailed hands-on example of deploying a trunk-based development cycle using state-of-the-art tools like Docker, Helm, and GitHub Actions and a CI/CD pipeline for automated building, testing, and deploying each change. We also discussed strategies for handling common problems like feature incompleteness, conflict resolution, and maintaining a stable build pipeline. Teams find that with trunk-based development, they simplify their workflows, minimize integration problems, and are confident in confidently deploying software whenever they see fit, leading to a culture of continuous improvement and rapid, quality software delivery.
FAQs
How Does Trunk-based Development Improve Deployment Speed?
Trunk-based development accelerates deployment by encouraging frequent, small commits to the main branch, reducing integration delays and allowing for quicker detection of issues. This enables faster, more predictable releases with automated CI/CD pipelines.
What is the Role of Feature Flags in Microservices and Trunk-based Workflows?
Feature flags enable the safe introduction of incomplete features by decoupling development from deployment. They allow teams to test and deploy features gradually without impacting users, ensuring stability in production.
Are There Scenarios Where Trunk-based Development Might Not Work Well?
Trunk-based development may only suit teams working on long-lived features or those with robust CI/CD pipelines. It can also be challenging for teams unfamiliar with frequent integration.
What Tools Complement Trunk-based Development for Microservices?
CI/CD tools like Jenkins or GitHub Actions automate testing and deployment. Feature flags management platforms like LaunchDarkly and service mesh technologies like Istio help manage microservices and controlled deployments.
How Do You Maintain Stability With Frequent Commits and Deployments?
Stability is maintained through automated testing, feature flags, and deployment strategies like canary or blue-green. Monitoring and quick rollback mechanisms help ensure issues are detected and resolved swiftly.
