{"id":319,"date":"2026-03-12T07:05:26","date_gmt":"2026-03-12T07:05:26","guid":{"rendered":"https:\/\/blog.ngocha.biz\/?p=319"},"modified":"2026-03-12T07:05:26","modified_gmt":"2026-03-12T07:05:26","slug":"docker-image-build-and-promotion-pipeline","status":"publish","type":"post","link":"https:\/\/blog.ngocha.biz\/?p=319","title":{"rendered":"Docker Image Build and Promotion Pipeline: A Production Guide"},"content":{"rendered":"<p>Looking for a guide that teaches you production level <a href=\"https:\/\/devopscube.com\/build-docker-image\/\" rel=\"noreferrer\">docker image build<\/a> workflow for application? You are in the right place.<\/p>\n<p>By the end of this guide, you will understand:<\/p>\n<ul>\n<li>Deployment environments in real projects<\/li>\n<li>Container registry architecture for multi-environment pipelines<\/li>\n<li><a href=\"https:\/\/devopscube.com\/build-docker-image-kubernetes-pod\/\" rel=\"noreferrer\">Docker image<\/a> tagging strategy (immutable vs mutable tags)<\/li>\n<li>Git branching strategy for Docker based <a href=\"https:\/\/devopscube.com\/learning-ci-cd-tools\/\" rel=\"noreferrer\">CI\/CD<\/a> pipelines.<\/li>\n<li>How the PR-based image build workflow is structured end-to-end<\/li>\n<\/ul>\n<p>Lets get started.<\/p>\n<h2 id=\"deployment-environments-in-real-projects\">Deployment Environments in Real Projects<\/h2>\n<p>The common question everyone has is &#8220;<em>How many environments are there in real projects?<\/em>&#8220;<\/p>\n<p>Well it depends.<\/p>\n<ul>\n<li><strong>Small projects<\/strong>&nbsp;typically have three environments. For example, dev, stage, and prod. This is the minimum you need for a proper promotion flow. <\/li>\n<li><strong>Medium projects<\/strong>&nbsp;add a dedicated QA (for functional testing, separate from dev) and a Performance environment for load testing. The stage environment acts as a pre-prod environment.<\/li>\n<li><strong>Enterprise projects<\/strong> (finance, healthcare, government)&nbsp; often have many environments. &nbsp;For example, dev, SIT (system integration testing), QA, performance, staging, UAT (user acceptance testing), and production. It can be more.<\/li>\n<\/ul>\n<p>I have worked with many small to enterprise projects, and none of the projects had the same number of environments. It totally depends on the project and teams.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udccc<\/div>\n<div class=\"kg-callout-text\">In this guide, we will use a <b><strong style=\"white-space: pre-wrap;\">dev, stage and prod<\/strong><\/b> environments as example. It can scale to any number of environments. You just need to add more promotion steps in between.<\/div>\n<\/div>\n<p><em>So how are these environments segregated?<\/em><\/p>\n<p>In most enterprise projects, each environment means, <strong>a dedicated cloud account<\/strong> ( <em>or subscription\/project depending on the cloud provide<\/em>r).  Each account has its own <a href=\"https:\/\/devopscube.com\/secondary-network-eks-cluster\/\" rel=\"noreferrer\">VPC<\/a>, <a href=\"https:\/\/devopscube.com\/terraform-iam-role\/\" rel=\"noreferrer\">IAM roles<\/a>, Security, etc.<\/p>\n<p>For example, the dev environment runs in the dev AWS account, the stage in the stage account, and so on. Each account has its own VPC, IAM roles, security perimeter, and billing. <\/p>\n<p>The key reason for this is, <strong>blast radius isolation<\/strong>. Meaning, how much of the environment is affected when something happens to the environment. So if a developer deletes something in dev, only dev is affected.<\/p>\n<p>Also, separate accounts <strong>helps tracking costs better<\/strong>, and maintain clear security logs. We will look at this in detail in the registry architecture section below.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\">In the production account, no one will have access to do anything manually (zero standing access in production). All the changes happen through CI\/CD systems. <\/div>\n<\/div>\n<h2 id=\"build-once-and-deploy-everywhere\">Build once and Deploy Everywhere<\/h2>\n<p>Another key question most engineer have is, <em>should you rebuild images for each environment?<\/em><\/p>\n<p>Answer is no! The standard best practice is to build the image once and the image selected for production deployment should be promoted it till prod. This is the workflow we are discussing in this guide.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\">What does image promotion really mean?<\/p>\n<p>When we say image promotion, taking a verified container image and moving it across environments. In practice, this means either retagging the image or copying it to a different registry using tools like crane or skopeo.<\/p><\/div>\n<\/div>\n<p>Next lets look at the key container registry usage patterns.<\/p>\n<h2 id=\"container-registry-patterns\">Container Registry Patterns<\/h2>\n<p>One of the common questions asked by a <a href=\"https:\/\/devopscube.com\/become-devops-engineer\/\" rel=\"noreferrer\">DevOps engineer<\/a> is, <em>how many container registries should we have for a project?<\/em><\/p>\n<p>Well, it depends on the project. The common pattern used in enterprise environments is <strong>Per-Account Registry Pattern.<\/strong><\/p>\n<h3 id=\"1-per-account-registry-pattern\"><strong>1. Per-Account Registry Pattern<\/strong><\/h3>\n<p>Meaning, each account (environment) gets its own container registry. (Eg, <a href=\"https:\/\/devopscube.com\/setup-argocd-image-updater\/\" rel=\"noreferrer\">AWS ECR<\/a>). So the dev account has its own registry, the staging account has its own, and so on.<\/p>\n<figure class=\"kg-card kg-image-card kg-card-hascaption\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-60.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"2000\" height=\"1739\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/image-60.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/image-60.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/image-60.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w2400\/2026\/03\/image-60.png 2400w\" sizes=\"auto, (min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Illustration of Per-Account Contianer Registry Pattern<\/span><\/figcaption><\/figure>\n<p>So why separate registries?<\/p>\n<p>Its the same concerns we discussed above.<\/p>\n<ul>\n<li>If the dev account is compromised, they can&#8217;t touch the prod registry or its images. The accounts are completely separate.<\/li>\n<li>Each account has different permissions. For example, Dev teams may get full access to the dev registry for productivity. However, <strong>only the CI pipeline job can push to prod<\/strong>. No one can push or modify images in prod manually.<\/li>\n<li>It also helps comply with SOC2, HIPAA, and PCI-DSS for strong security controls.<\/li>\n<\/ul>\n<h3 id=\"2-shared-registry-pattern\">2. Shared Registry Pattern<\/h3>\n<p>In this pattern, you have one container registry for multiple environments. For example, dev, QA, stage, and pre-prod use the same registry.<\/p>\n<figure class=\"kg-card kg-image-card kg-card-hascaption\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-61.png\" class=\"kg-image\" alt=\"\" loading=\"lazy\" width=\"2000\" height=\"1033\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/image-61.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/image-61.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/image-61.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w2400\/2026\/03\/image-61.png 2400w\" sizes=\"auto, (min-width: 720px) 720px\"><figcaption><span style=\"white-space: pre-wrap;\">Illustration of shared Contianer Registry Pattern<\/span><\/figcaption><\/figure>\n<p>You have to use namespaces or paths to separate environments.<\/p>\n<p>For example, <\/p>\n<ul>\n<li>In Dockerhub, you have to use naming conventions like&nbsp;<code>myorg\/dev-payment-svc<\/code>,&nbsp;<code>myorg\/stage-payment-svc<\/code><\/li>\n<li><strong>GCR\/Harbor \/ JFrog Artifactory<\/strong> supports project-based separation with individual access control (e.g.,&nbsp;<code>harbor.company.com\/dev\/payment-svc<\/code>)<\/li>\n<\/ul>\n<p>So the common structure of registries that support paths looks like the following.<\/p>\n<pre><code class=\"language-bash\">registry.company.com\/\n\u251c\u2500\u2500 dev\/\n\u2502   \u251c\u2500\u2500 payment-svc\n\u2502   \u251c\u2500\u2500 auth-svc\n\u2502   \u2514\u2500\u2500 order-svc\n\u251c\u2500\u2500 stage\/\n\u251c\u2500\u2500 release\/\n\u2514\u2500\u2500 prod\/<\/code><\/pre>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\">The key issue in this pattern is access control and no isolation&nbsp;between&nbsp;non-prod environments. Anyone with push access can push to both&nbsp;<code spellcheck=\"false\" style=\"white-space: pre-wrap;\">qa-payment-svc<\/code>&nbsp;and&nbsp;<code spellcheck=\"false\" style=\"white-space: pre-wrap;\">stage-payment-svc<\/code><\/p>\n<p>Also, this approach is simple and cost-effective. You have to manage two registries. One shared for non-prod, one dedicated for prod.<\/p><\/div>\n<\/div>\n<h2 id=\"registry-storage-and-garbage-collection\">Registry Storage and Garbage Collection<\/h2>\n<p>Garbage Collection is the key thing you should plan in your pipeline.<\/p>\n<p>Think of 100+ services pushing images daily. What happens to registry storage? That is where&nbsp;<strong>garbage collection policies<\/strong>&nbsp;come in. <\/p>\n<p>You can configure policies to automatically clean up old images based on certain criteria.<\/p>\n<p>For example,<\/p>\n<ul>\n<li>Delete after a certain number of days or<\/li>\n<li>After a set number of images. <\/li>\n<\/ul>\n<p>Most registries (Harbor, ECR, GCR, Artifactory) support this natively.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\"><b><strong style=\"white-space: pre-wrap;\">Key Production Insight<\/strong><\/b><br \/>When it comes to production images, every organization has compliance rules to keep certain versions that were deployed to production for a certain period of time. <\/p>\n<p>For example, in financial services, the compliance requirement is to keep production artifacts for 5 to 7 years<\/p><\/div>\n<\/div>\n<h2 id=\"image-tagging-strategy-immutable-vs-mutable-tags\">Image Tagging Strategy (Immutable vs Mutable Tags)<\/h2>\n<p>One of the key things that helps in how we track, promote, and trace images across environments is image tags.<\/p>\n<p>There are two types of tags.<\/p>\n<ol>\n<li><strong>Immutable tag: <\/strong> It is a tag that never changes and always points to the same image. For example, tag based on the GitHub commit SHA ID (<code>registry\/service:sha-abc1234)<\/code>.The best part is, it is a one to one mapping between the image and the exact source code that built it.<\/li>\n<li><strong>Mutable tag: <\/strong>It is a tag that can be updated to point to a different image. For example, the&nbsp;<code>latest<\/code>&nbsp;tag. It always points to the last pushed image. It is actually useful for quick testing. <\/li>\n<\/ol>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-62.png\" class=\"kg-image\" alt=\"Immutable vs Mutable Tags in Docker images\" loading=\"lazy\" width=\"1591\" height=\"1936\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/image-62.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/image-62.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-62.png 1591w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\"><b><strong style=\"white-space: pre-wrap;\">Note:<\/strong><\/b> Production deployments should always use immutable tags so we can always track exact version of the application. It also helps in easy rollback.<\/div>\n<\/div>\n<h2 id=\"docker-image-build-pipeline-architecture\">Docker Image Build Pipeline  Architecture <\/h2>\n<p>Before we dive deep, lets look at the high-level architecture.<\/p>\n<p>Our pipeline is <strong>split into&nbsp;four stages<\/strong>. Each stage get triggered at a different point in the development lifecycle.<\/p>\n<ol>\n<li>PR Check Workflow<\/li>\n<li><a href=\"https:\/\/devopscube.com\/build-docker-image\/\" rel=\"noreferrer\">Image Build<\/a> + Dev Deployment<\/li>\n<li>Image promotion from dev to stage environment<\/li>\n<li>Image promotion from stage to prod environment.<\/li>\n<\/ol>\n<p>The following image illustrates those four stages.<\/p>\n<figure class=\"kg-card kg-image-card kg-card-hascaption\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion.png\" class=\"kg-image\" alt=\"Docker Image Build Pipeline Architecture with GitHub Actions\" loading=\"lazy\" width=\"1512\" height=\"950\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/build-promotion.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/build-promotion.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion.png 1512w\" sizes=\"auto, (min-width: 720px) 720px\"><figcaption><b><strong style=\"white-space: pre-wrap;\">(Click the image to view in HD)<\/strong><\/b><\/figcaption><\/figure>\n<p>Before we get into the image build pipeline, you need to <strong>first understand the branching strategy<\/strong>. Why? Because every workflow in this pipeline is triggered by a branch event.<\/p>\n<p>If you don&#8217;t understand which branch does what, the workflow triggers won&#8217;t make sense.&nbsp;<\/p>\n<h2 id=\"branching-strategy\">Branching Strategy<\/h2>\n<p>Our pipeline follows a Git-flow style branching model. The key branches <code>develop<\/code>,&nbsp;<code>release<\/code>, and&nbsp;<code>main<\/code>&nbsp;drive the pipeline through dev, stage, and prod environments.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\">There are other branching strategies like <a href=\"https:\/\/www.atlassian.com\/continuous-delivery\/continuous-integration\/trunk-based-development?ref=devopscube.com\" rel=\"noreferrer\">trunk based<\/a> where all developers commit directly to a single main branch with short-lived feature branches. <\/div>\n<\/div>\n<p>Here is the key thing to understand.<\/p>\n<p>Not every environment needs its own branch. For example,  for <strong>environments like perf, UAT, or pre-prod<\/strong> the same image that is ready for production gets promoted into them without a separate branch.<\/p>\n<p>The following image illustrates the git branching strategy.<\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-promotion4-1.png\" class=\"kg-image\" alt=\"branching strategy\" loading=\"lazy\" width=\"2000\" height=\"1343\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/image-promotion4-1.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/image-promotion4-1.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/image-promotion4-1.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w2400\/2026\/03\/image-promotion4-1.png 2400w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Here is the development flow.<\/p>\n<ul>\n<li><code><strong>feature\/*<\/strong><\/code>&nbsp;: Developers create feature branches from&nbsp;<code>develop<\/code>. When the feature is ready, they raise a PR. This triggers the PR check workflow.<\/li>\n<li><strong><code>develop<\/code><\/strong>&nbsp;: Merging a PR triggers the image build followed by deployment to the dev environment. This happens many times.<\/li>\n<li><code><strong>release\/*<\/strong><\/code>&nbsp;: When a version is finalized for production deployment, the team cuts a release branch from&nbsp;<code>develop<\/code>. This triggers the stage promotion. QA and integration testing happen here. Also, all the bugfixes go directly on this branch.<\/li>\n<li><code><strong>main<\/strong><\/code>&nbsp;: Merging the release branch into&nbsp;<code>main<\/code>&nbsp;triggers the production promotion. <\/li>\n<\/ul>\n<p>Now lets say, the app is running in production and what if we want to fix something in production?<\/p>\n<p>This is where, we will create a hotfix branch.<\/p>\n<p>For emergency production fixes, a hotfix branch is created from&nbsp;<code>main<\/code>, tested quickly, merged back to both&nbsp;<code>main<\/code>&nbsp;(prod deploy) and&nbsp;<code>develop<\/code>&nbsp;(so the fix is not lost).<\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/image-promotion5.png\" class=\"kg-image\" alt=\"hotfix branch flow\" loading=\"lazy\" width=\"2000\" height=\"734\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/image-promotion5.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/image-promotion5.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/image-promotion5.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w2400\/2026\/03\/image-promotion5.png 2400w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Now lets look at the image build workflow in detail.<\/p>\n<h3 id=\"pr-check-workflow\">PR Check Workflow<\/h3>\n<p>This is where everything begins. When you raise a Pull Request (PR) to the&nbsp;<code>develop<\/code>&nbsp;branch, this workflow kicks in automatically.<\/p>\n<p>The goal here is very simple. We need to verify that everything is correct before the code gets merged.<\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion02.png\" class=\"kg-image\" alt=\"steps involved in PR check workflow\" loading=\"lazy\" width=\"2000\" height=\"985\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/build-promotion02.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/build-promotion02.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/build-promotion02.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion02.png 2328w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Here is how it works.<\/p>\n<ul>\n<li>The PR first checks out the files and folders on the GitHub repository.<\/li>\n<li>Then, it uses <a href=\"https:\/\/devopscube.com\/lint-dockerfiles-using-hadolint\/\" rel=\"noreferrer\">Hadolint<\/a> to lint the <a href=\"https:\/\/devopscube.com\/create-dockerfile-using-docker-init\/\" rel=\"noreferrer\">Dockerfile<\/a> that catches common issues (like missing version pinning or running as root).<\/li>\n<li>Then it uses Buildx to build the Docker image using the Dockerfile to check if the image builds without any issues.<\/li>\n<li>For the built image, it generates an SBOM (Software Bill of Materials) report using Trivy and uploads it to GitHub Actions artifacts.<\/li>\n<li>Then, it scans the Docker image using <a href=\"https:\/\/devopscube.com\/scan-docker-images-using-trivy\/\" rel=\"noreferrer\">Trivy<\/a> for vulnerabilities.<\/li>\n<li>Finally, it uses the <a href=\"https:\/\/github.com\/GoogleContainerTools\/container-structure-test?ref=devopscube.com\" rel=\"noreferrer\">container structure test<\/a> to verify if it meets your compliance rules.<\/li>\n<\/ul>\n<p>So far, so good. <\/p>\n<p><strong>If the PR check passes<\/strong>, it means the Dockerfile has no issues, the image builds as expected, there are no known vulnerabilities in the image, and the container structure is compliant. <strong>Only then we will merge the PR.<\/strong><\/p>\n<h3 id=\"build-multi-architecture-docker-image-and-deploy-to-dev\">Build Multi-Architecture Docker Image and Deploy to Dev<\/h3>\n<p>Once the PR is merged into the&nbsp;<code>develop<\/code>&nbsp;branch, the image build workflow triggers automatically.<\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion03.png\" class=\"kg-image\" alt=\"steps involved in the Image Build workflow\" loading=\"lazy\" width=\"1936\" height=\"1144\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/build-promotion03.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/build-promotion03.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/build-promotion03.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion03.png 1936w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Here is how it works.<\/p>\n<ul>\n<li>The build workflow starts with checking out the files and folders on the GitHub repository.<\/li>\n<li>Then it installs QEMU and Buildx to <a href=\"https:\/\/devopscube.com\/build-multi-arch-docker-image\/\" rel=\"noreferrer\">build multi architecture Docker image<\/a>. In the workflow, we specify to build <code>linux\/amd64 and linux\/arm64<\/code> architecture.<\/li>\n<li>Logs in to DockerHub using credentials stored in GitHub Secrets.<\/li>\n<li>Builds and pushes the image to the&nbsp;dev&nbsp;container registry with two tags. An&nbsp;<strong>immutable git commit SHA<\/strong> (e.g.,&nbsp;<code>sha-2b2e927<\/code>) based tag and a&nbsp;mutable tag&nbsp;(<code>latest<\/code>). The SHA tag is what we will use later to promote the exact image to stage and prod.<\/li>\n<li>Deploys the image to the&nbsp;<strong>dev environment<\/strong>&nbsp;(<a href=\"https:\/\/devopscube.com\/kubernetes-tutorials-beginners\/\" rel=\"noreferrer\">Kubernetes<\/a>, <a href=\"https:\/\/devopscube.com\/setup-ecs-cluster-as-build-slave-jenkins\/\" rel=\"noreferrer\">ECS<\/a>, or whatever your team uses).<\/li>\n<\/ul>\n<p>Now, the built image is running in dev. The Dev team uses this deployment to test their changes for the latest code. <\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\"><b><strong style=\"white-space: pre-wrap;\">Important Note:<\/strong><\/b> Developers keep pushing changes through PR&#8217;s and the pipeline builds new images deploys it to to dev environment until a version is finalized for production. <\/p>\n<p>Once the code is stable and ready for deployment, the specific image version gets promoted to the stage environment.<\/p><\/div>\n<\/div>\n<h3 id=\"promote-docker-image-from-dev-to-stage-registry\">Promote Docker Image from Dev to Stage Registry<\/h3>\n<p>Next, the <strong>dev team picks a specific application version\/commit<\/strong> that has been tested, reviewed, and agreed upon for production deployment. That is the image that gets promoted to the stage environment.<\/p>\n<p>For that, we need to create a&nbsp;<code>release<\/code>&nbsp;branch from the&nbsp;<strong>specific commit<\/strong>&nbsp;on&nbsp;<code>develop<\/code>&nbsp;the branch. The image that was built from that commit is the one we want to promote to stage environment.&nbsp;<\/p>\n<p>For example, lets say <code>2b2e927<\/code> is the SHA of the commit development team chose for production. The following command <strong>creates a release branch<\/strong> named <code>release\/v1.2.0-sha-2b2e927<\/code> from that exact commit <code>2b2e927<\/code>.<\/p>\n<pre><code class=\"language-bash\">$ git checkout develop\n\n$ git checkout -b release\/v1.2.0-sha-2b2e927 2b2e927<\/code><\/pre>\n<p>Here,<\/p>\n<ul>\n<li><code>release\/v1.2.0<\/code> &#8211; Branch name with semantic versioning<\/li>\n<li><code>2b2e927<\/code> &#8211; The specific commit on the develop branch<\/li>\n<\/ul>\n<p>This way, anyone looking at the branch instantly knows which image version it is promoting.<\/p>\n<div class=\"kg-card kg-callout-card kg-callout-card-blue\">\n<div class=\"kg-callout-emoji\">\ud83d\udca1<\/div>\n<div class=\"kg-callout-text\">The branch naming convention depends on your team. Common patterns include&nbsp;<code spellcheck=\"false\" style=\"white-space: pre-wrap;\">release\/sprint-42<\/code>,&nbsp;<code spellcheck=\"false\" style=\"white-space: pre-wrap;\">release\/2025-03-10<\/code>, or&nbsp;<code spellcheck=\"false\" style=\"white-space: pre-wrap;\">release\/v1.2.0<\/code> if you follow semantic versioning. You can pick whatever works for your project.<\/div>\n<\/div>\n<p>Here is the important part.<\/p>\n<p>The stage promotion workflow is manual. We need to pass the Docker image tagged with SHA in the pipeline as input. <\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion04.png\" class=\"kg-image\" alt=\"image promotion workflow from dev to stage\" loading=\"lazy\" width=\"2000\" height=\"1214\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/build-promotion04.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/build-promotion04.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/build-promotion04.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion04.png 2284w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Here is what happens in this promotion pipeline.<\/p>\n<ul>\n<li>It runs a&nbsp;<strong>Trivy vulnerability scan<\/strong>&nbsp;on the dev image to catch issues before staging.<\/li>\n<li>Once it passes the vulnerability scan, it uses&nbsp;<strong>Crane&nbsp;to copy (promote) <\/strong>the image from the dev registry to the stage registry.<\/li>\n<li>Finally, the image gets deployed to the&nbsp;stage environment&nbsp;for <strong>integration and QA testing.<\/strong><\/li>\n<\/ul>\n<h3 id=\"promote-docker-image-to-production-with-cosign-signing\">Promote Docker Image to Production with Cosign Signing<\/h3>\n<p>This is the final gate in the pipeline. After stage environment tests pass, we push the changes to the&nbsp;<code>main<\/code>&nbsp;branch and manually trigger the production promotion.<\/p>\n<figure class=\"kg-card kg-image-card\"><img decoding=\"async\" src=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/2026\/03\/build-promotion05.png\" class=\"kg-image\" alt=\"image promotion from stage to prod\" loading=\"lazy\" width=\"2000\" height=\"1146\" srcset=\"https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w600\/2026\/03\/build-promotion05.png 600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1000\/2026\/03\/build-promotion05.png 1000w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w1600\/2026\/03\/build-promotion05.png 1600w, https:\/\/storage.ghost.io\/c\/5f\/2f\/5f2f4d20-2abf-4534-8d40-7aa233aedd43\/content\/images\/size\/w2400\/2026\/03\/build-promotion05.png 2400w\" sizes=\"auto, (min-width: 720px) 720px\"><\/figure>\n<p>Now, you might ask, <em>don&#8217;t we need to rebuild the image from&nbsp;<code>main<\/code>?<\/em><\/p>\n<p>No. The whole point of the promotion model is that&nbsp;<strong>we should never rebuild between environments<\/strong>. The exact same image (same SHA digest) that was tested in stage gets copied to the prod registry via Crane. <\/p>\n<p>Why? Because even with the same source code, an image rebuild can pull different base image layers, get updated code dependencies, etc. So the code merge from release to main is <strong>just for keeping the Git history in sync.<\/strong><\/p>\n<p>Here is what this pipeline does.<\/p>\n<ol>\n<li>It runs one last&nbsp;Trivy vulnerability scan&nbsp;on the staged image. This is called&nbsp;<strong>admission-time scanning<\/strong>. It is sort of a safety net to catch any new vulnerabilities that appeared between staging and now.<\/li>\n<li>It then uses&nbsp;<strong>Crane<\/strong>&nbsp;to copy the image from the stage registry to the production registry.<\/li>\n<li>Finally, the image is digitally signed using&nbsp;<strong>Cosign.<\/strong> This way, during deployment, we can <strong>verify if it was created by our image pipeline<\/strong> and has not been modified after it was pushed to the registry.<\/li>\n<li>The signed image is deployed to the&nbsp;<strong>production environment<\/strong>.<\/li>\n<\/ol>\n<h2 id=\"conclusion\">Conclusion<\/h2>\n<p>In this guide, we have gone through sections to learn how a <a href=\"https:\/\/devopscube.com\/docker-tutorial-getting-started-with-docker-swarm\/\" rel=\"noreferrer\">Docker<\/a> image is built and promoted to different environments.<\/p>\n<p>A key thing to understand it the process would differ based on the project requirements. <\/p>\n<p>What I have explained in this guide is to, build once and deploy everywhere. However, I have personally worked on projects where the image gets built again from the release branch when the production commit is decided. So overall, it depends on the project and team.<\/p>\n<p>In the next guide, we will walk through a complete hands-on guide on a production-grade Docker image build pipeline using <a href=\"https:\/\/devopscube.com\/github-actions-self-hosted-runner\/\" rel=\"noreferrer\">GitHub Actions<\/a>.<\/p>\n<hr>\n<p><strong>Ngu\u1ed3n:<\/strong> <a href=\"https:\/\/devopscube.com\/docker-image-build-and-promotion-pipeline\/\" target=\"_blank\" rel=\"noopener noreferrer\">Docker Image Build and Promotion Pipeline: A Production Guide \u2014 DevOpsCube<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Source: https:\/\/devopscube.com\/docker-image-build-and-promotion-pipeline\/<\/p>\n","protected":false},"author":1,"featured_media":320,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-319","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops"],"_links":{"self":[{"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/posts\/319","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=319"}],"version-history":[{"count":0,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/posts\/319\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=\/wp\/v2\/media\/320"}],"wp:attachment":[{"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=319"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=319"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/blog.ngocha.biz\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=319"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}