Building and Pushing a Docker Image to GHCR
Everything deployed so far in this section has been plain files — HTML, a Python source tree. This chapter switches to a container image, which is a fundamentally different kind of artifact: instead of copying files to a destination, you’re building a self-contained, runnable unit and handing it to a registry that stores and serves it by digest. The next three chapters all revolve around this same image, so getting the build-and-push mechanics solid here pays off three times over.
The App You’re Containerizing
Deliberately boring — pure standard library, zero dependencies, one HTTP endpoint:
app.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"Hello from devstuffs CI/CD pipeline!\n")
if __name__ == "__main__":
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY app.py .
EXPOSE 8080
CMD ["python", "app.py"]Worth building and running this locally once before you touch CI at all, just to confirm it actually works before automation is in the mix:
docker image build -t devstuffs-hello-docker .
docker container run -d -p 8080:8080 --name devstuffs-hello-test devstuffs-hello-docker
curl http://localhost:8080
docker container stop devstuffs-hello-test
docker container rm devstuffs-hello-testYou should see Hello from devstuffs CI/CD pipeline! from the curl.
Where This Chapter Is Pushing To
GitHub Container Registry (GHCR) — deliberately chosen first because it needs no new secrets at all. GHCR trusts the same GITHUB_TOKEN every workflow already gets automatically, so this chapter is purely about the build/tag/push mechanics, with zero registry-authentication complexity to think about yet. That complexity shows up the moment AWS enters the picture, in the next chapter.
flowchart LR
A[Workflow triggered] --> B[Log in to GHCR<br/>with GITHUB_TOKEN]
B --> C[Build image<br/>from Dockerfile]
C --> D[Tag with git SHA]
D --> E[Push to GHCR]
Push The Workflow
Create workflow flle:
mkdir -p .github/workflows
touch .github/workflows/publish.ymlAdd the following to .github/workflows/publish.yml:
name: Build and Push Docker Image to GHCR
on:
push:
branches: [master]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository_owner }}/devstuffs-hello
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}A few pieces here are new enough to walk through individually:
packages: writein thepermissionsblock is what actually letsGITHUB_TOKENpush to GHCR — without it, the login step succeeds but the push is rejected. This is the same least-privilege pattern you’ve used for other permissions in earlier sections, just applied to a new scope.docker/setup-buildx-actionsets up BuildKit, the modern build engine thatdocker/build-push-actionrelies on for caching, multi-platform builds, and build attestations — you need it even for a single-platform build like this one.docker/metadata-actioncomputes the actual tag strings for you rather than you hand-assembling them with${{ github.sha }}in arun:step.type=sha,format=longproduces a tag likesha-<full-commit-sha>;type=raw,value=latest,enable={{is_default_branch}}adds a floatinglatesttag, but only on pushes to the default branch — a PR build from a feature branch won’t clobberlatest.docker/build-push-actiondoes the actual build and push in one step. It never shells out to a baredocker image build— it drives BuildKit directly, which is why the Buildx setup step above is a prerequisite, not decoration.
Commit and push:
git add .
git commit -m 'Publish Docker Images to GHCR
git push -u origin masterVerifying the Push
Once the workflow succeeds, the image shows up under your GitHub profile or organization’s Packages tab (https://github.com/<owner>?tab=packages), listed as devstuffs-hello. Click into it and you’ll see both tags — the sha-... one and latest — pointing at the same image digest.
Pull and run it from there to confirm it’s a real, working image and not just a registry entry:
docker image pull ghcr.io/<owner>/devstuffs-hello:latest
docker container run -d -p 8080:8080 --name devstuffs-hello-ghcr ghcr.io/<owner>/devstuffs-hello:latest
curl http://localhost:8080
docker container stop devstuffs-hello-ghcr
docker container rm devstuffs-hello-ghcrNote
By default, a package pushed by a workflow inherits your repository’s visibility settings, but GHCR packages have their own separate visibility toggle under the package’s Package settings. If docker image pull fails with an authentication error even though the workflow clearly succeeded, that toggle — not the push itself — is almost always the reason.
Cleanup
Container images in GHCR linked to a public repository don’t cost anything to store, so there’s no billing pressure to delete this one. If you want to remove it anyway — say, to keep your Packages tab tidy — go to the package’s page, Package settings, and Delete this package at the bottom. That removes every tag and every version in one action; there’s no per-tag deletion from the UI, only from the package’s version history if you want to prune older SHA tags individually while keeping the package itself.