Skip to content

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-test

You 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.yml

Add the following to .github/workflows/publish.yml:

.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: write in the permissions block is what actually lets GITHUB_TOKEN push 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-action sets up BuildKit, the modern build engine that docker/build-push-action relies on for caching, multi-platform builds, and build attestations — you need it even for a single-platform build like this one.
  • docker/metadata-action computes the actual tag strings for you rather than you hand-assembling them with ${{ github.sha }} in a run: step. type=sha,format=long produces a tag like sha-<full-commit-sha>; type=raw,value=latest,enable={{is_default_branch}} adds a floating latest tag, but only on pushes to the default branch — a PR build from a feature branch won’t clobber latest.
  • docker/build-push-action does the actual build and push in one step. It never shells out to a bare docker 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 master

Verifying 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-ghcr

Note

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.

Last updated on