Phase 2 · CONTAINERS & CI/CD
GitHub Actions 3 — build & push an image to a registry
By the end of today
- Build a Docker image in CI and push it to GHCR on every commit
- Grant packages write and log in with the built-in GITHUB_TOKEN
- Tag each image with the git SHA so deploys map to exact code
Building and pushing an image to GHCR in CI
Days 23 and 24 you built Docker images by hand on your laptop; yesterday you learned jobs, matrices and secrets. Today those meet. A GitHub Actions workflow builds your image on every push and ships it to a registry, so the exact image you tested is the one anyone — a teammate, a server, tomorrow’s deploy — can pull.
A registry is a server that stores and serves images (Day 23). GitHub runs its own, GitHub Container Registry at ghcr.io, right beside your code. The workflow does four things on a runner: check out the repo, log in to the registry, build the image, push it. Three official actions wire that up — actions/checkout@v4 puts your Dockerfile on the runner, docker/login-action@v3 authenticates to ghcr.io, and docker/build-push-action@v6 runs the build and, with push: true, uploads every layer.
The credential is the quiet star: GITHUB_TOKEN. Every run gets a short-lived token minted automatically — no personal access token to create, rotate, or leak. But by default that token cannot write packages. You grant it with a permissions block: packages: write (plus contents: read). That’s least privilege — the token can push images and read code, nothing more, and it expires the moment the job ends.
Real world: Think of a factory stamping a unique serial number on every unit as it comes off the line and shipping it to a central warehouse. The serial isn’t decoration — when a store reports a fault, that number pulls the exact batch, blueprint and shift that made it. The git SHA is that serial number, the registry is the warehouse, and CI is the line that stamps and ships on every commit.
Naming and tagging: ghcr.io/owner/repo:<sha>
An image on GHCR is addressed ghcr.io/<owner>/<repo>. The GitHub context hands you ${{ github.repository }} — already owner/repo — so ghcr.io/${{ github.repository }} is your repository’s image path. The tag is the real decision. latest moves under you (Day 23): it’s whatever the pipeline pushed last. In CI you tag with the commit SHA — ${{ github.sha }} — so ghcr.io/pushkar/app:9f3c1a2… is a permanent, immutable pointer to the exact code that built it. When a deploy breaks, the running tag names the precise commit, and you can pull that image months later and get identical bytes. Push :latest too for convenience, but deploy the SHA.
GitHub Container Registry makes this concrete: it ties every image to the repository and commit that produced it and reuses the same GITHUB_TOKEN and permissions model as the rest of Actions — a public image is pullable by anyone with docker pull, a private one needs a login first, and there’s no separate registry account to manage. Build once in CI, tag by SHA, push to GHCR: that’s a real continuous-delivery step, and you wire it up below.
Hands-On Lab
Budget about 30 minutes. You’ll reuse the Flask app and Dockerfile from Day 24, now in a GitHub repo (pushkar/dockerfile-lab) with gh (the GitHub CLI) authenticated. You’ll add a workflow that builds and pushes to GHCR on every push, watch it run, then pull the exact image the pipeline built. Run IDs, SHAs, digests and timestamps are unique to each run — yours will differ from the samples.
# 1. In the repo, confirm the Dockerfile and app are present and the remote is set.
cd ~/dockerfile-lab && ls && git remote -v
# Output:
# Dockerfile app.py requirements.txt
# origin https://github.com/pushkar/dockerfile-lab.git (fetch)
# origin https://github.com/pushkar/dockerfile-lab.git (push)
Save this workflow as .github/workflows/build-push.yml. Note the permissions block and the tags list — the two lines that make the push work and traceable:
# .github/workflows/build-push.yml
name: build-push
on:
push:
branches: [main]
permissions:
contents: read
packages: write # lets GITHUB_TOKEN publish to GHCR
jobs:
build-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
# 2. Create the workflow folder, then commit and push it to trigger the run.
mkdir -p .github/workflows
# (save the YAML above to .github/workflows/build-push.yml first)
git add .github/workflows/build-push.yml
git commit -m "ci: build and push image to GHCR"
git push
# Output (your object hashes and commit SHA will differ):
# [main 9f3c1a2] ci: build and push image to GHCR
# 1 file changed, 24 insertions(+)
# To https://github.com/pushkar/dockerfile-lab.git
# a1b2c3d..9f3c1a2 main -> main
# 3. See the run the push triggered (the run ID is yours-will-differ).
gh run list --limit 1
# Output:
# STATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE
# * ci: build and push image to GHCR build-push main push 17384920516 - 8s
# 4. Follow the run live until it finishes green.
gh run watch 17384920516
# Output (job/step IDs and timing are yours-will-differ):
# ✓ build-push · 17384920516
# Triggered via push about 1 minute ago
#
# JOBS
# ✓ build-push in 58s (ID 48210937551)
# ✓ Set up job
# ✓ Run actions/checkout@v4
# ✓ Log in to GHCR
# ✓ Build and push
# ✓ Complete job
#
# ✓ Run build-push (17384920516) completed with 'success'
The workflow published fine because the in-run GITHUB_TOKEN had packages: write. Your local gh token is different: a plain gh auth login grants repo scopes but not read:packages/write:packages, so the next steps — querying the package and pulling the private image — would fail with 403/denied until you add those scopes. Refresh the token once (or use a classic PAT with read:packages,write:packages as --password-stdin in step 7 instead):
# 5. Add package scopes to your gh token so the next steps can read and pull packages.
gh auth refresh -h github.com -s read:packages,write:packages
# Output (approve the one-time code in your browser):
# ! First copy your one-time code: 1A2B-3C4D
# Press Enter to open github.com in your browser...
# ✓ Authentication complete.
# 6. Confirm both tags now exist on the package in GHCR.
gh api /user/packages/container/dockerfile-lab/versions --jq '.[0].metadata.container.tags'
# Output (the SHA tag matches the commit you pushed — yours will differ):
# ["latest","9f3c1a2b8e7d6c5f4a3b2c1d0e9f8a7b6c5d4e3f"]
# 7. Log in locally to pull the private image — reuse your gh token, no new PAT.
gh auth token | docker login ghcr.io -u pushkar --password-stdin
# Output:
# Login Succeeded
# 8. Pull the EXACT image the pipeline built, by its commit SHA.
docker pull ghcr.io/pushkar/dockerfile-lab:9f3c1a2b8e7d6c5f4a3b2c1d0e9f8a7b6c5d4e3f
# Output (the digest is content-addressed — yours will differ):
# 9f3c1a2...: Pulling from pushkar/dockerfile-lab
# Digest: sha256:… (unique to this build — never hard-code it)
# Status: Downloaded newer image for ghcr.io/pushkar/dockerfile-lab:9f3c1a2…
# 9. Run the CI-built image and prove it answers exactly like your local build did.
docker run -d -p 8000:5000 --name ci-web ghcr.io/pushkar/dockerfile-lab:9f3c1a2b8e7d6c5f4a3b2c1d0e9f8a7b6c5d4e3f
curl http://localhost:8000/
# Output:
# Hello from my first image!
# 10. Clean up the local container so the port is free.
docker rm -f ci-web
# Output:
# ci-web
Read the last steps back: you pushed a commit, a runner checked it out, logged in with a token it was handed for that run alone, built your image and pushed it to GHCR under two tags — then you pulled the image by its commit SHA and it answered identically to the one you built by hand on Day 24. That round trip, commit to registry to pull, is continuous delivery of a container.
Common Errors & Fixes
These three are the errors almost everyone hits the first time a workflow pushes to GHCR. Read the text slowly — parsing it is the actual skill.
Common error: Pushing to GHCR from a workflow that never granted the token package-write permission — the
Build and pushstep fails:ERROR: failed to push ghcr.io/pushkar/dockerfile-lab:9f3c1a2…: denied: permission_denied: write_package Error: buildx failed with: ERROR: failed to solve: ... denied: permission_denied Error: Process completed with exit code 1.Why:
GITHUB_TOKENis real and the login succeeds, but by default the token is read-only for packages. Withoutpermissions: packages: writein the workflow, GHCR correctly refuses the push — the login working is what fools people into hunting for a credential problem that isn’t there.Fix: Add a
permissions:block grantingpackages: write(keepcontents: read) at the workflow or job level, then re-push. If the repo is in an organization, also check Settings → Actions → Workflow permissions isn’t forcing read-only for the whole org.How you’d spot it in prod: A pipeline that builds fine but dies with
denied: permission_deniedonly on the push step is almost always a missing or too-narrowpermissionsblock — check that before you touch the Dockerfile or the login action.
Common error: An owner or repository name with capital letters reaching the image reference — the build fails before it even pushes:
ERROR: invalid tag "ghcr.io/Pushkar/Dockerfile-Lab:9f3c1a2…": invalid reference format: repository name must be lowercase Error: Process completed with exit code 1.Why: Docker image references must be lowercase, but
${{ github.repository }}preserves the exact case of the owner and repo. If either has an uppercase letter, the tag it produces is an illegal reference and Buildx rejects it — the workflow YAML is fine; the value it interpolated isn’t.Fix: Lowercase the reference before it becomes a tag. The common fix is a small step that sets
IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}(bash lowercasing) into$GITHUB_ENV, or usedocker/metadata-action, which lowercases for you. Then reference that variable intags:.How you’d spot it in prod: A workflow that works in one repo and fails with
repository name must be lowercasewhen copied to another means the new owner/repo has a capital letter. It’s a naming issue, not a Docker or registry outage.
Common error: The build goes green but no image ever appears in the registry, because the push was never turned on:
# docker/build-push-action step is green, but: gh api /user/packages/container/dockerfile-lab/versions # HTTP 404: Package not found (or the package simply doesn't exist)Why:
docker/build-push-actiondefaults topush: false— it builds the image on the runner but doesn’t upload it. Omit or misspellpush: trueand the step succeeds (the build worked) while nothing is ever published, so the job is green and the registry stays empty. A silent success is harder to catch than a red failure.Fix: Set
push: truein the step’swith:block, and after a run confirm the tags exist withgh api /user/packages/...or the repo’s Packages page — don’t trust the green check alone until you’ve seen the image land.How you’d spot it in prod: A “successful” release pipeline that deploys an old image, or a deploy that can’t find the new tag, often traces back to a build step that builds but never pushes. Verify the registry has the new SHA tag as part of the pipeline, not by eye.
GitHub Actions & Registry Interview Questions
These four are what a screening round asks once “can you write a workflow?” turns into “can you ship a container?” — cover each answer, say your own version out loud first, then compare, because recalling before revealing is what makes it stick. The four questions and answers render right after this note.
Go Deeper
Optional extras if you have ~30 more minutes today:
- 5 min — Open your repo’s Packages page (or run the
gh apiversions call again) and read the two tags side by side: the movinglatestand the immutable commit SHA pointing at the same image. - 10 min — Skim the docker/build-push-action README and note
cache-from/cache-towith the GitHub Actions cache — the one addition that turns a slow CI rebuild into a few seconds. - 15 min — Read the images, tags and registry sections of the Docker for DevOps guide to see how today’s push-to-GHCR step fits the wider build-and-ship workflow you carry into deployment.
How do you build a Docker image and push it to a registry from GitHub Actions? Both
Three official actions do the whole job. actions/checkout@v4 puts my Dockerfile on the runner, docker/login-action@v3 authenticates to the registry, and docker/build-push-action@v6 builds the image and, with push set to true, uploads it. For GitHub's own registry, ghcr.io, I log in with github.actor as the username and the built-in GITHUB_TOKEN as the password, and I add permissions: packages: write to the job so that token is allowed to publish. The build-push step gets a tags list — I tag with the commit SHA and often latest. That's a full continuous-delivery step: every push to main rebuilds and republishes the exact image that code produced.
What is GITHUB_TOKEN and why don't you create a personal access token to push to GHCR? Service
GITHUB_TOKEN is a short-lived credential GitHub mints automatically for every workflow run and destroys when the job ends. Because it's scoped to just that run, there's no secret to create, rotate, or leak — a personal access token, by contrast, is a long-lived password you'd have to store and manage. By default the token can read the repo but can't publish packages, so I add permissions: packages: write to grant exactly that and nothing else. That's least privilege: the token can push images to GHCR for this repository and expires minutes later. Reaching for a PAT here is a common over-permissioned mistake — the built-in token is both safer and less work.
How should you tag an image built in CI, and why tag with the git SHA instead of only latest? Product
latest is a moving pointer — it's whatever the pipeline pushed last, so two pulls a day apart can be different images. In CI I tag every build with the commit SHA, like ghcr.io/owner/app:9f3c1a2, which is an immutable pointer to the exact code that produced it. If a release misbehaves I can read the running tag and know the precise commit, and I can pull that same image months later and get identical bytes. I usually push latest as well for convenience, but I deploy the SHA. That traceability — image to commit — is what makes rollbacks and incident forensics fast instead of guesswork.
What does docker/build-push-action give you over running docker build and docker push yourself? Product
It wraps Buildx, Docker's modern builder, so out of the box you get a clean build context, a proper tags list, and one step that builds and pushes together instead of two brittle shell commands. It hands you cache-from and cache-to so a CI build can reuse layers from a previous run and finish in seconds, plus multi-platform builds — amd64 and arm64 from one workflow — and build provenance and SBOM attestations for supply-chain security. You could script raw docker build and docker push, but you'd re-implement caching and multi-arch by hand. The action is the maintained, current way teams build images in GitHub Actions.
Mark Day 34 complete
Tomorrow closes Week 5 with a review that hardens the pipeline you just built — pinned tags, least-privilege tokens, and a green run you trust.
Stuck on today’s lab? Ask in Mission 90 Q&A