52.13 GitHub Actions: build and push images to GHCR
Friends, building on your laptop is fine for learning, but real teams build images automatically on every push. Today we write a GitHub Actions workflow, step by step, that builds our image, tags it properly and pushes it to the GitHub Container Registry, ghcr.io.
मित्रांनो, laptop वर build करणं शिकण्यासाठी ठीक आहे, पण खऱ्या teams प्रत्येक push वर image आपोआप build करतात. आज आपण एक GitHub Actions workflow टप्प्याटप्प्याने लिहू, जो image build करतो, योग्य tag देतो आणि GitHub Container Registry, ghcr.io वर push करतो.
दोस्तों, laptop पर build करना सीखने के लिए ठीक है, पर असली teams हर push पर image अपने-आप build करती हैं. आज हम एक GitHub Actions workflow कदम-दर-कदम लिखेंगे, जो image build करता है, सही tag देता है और GitHub Container Registry, ghcr.io पर push करता है.
1. Start from a story
At Zerodha, Meera's team pushed images by hand. Someone would build on a laptop, tag it latest and push. Twice a laptop image with debug settings reached staging.
Meera decided no human would push images any more. Every merge to main would build in CI, with the same Dockerfile, the same cache and a tag equal to the commit.
She used GitHub Actions because the code already lived there, and GHCR because the workflow can log in with its own short-lived token, so no password is stored anywhere.
Within a week every image in staging could be traced back to one commit. Today we build the same pipeline.
2. The first workflow
Step 1: Workflow file location
Why: GitHub only reads workflows from one folder.
What: Workflows are YAML files in .github/workflows. The on key says when they run.
Example 1: Create file
Type this:
mkdir -p .github/workflows && printf 'name: docker\non:\n push:\n branches: [main]\n pull_request:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n' > .github/workflows/docker.yml
You will see:
(no output)
What it means: A minimal workflow.
Example 2: Check
Type this:
ls .github/workflows
You will see:
docker.yml
What it means: File is in place.
Example 3: Push
Type this:
git add .github && git commit -qm 'ci: docker' && git push -q && gh run list --limit 1
You will see:
in_progress ci: docker docker main push
What it means: The run started.
Step 2: Permissions
Why: The job token must be allowed to write packages.
What: Add permissions: contents read, packages write. Nothing more.
Example 4: Add
Type this:
mkdir -p .github/workflows && printf 'name: docker\non:\n push:\n branches: [main]\npermissions:\n contents: read\n packages: write\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n' > .github/workflows/docker.yml
You will see:
(no output)
What it means: Least privilege.
Example 5: Check
Type this:
grep -A2 permissions .github/workflows/docker.yml
You will see:
permissions:
contents: read
packages: write
What it means: Exactly two rights.
Example 6: Without it
Type this:
gh run view --log-failed | tail -1
You will see:
denied: installation not allowed to Write organization package
What it means: This is the error when packages write is missing.
Step 3: Set up buildx
Why: The official actions give you BuildKit and QEMU.
What: docker/setup-qemu-action for other CPU types, docker/setup-buildx-action for the builder.
Example 7: Steps
Type this:
printf ' - uses: docker/setup-qemu-action@v3\n - uses: docker/setup-buildx-action@v3\n' >> .github/workflows/docker.yml && tail -2 .github/workflows/docker.yml
You will see:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
What it means: Two steps added.
Example 8: Run log
Type this:
gh run view --log | grep 'Buildx version'
You will see:
Buildx version: v0.17.1
What it means: Builder is ready.
Example 9: Pin versions
Type this:
grep -c '@v' .github/workflows/docker.yml
You will see:
3
What it means: Always pin action versions.
Step 4: Log in to GHCR
Why: Push needs a login.
What: docker/login-action with registry ghcr.io, username github.actor and password secrets.GITHUB_TOKEN.
Example 10: Step
Type this:
printf ' - uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n' >> .github/workflows/docker.yml && grep -c login-action .github/workflows/docker.yml
You will see:
1
What it means: Login step added.
Example 11: Run log
Type this:
gh run view --log | grep 'Login Succeeded'
You will see:
Login Succeeded!
What it means: Token worked.
Example 12: Why no secret
Type this:
printf 'GITHUB_TOKEN is created per run and expires when the job ends.\n'
You will see:
GITHUB_TOKEN is created per run and expires when the job ends.
What it means: Nothing long-lived to leak.
3. Build, tag and push
Step 5: Build and push
Why: One action does build and push.
What: docker/build-push-action with context, push and tags.
Example 13: Step
Type this:
printf ' - uses: docker/build-push-action@v6\n with:\n context: .\n push: true\n tags: ghcr.io/${{ github.repository }}:${{ github.sha }}\n' >> .github/workflows/docker.yml && tail -1 .github/workflows/docker.yml
You will see:
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
What it means: Tag is the commit.
Example 14: Run log
Type this:
gh run view --log | grep 'pushing manifest'
You will see:
pushing manifest for ghcr.io/zerodha-demo/shop:3f9c2e1... done
What it means: Pushed.
Example 15: Pull it
Type this:
docker pull ghcr.io/zerodha-demo/shop:3f9c2e1a7b
You will see:
Status: Downloaded newer image for ghcr.io/zerodha-demo/shop:3f9c2e1a7b
What it means: Anyone with access can pull.
Step 6: Smart tags with metadata-action
Why: Hand-written tags get messy.
What: docker/metadata-action makes tags from branch, version tag and commit, plus OCI labels.
Example 16: Step
Type this:
printf ' - id: meta\n uses: docker/metadata-action@v5\n with:\n images: ghcr.io/${{ github.repository }}\n tags: |\n type=ref,event=branch\n type=semver,pattern={{version}}\n type=sha\n' > meta.yml && grep type= meta.yml
You will see:
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
What it means: Three rules.
Example 17: Use outputs
Type this:
printf ' tags: ${{ steps.meta.outputs.tags }}\n labels: ${{ steps.meta.outputs.labels }}\n' > use.yml && cat use.yml
You will see:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
What it means: Pass them to build-push.
Example 18: Tags on v1.4.0
Type this:
gh run view --log | grep 'ghcr.io' | head -3
You will see:
ghcr.io/zerodha-demo/shop:1.4.0
ghcr.io/zerodha-demo/shop:sha-3f9c2e1
ghcr.io/zerodha-demo/shop:main
What it means: Version, commit and branch tags.
Step 7: Build on PR, push only on main
Why: Pull requests from forks must never push.
What: Set push to an expression that is true only for non-PR events.
Example 19: Expression
Type this:
printf ' push: ${{ github.event_name != "pull_request" }}\n' > push.yml && cat push.yml
You will see:
push: ${{ github.event_name != "pull_request" }}
What it means: PRs only build.
Example 20: PR run
Type this:
gh run view --log | grep -i 'push'
You will see:
push: false
What it means: Tested but not pushed.
Example 21: Main run
Type this:
gh run view --log | grep -i 'push:'
You will see:
push: true
What it means: Main pushes.
Step 8: Cache in Actions
Why: Runners start empty every time.
What: cache-from and cache-to type=gha store BuildKit cache in the Actions cache.
Example 22: Add
Type this:
printf ' cache-from: type=gha\n cache-to: type=gha,mode=max\n' > cache.yml && cat cache.yml
You will see:
cache-from: type=gha
cache-to: type=gha,mode=max
What it means: Two lines.
Example 23: First run
Type this:
gh run view --log | grep 'RUN npm ci'
You will see:
#9 [4/6] RUN npm ci 51.2s
What it means: Cold.
Example 24: Second run
Type this:
gh run view --log | grep 'RUN npm ci'
You will see:
#9 CACHED [4/6] RUN npm ci
What it means: Warm, seconds instead of a minute.
4. Make it production ready
Step 9: Multi-arch images
Why: Graviton and Apple machines need arm64.
What: platforms: linux/amd64,linux/arm64 with QEMU builds both into one tag.
Example 25: Add
Type this:
printf ' platforms: linux/amd64,linux/arm64\n' > plat.yml && cat plat.yml
You will see:
platforms: linux/amd64,linux/arm64
What it means: Two platforms.
Example 26: Inspect
Type this:
docker buildx imagetools inspect ghcr.io/zerodha-demo/shop:main | grep Platform
You will see:
Platform: linux/amd64
Platform: linux/arm64
What it means: One tag, two images.
Example 27: Run on Mac
Type this:
docker run --rm ghcr.io/zerodha-demo/shop:main uname -m
You will see:
aarch64
What it means: The right one is picked.
Step 10: SBOM and provenance
Why: Auditors ask what is inside and who built it.
What: sbom: true and provenance: mode=max attach attestations to the image.
Example 28: Add
Type this:
printf ' sbom: true\n provenance: mode=max\n' > att.yml && cat att.yml
You will see:
sbom: true
provenance: mode=max
What it means: Two lines.
Example 29: Read SBOM
Type this:
docker buildx imagetools inspect ghcr.io/zerodha-demo/shop:main --format '{{json .SBOM}}' | head -c 60
You will see:
{"linux/amd64":{"SPDX":{"SPDXID":"SPDXRef-DOCUMENT"
What it means: SPDX SBOM is there.
Example 30: Provenance
Type this:
docker buildx imagetools inspect ghcr.io/zerodha-demo/shop:main --format '{{json .Provenance}}' | grep -o 'builder[^,]*' | head -1
You will see:
builder":{"id":"https://ci.example.com/run/123"
What it means: Shows the build run.
Step 11: Scan before push
Why: Do not ship known critical bugs.
What: Build with load true, scan with Trivy, then push only if it passes.
Example 31: Scan step
Type this:
printf ' - uses: aquasecurity/trivy-action@0.28.0\n with:\n image-ref: shop:test\n severity: CRITICAL\n exit-code: 1\n' > scan.yml && grep exit-code scan.yml
You will see:
exit-code: 1
What it means: Fails the job on critical bugs.
Example 32: Fail log
Type this:
gh run view --log-failed | tail -2
You will see:
Total: 1 (CRITICAL: 1)
Error: Process completed with exit code 1.
What it means: Push step never ran.
Example 33: Fixed
Type this:
gh run list --limit 1
You will see:
completed success fix: bump base image docker main push
What it means: Green again.
Step 12: Package visibility and cleanup
Why: New GHCR packages are private by default.
What: Link the package to the repo, set visibility, and delete old untagged versions.
Example 34: Link label
Type this:
printf 'LABEL org.opencontainers.image.source=https://git.example.com/zerodha-demo/shop\n' >> Dockerfile && tail -1 Dockerfile
You will see:
LABEL org.opencontainers.image.source=https://git.example.com/zerodha-demo/shop
What it means: metadata-action adds this label automatically from the repository.
Example 35: List versions
Type this:
gh api /orgs/zerodha-demo/packages/container/shop/versions --jq 'length'
You will see:
214
What it means: Many old versions.
Example 36: Cleanup step
Type this:
printf ' - uses: actions/delete-package-versions@v5\n with:\n package-name: shop\n package-type: container\n delete-only-untagged-versions: true\n min-versions-to-keep: 20\n' > clean.yml && grep min-versions clean.yml
You will see:
min-versions-to-keep: 20
What it means: Keeps the registry tidy.
5. Common mistakes and how Meera avoids them
Mistake one: storing a personal access token as a secret. Meera uses GITHUB_TOKEN with packages write.
Mistake two: pushing from pull requests. Meera sets push only for non-PR events.
Mistake three: tagging only latest. Meera always adds a commit tag and a version tag.
Mistake four: no cache, so every run is cold. Meera uses type=gha.
Mistake five: unpinned actions. Meera pins every action to a version.
Check yourself
Q1. Where do workflow files live?
.github/workflows.
Q2. Which permission is needed to push to GHCR?
packages: write.
Q3. How do you stop PRs from pushing?
push only when event is not pull_request.
Q4. Which action makes tags from branch and version?
docker/metadata-action.
Friends, checkout, buildx, login, metadata, build and push. Six steps and no human pushes images again. Did you understand? Let us try it once more. Add the workflow to a practice repository and push one commit.
मित्रांनो, checkout, buildx, login, metadata, build आणि push. सहा steps आणि कोणीही हाताने image push करत नाही. समजलं का? चला, पुन्हा एकदा करून पाहू. सरावाच्या repository मध्ये workflow जोडा आणि एक commit push करा.
दोस्तों, checkout, buildx, login, metadata, build और push. छह steps और कोई हाथ से image push नहीं करता. समझ आया? चलिए, एक बार फिर करके देखते हैं। अभ्यास की repository में workflow जोड़िए और एक commit push कीजिए.
Ravindra Bagale's Tip
Give every image at least one tag that never moves, such as the commit SHA. Deploy that tag, not latest.
प्रत्येक image ला किमान एक कधीही न बदलणारा tag द्या, जसा commit SHA. तोच tag deploy करा, latest नाही.
हर image को कम से कम एक कभी न बदलने वाला tag दीजिए, जैसे commit SHA. वही tag deploy कीजिए, latest नहीं.
Lab: try at home
- Create the workflow file.
- Add permissions and login.
- Build and push with a SHA tag.
- Add metadata-action tags.
- Add gha cache and compare times.
- Add a Trivy scan that blocks criticals.
Recap
- Workflows live in .github/workflows.
- packages write plus GITHUB_TOKEN logs in to GHCR.
- build-push-action builds and pushes.
- metadata-action makes good tags.
- PRs build, main pushes.
- Cache, multi-arch, SBOM and scans make it production ready.
Lab
- Write a Dockerfile using every instruction in this chapter and build it.
- Break the cache on purpose and watch which steps re-run.
- Convert a single-stage Go or Java build to multi-stage and compare sizes.
- Pass a fake token with --secret and prove it is not in docker history.
Practice task
Take any app you know, write a production Dockerfile with a pinned base, non-root USER, HEALTHCHECK, .dockerignore and multi-stage build, then show docker history has no secret and the size dropped.
Ravindra Bagale's Tip
Interviewers ask about CMD vs ENTRYPOINT almost every time. ENTRYPOINT is the program, CMD is its default arguments. Remember that.
CMD आणि ENTRYPOINT मधला फरक interview मध्ये 100% विचारतात. ENTRYPOINT program, CMD त्याचे default arguments, हे लक्षात ठेवा.
CMD और ENTRYPOINT का फ़र्क interview में लगभग हर बार पूछते हैं. ENTRYPOINT program है, CMD उसके default arguments, यह याद रखिए.
In short
- Pin a slim base.
- Chain RUN steps and clean in the same layer.
- ENTRYPOINT is the program; CMD is default arguments.
- Prefer COPY; ADD only for tar or checksummed URLs.
- Never put secrets in ARG or ENV; use --secret.
- Order steps for cache and use .dockerignore.
- Multi-stage keeps tools out of the final image.
Revision card
Interview card