Ship It (Go Edition)

Containerize your app and publish to GHCR with GitHub Actions

This guide adds a Dockerfile that bakes in the Git SHA, and a GitHub Actions workflow that builds and pushes to GitHub Container Registry (GHCR). Tags drive versions. The image carries the commit SHA in both runtime output and OCI labels.


What you'll get

  • A multi-stage Dockerfile for Go, tiny final image, SHA baked in
    via -ldflags, and OCI labels for traceability.
  • A GitHub Action that:
    • Builds on main and on tags like v1.2.3.
    • Pushes to GHCR using GITHUB_TOKEN.
    • Tags images as v1.2.3 + latest on releases, or
      main-<shortsha> on branch builds.

Minimal Go service

Create a go service that will return the build infocmd/app/main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
)

var (
    version   = "dev"    // set via -ldflags
    commitSHA = "unknown"// set via -ldflags
    buildDate = "unknown"// set via -ldflags
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        _ = json.NewEncoder(w).Encode(map[string]string{
            "ok":        "true",
            "version":   version,
            "commit":    commitSHA,
            "buildDate": buildDate,
        })
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    log.Printf("listening on %s (commit %s)", port, commitSHA)
    log.Fatal(http.ListenAndServe(":"+port, mux))
}

Add a go.mod

module github.com/OWNER/REPO

go 1.22

and a .dockerignore file

.git
.github
**/tmp
**/*.test

Dockerfile (multi-stage, SHA baked in)

Create the following Dockerfile

# syntax=docker/dockerfile:1.6

########################################
# Builder
########################################
FROM golang:1.22-alpine AS builder
WORKDIR /src

# Build args for metadata
ARG GIT_SHA=unknown
ARG VERSION=dev
ARG BUILD_DATE=unknown

RUN apk add --no-cache ca-certificates upx

# Pre-cache modules
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod     go mod download

# Copy source
COPY . .

# Build static binary with build metadata
RUN --mount=type=cache,target=/go/pkg/mod     CGO_ENABLED=0 GOOS=linux GOARCH=amd64     go build -trimpath -ldflags "-s -w       -X 'main.version=${VERSION}'       -X 'main.commitSHA=${GIT_SHA}'       -X 'main.buildDate=${BUILD_DATE}'"     -o /out/app ./cmd/app

# Optional: compress for tiny binaries
RUN upx -q /out/app || true

########################################
# Runtime
########################################
FROM gcr.io/distroless/static:nonroot

# OCI labels for traceability
ARG GIT_SHA=unknown
ARG VERSION=dev
ARG BUILD_DATE=unknown

LABEL org.opencontainers.image.title="everyday-devops-go-app"       org.opencontainers.image.revision="${GIT_SHA}"       org.opencontainers.image.version="${VERSION}"       org.opencontainers.image.created="${BUILD_DATE}"

WORKDIR /app
COPY --from=builder /out/app /app/app

USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/app"]

GitHub Actions workflow

Now create a github action to run this build each time there is a push to main or a new tag.github/workflows/docker.yaml

name: Build and Publish Docker image

on:
  push:
    branches: [ "main" ]
    tags: [ "v*" ]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository_owner }}/everyday-devops-go-app

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Compute metadata
        id: meta
        run: |
          SHORT_SHA=$(git rev-parse --short HEAD)
          echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
          echo "image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_OUTPUT

          DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
          echo "build_date=${DATE}" >> $GITHUB_OUTPUT

          if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
            echo "version=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT
            echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF_NAME},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT
          else
            BRANCH="${GITHUB_REF_NAME//\//-}"
            echo "version=${BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT
            echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH}-${SHORT_SHA}" >> $GITHUB_OUTPUT
          fi

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          build-args: |
            GIT_SHA=${{ github.sha }}
            VERSION=${{ steps.meta.outputs.version }}
            BUILD_DATE=${{ steps.meta.outputs.build_date }}
          labels: |
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.version=${{ steps.meta.outputs.version }}
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.created=${{ steps.meta.outputs.build_date }}

      - name: Show published tags
        run: echo "Pushed: ${{ steps.meta.outputs.tags }}"

Repo layout

This gives us the following layout.

.
├── .dockerignore
├── .github
│   └── workflows
│       └── docker.yaml
├── .gitignore
├── Dockerfile
├── cmd
│   └── app
│       └── main.go
└── go.mod

Minimal Go service

Run git init and commit the code.

git init
git add .
git commit -m "first checkin"

Local build

export GIT_SHA=$(git rev-parse HEAD)
export VERSION=dev
export BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
docker build --build-arg GIT_SHA=$GIT_SHA --build-arg VERSION=$VERSION \
  --build-arg BUILD_DATE=$BUILD_DATE \
  -t ghcr.io/OWNER/ghcr-go-example:dev .

Look at the metadata for the built

docker inspect ghcr.io/OWNER/ghcr-go-example:dev \
--format '{{ index .Config.Labels "org.opencontainers.image.revision" }}'

If you want to push this to ghcr.io you'll need to create a PAT with write:packages and read:packages permissions.

docker login ghcr.io -u USERNAME

Use your github username and the token as the password.


Running

Now run it and check with curl

docker run -p 8080:8080 ghcr.io/OWNER/ghcr-go-example:dev
curl localhost:8080

CI Build

First setup access to the github actions in the Github UI from: https://github.com/USERNAME/ghcr-go-example/pkgs/container/ghcr-go-example

Make sure you add your repository and make the access Write.

Now push the code and go to the Actions tab to see it complete.

git push

Now create a new tag and see that is created as well.

git tag v1.2.3
git push --tags

Conclusion

By adding a multi-stage Dockerfile and an automated GitHub Actions workflow, you’ve made your Go application build, ship, and traceable, without manual intervention. Every push or tag creates a reproducible container image in GHCR, tagged with meaningful versions and stamped with the commit SHA.

This setup makes deployments faster and safer:

  • Faster, because builds run automatically with consistent steps.
  • Safer, because you can roll back to any tagged image knowing exactly which commit it came from.

In everyday DevOps practice, these small, repeatable patterns are what keep teams shipping with confidence. You’ve now got a foundation that works for a weekend hobby project or a production-grade service, just keep tagging, and keep shipping.