Capstone: Build, Test, Publish, and Deploy a Docker App

This capstone pulls together everything from earlier lessons into a single, production-shaped pipeline. Instead of separate examples for testing, image building, and deployment, you will assemble one workflow that takes a code change from a pull request all the way to a running container in a protected production environment—with the safety rails a real team would require: minimal token permissions, immutable image references, an approval gate, a health check, and a rollback path.

Overview / How it works

A production Docker pipeline is really three pipelines chained together, each with a different trust level:

  • Continuous integration (CI) — every push and pull request runs lint and test jobs. This stage never touches secrets or a registry, so it is safe to run even on pull requests from forks.
  • Publish — once code lands on the trunk branch, a build job produces a container image and pushes it to a registry as an immutable, digest-addressed artifact. This stage requires a privileged token (packages: write), so it must never run against untrusted fork code.
  • Deploy — a separate job promotes the exact digest produced by the publish stage into a protected environment. GitHub can require a human reviewer before this job runs, and the job itself runs a post-deploy health check with an automatic rollback path if the check fails.

This distinction matters for terminology too. The pipeline below implements continuous delivery: every change that passes tests produces a release-ready image, but a human approves the environment before it goes live. Remove the required reviewer rule from the production environment and the same workflow becomes continuous deployment—every green build on the trunk branch ships automatically. Continuous integration alone, by contrast, only guarantees the code is merge-ready; it says nothing about what happens after merge.

Syntax or workflow structure

The workflow uses a linear job graph connected with needs, so each stage only starts after the previous one succeeds: testbuild-and-pushdeploy, with a rollback job that only runs if: failure(). A concurrency group keyed on the branch ref prevents two deploys from racing each other if two commits land close together.

Permissions are set at the workflow level to the most restrictive value (contents: read) and then raised only inside the specific job that needs more, for only as long as that job runs:

Job Runs on Permissions Why
test every push and pull_request, including forks contents: read Only needs to check out code and run test tooling
build-and-push push to main only contents: read, packages: write Publishes an image; must never run on untrusted fork PRs
deploy after build-and-push, gated by environment review contents: read Deploy credentials come from environment secrets, not the workflow token

Examples

Example 1: CI stage only

Start with the part that is safe to run unconditionally, including on pull requests from forks, because it never touches a registry or a deployment secret.

name: CI

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run lint
      - run: npm test

Expected behavior: every push and every pull request—including one opened from a fork—triggers this job. It installs dependencies, lints, and runs the test suite. Nothing is built or published, and the token has read-only access, so there is no privileged credential for a malicious PR to abuse.

Example 2: add the publish stage

Once the trunk branch is protected by the CI job, add a second job that builds the Docker image and pushes it to GitHub Container Registry (GHCR), but restrict it to direct pushes on main so pull requests—trusted or not—never trigger a publish.

  build-and-push:
    name: Build and push image
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Expected behavior: on a pull request, only test runs. After a merge to main, test runs first, and only if it passes does build-and-push run, tagging the image with the commit SHA and exposing the registry-assigned image_digest as a job output for the next stage. Tagging by SHA (not latest) means every build produces a uniquely addressable artifact.

Example 3: add deploy and rollback

The final stage promotes the published digest—not a mutable tag—to a protected environment, verifies it is healthy, and defines an automatic rollback if it is not.

  deploy:
    name: Deploy to production
    needs: build-and-push
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    permissions:
      contents: read
    steps:
      - name: Deploy image by digest
        run: |
          echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.image_digest }}"
          # Replace with your real deploy command: ssh to the host and update
          # a compose file, or call your orchestrator's deploy API.
      - name: Health check
        run: |
          for i in 1 2 3 4 5; do
            if curl -fsS https://app.example.com/health; then
              exit 0
            fi
            sleep 10
          done
          echo "Health check failed after 5 attempts" >&2
          exit 1

  rollback:
    name: Rollback production
    if: failure()
    needs: [build-and-push, deploy]
    runs-on: ubuntu-latest
    environment:
      name: production
    permissions:
      contents: read
    steps:
      - name: Redeploy last stable digest
        run: |
          echo "Rolling back to ghcr.io/${{ github.repository }}@${{ vars.LAST_STABLE_DIGEST }}"
          # Replace with your real rollback command, then update the
          # LAST_STABLE_DIGEST repository variable once this is confirmed healthy.

Expected behavior: if the production environment has required reviewers configured, the deploy job pauses until someone approves it in the Actions UI. It then deploys the specific digest—never a tag that could point somewhere else by the time this step runs—and polls /health up to five times. If every attempt fails, the job exits non-zero, which triggers the rollback job to redeploy the last digest known to be stable, read from a repository vars value (not a secret, since a digest is not sensitive).

Here is the complete file with all three stages combined:

name: Build, Test, Publish, Deploy

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

concurrency:
  group: capstone-deploy-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

jobs:
  test:
    name: Lint and test
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - run: npm ci
      - run: npm run lint
      - run: npm test

  build-and-push:
    name: Build and push image
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_digest: ${{ steps.push.outputs.digest }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/setup-buildx-action@v3
      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    name: Deploy to production
    needs: build-and-push
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://app.example.com
    permissions:
      contents: read
    steps:
      - name: Deploy image by digest
        run: |
          echo "Deploying ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.image_digest }}"
          # Replace with your real deploy command.
      - name: Health check
        run: |
          for i in 1 2 3 4 5; do
            if curl -fsS https://app.example.com/health; then
              exit 0
            fi
            sleep 10
          done
          echo "Health check failed after 5 attempts" >&2
          exit 1

  rollback:
    name: Rollback production
    if: failure()
    needs: [build-and-push, deploy]
    runs-on: ubuntu-latest
    environment:
      name: production
    permissions:
      contents: read
    steps:
      - name: Redeploy last stable digest
        run: |
          echo "Rolling back to ghcr.io/${{ github.repository }}@${{ vars.LAST_STABLE_DIGEST }}"
          # Replace with your real rollback command.

Step by step

  1. A contributor opens a pull request. The test job runs with read-only permissions; no registry credential is ever exposed to the PR, even if it comes from a fork.
  2. A maintainer merges the pull request into main. The push event re-runs test, then build-and-push, which logs in to GHCR with the automatically generated GITHUB_TOKEN, builds the image, and pushes it tagged with the commit SHA.
  3. The registry returns a content digest for the pushed image. The job exposes it as outputs.image_digest so later jobs reference the exact bytes that were tested, not a tag that could later be overwritten.
  4. The deploy job requests the production environment. If that environment has required reviewers, the run pauses until approved.
  5. Once approved, the job deploys the image by digest and polls the health endpoint with retries and backoff.
  6. If the health check passes, the run finishes green and the environment URL shown in the Actions UI reflects the live deployment.
  7. If the health check fails after all retries, the job fails, which satisfies the if: failure() condition on rollback, redeploying the last digest recorded as stable.

Common Mistakes

Mistake 1: deploying a mutable tag instead of a digest

Tagging and deploying with latest means the tag can be overwritten by a later build before or during your deploy step, so the artifact you tested is not guaranteed to be the artifact you run.

# Bad: tag is mutable and can move between build and deploy
tags: ghcr.io/${{ github.repository }}:latest

- name: Deploy
  run: docker pull ghcr.io/${{ github.repository }}:latest && docker run -d ghcr.io/${{ github.repository }}:latest

Correction: capture the registry-assigned digest as a job output when you push, and deploy that digest, never a tag.

# Good: digest is immutable and always refers to the exact tested bytes
outputs:
  image_digest: ${{ steps.push.outputs.digest }}

- name: Deploy
  run: docker pull ghcr.io/${{ github.repository }}@${{ needs.build-and-push.outputs.image_digest }}

Mistake 2: over-broad workflow permissions

Setting permissions: write-all (or leaving the default, which on many repositories is broadly permissive) gives every step in every job a token that can push packages, write deployments, and more—even the lint step that only needs to read source code. If any dependency pulled in during testing is compromised, it inherits that same powerful token.

# Bad: every job's token can write packages, deployments, and more
permissions: write-all

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

Correction: default to read-only at the workflow level, then grant only the specific scope a job needs, only inside that job.

# Good: minimal default, elevated only where required
permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - run: ./deploy.sh

Mistake 3: letting deploy start before tests finish

A deploy job with no needs: entry starts as soon as its trigger fires, running in parallel with (or even before) the test job. A failing test would not block a deployment that is already underway. Always chain jobs explicitly: needs: build-and-push, and make sure build-and-push itself declares needs: test, so a red test run stops the pipeline before an image is even built.

Best Practices

  • Pin the base image in your Dockerfile by digest (for example FROM node@sha256:...) so a tag republish upstream cannot silently change what you build.
  • Never run the publish or deploy stage on pull_request events from forks; restrict them to pushes on your trunk branch, as shown above.
  • Set permissions to the minimum at the workflow level and raise it only inside the job that needs it.
  • Deploy by digest, not by tag, so the artifact that was tested is provably the artifact that runs.
  • Use a protected environment with required reviewers for production, and a separate, less-restricted environment for staging.
  • Always run a post-deploy health check with retries, and wire a rollback path (if: failure()) to the last known-good digest.
  • Use a concurrency group on the deploy workflow so two rapid merges cannot race each other into production.
  • Store non-sensitive deployment metadata, like a last-known-good digest, in repository vars rather than secrets, reserving secrets for actual credentials.
  • Cache Docker layers (for example with type=gha) to keep the publish stage fast without weakening any of the above.

Practice Exercises

  1. Starting from Example 1, add the build-and-push job from Example 2. Open a pull request from a branch (not a fork) and confirm in the Actions log that only the test job runs.
  2. Merge that pull request and confirm build-and-push runs afterward, then find the pushed image’s digest with the inspection command below.
  3. Add the deploy and rollback jobs from Example 3. Configure a production environment with yourself as a required reviewer, and confirm the run pauses for approval.
  4. Temporarily point the health check at a path that returns a non-200 status, and confirm the rollback job fires automatically after the retries are exhausted.
docker buildx imagetools inspect ghcr.io/OWNER/REPO:TAG

Expected output shows the manifest and its content digest, which is the value you should compare against the image_digest job output:

Name:      ghcr.io/OWNER/REPO:TAG
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:6f1b2c9e4a7d... (truncated)

And a sample of what a health check retry loop looks like in the Actions log when the first two attempts fail before the service becomes ready:

curl: (7) Failed to connect to app.example.com port 443: Connection refused
curl: (7) Failed to connect to app.example.com port 443: Connection refused
HTTP/1.1 200 OK
{"status":"ok"}

Summary

A production Docker pipeline is not one job—it is three cooperating stages with different trust levels: an unprivileged test stage safe for any pull request, a privileged publish stage restricted to trunk pushes that produces an immutable digest, and a gated deploy stage that promotes exactly that digest behind a protected environment, a health check, and a rollback path. Every piece in this capstone—minimal permissions, digest pinning, trigger restriction, required reviewers, and automatic rollback—exists to answer the same question: if any one step misbehaves or any one dependency is compromised, how little damage can it actually do? Treat the workflow file here as a template: adapt the registry, health endpoint, and deploy command to your own infrastructure, and keep every credential in secrets, never in a log line.