Docker From PR to Production

Docker From PR to Production

Tracking a Docker Image Journey

Docker From PR to Production

In a [recent article]({{ < ref “post/2025/04/trivy-codespaces-ci/index.md” > }}), I wrote about creating a CodeSpaces container, using a Trivy-powered process to assess security.

At the end, I mentioned flaws - I was building an image in the PR, running the checks, and then in the deployment and tagging workflows, I was building the image again.

Sure, it’s the same source. It should be the same.

“Should” is my favourite word in IT.

Can we do better? Why not take the same image we built in the PR, and promote that to Docker Hub?

Further, when we’re ready to release, let’s take the appropriate SHA, and make sure that’s the version that gets tagged on GitHub.


PR Builds: One Image, tracked from the Start

Every pull request triggers a GitHub Actions workflow that:

  • Builds the image, tagging it with both the commit SHA and the PR number.
  • Runs a Trivy scan, posting comments on the PR (which I wrote about in my last article).
  • Pushes to GHCR, so it’s available for later workflows.
  • Cleans up old images: anything SHA-tagged from previous builds of the same PR gets removed.

It’s similar to the workflow I wrote about in my [last article]({{ < ref “post/2025/04/trivy-codespaces-ci/index.md” > }}), and you can see it here:

az-pwsh-terraform/.github/workflows/docker-pr-build-and-scan.yml


Promotion: Deploy Means Promote, Not Rebuild

Once the PR is merged onto main, the deploy workflow steps in.

Here’s the flow:

  • The workflow uses the PR number associated with the merge commit to find and pull the PR-tagged image from GHCR.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ──────────────────────────────────────────────────────────────
# discover the PR that introduced the merge commit
# ──────────────────────────────────────────────────────────────
- name: Get merged-PR number and head SHA
  id: pr
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: |
    pr_json=$(gh api repos/${{ github.repository }}/commits/${{ github.sha }}/pulls --jq '.[0]')
    pr_number=$(echo "$pr_json" | jq -r '.number')
    echo "PR_NUMBER=$pr_number" >> $GITHUB_ENV
    echo "REPO_NAME=${GITHUB_REPOSITORY##*/}" >> $GITHUB_ENV

# ──────────────────────────────────────────────────────────────
# pull image built in PR job, retag as :latest
# ──────────────────────────────────────────────────────────────
- name: Pull image from GHCR (by PR number)
  run: docker pull ghcr.io/${{ github.repository }}:pr-${{ env.PR_NUMBER }}

- name: Tag image as :latest for Docker Hub
  run: |
    docker tag ghcr.io/${{ github.repository }}:pr-${{ env.PR_NUMBER }} \
                  ${{ secrets.DOCKER_USERNAME }}/${{ env.REPO_NAME }}:latest
  • We scan it again with Trivy and upload results to GitHub’s code scanning UI.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# ──────────────────────────────────────────────────────────────
# run Trivy again to upload SARIF to GitHub Code Scanning
# ──────────────────────────────────────────────────────────────
- name: Scan Docker image with Trivy
  uses: aquasecurity/[email protected]
  with:
    image-ref: '${{ secrets.DOCKER_USERNAME }}/${{ env.REPO_NAME }}:latest'
    format: sarif
    output: results.sarif
    severity: CRITICAL,HIGH

- name: Upload SARIF file
  uses: github/codeql-action/upload-sarif@v3
  if: success() || failure()
  with:
    category: container-security
    sarif_file: results.sarif
  • It retags it as latest and pushes to Docker Hub.

  • Then we clean up the SHA-tagged version, keeping the staging (GHCR) registry lean.

Crucially, you’ll note that we do not rebuild the image. Due to how GitHub Actions handles PR builds, the commit SHA during the PR isn’t guaranteed to match the final merge commit — that’s why we associate images via PR number instead.

Link to workflow: /.github/workflows/docker-deploy.yml


Release Tags: Same Image, Just Versioned

Tagging releases provides a clear signal that an image is production ready. Combined with healthy use of semantic versioning, it makes it safer for consumers than always pulling ’latest'.

When we push a release tag (e.g., v1.2.3):

  • The same SHA-tagged image is pulled again.
  • It gets version-tagged and re-pushed to Docker Hub.
  • And once again, we clean up the SHA version.

At no point does anything get rebuilt after the PR.

Workflow: /.github/workflows/docker-tag-release.yml


Why This Matters

In CI/CD, trust is earned through consistency. Building once, promoting with confidence — that’s how ‘should’ becomes ‘is’.

By building the image once in the PR and reusing it across every stage, we ensure that what gets reviewed is exactly what gets deployed.

From the moment a PR is opened to the moment a new version is released, the same Docker image is carried through every stage, ensuring consistency and reliability.


Want to check it out?

If you’re curious about the details, you can check out all the workflows on GitHub.

The pre-built container is also available on Docker Hub.

The scripts that do the tidy up were a bit curly, so I made separate actions for them.

How do you manage Docker images in your CI/CD pipelines?

I’ve focused here on promoting a single build for maximum traceability — but I’m always interested to hear other approaches. Let me know your thoughts in the comments!

This post is licensed under CC BY 4.0 by the author.