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: test → build-and-push → deploy, 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
- A contributor opens a pull request. The
testjob runs with read-only permissions; no registry credential is ever exposed to the PR, even if it comes from a fork. - A maintainer merges the pull request into
main. The push event re-runstest, thenbuild-and-push, which logs in to GHCR with the automatically generatedGITHUB_TOKEN, builds the image, and pushes it tagged with the commit SHA. - The registry returns a content digest for the pushed image. The job exposes it as
outputs.image_digestso later jobs reference the exact bytes that were tested, not a tag that could later be overwritten. - The
deployjob requests theproductionenvironment. If that environment has required reviewers, the run pauses until approved. - Once approved, the job deploys the image by digest and polls the health endpoint with retries and backoff.
- If the health check passes, the run finishes green and the environment URL shown in the Actions UI reflects the live deployment.
- If the health check fails after all retries, the job fails, which satisfies the
if: failure()condition onrollback, 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_requestevents from forks; restrict them to pushes on your trunk branch, as shown above. - Set
permissionsto 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
environmentwith 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
concurrencygroup 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
varsrather thansecrets, 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
- Starting from Example 1, add the
build-and-pushjob from Example 2. Open a pull request from a branch (not a fork) and confirm in the Actions log that only thetestjob runs. - Merge that pull request and confirm
build-and-pushruns afterward, then find the pushed image’s digest with the inspection command below. - Add the
deployandrollbackjobs from Example 3. Configure aproductionenvironment with yourself as a required reviewer, and confirm the run pauses for approval. - Temporarily point the health check at a path that returns a non-200 status, and confirm the
rollbackjob 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.
