Right, let’s get your code into a container and shoved into a registry. This is the part of CI/CD that feels like alchemy to most people, but I promise you, it’s just following a recipe with a few sharp knives. Screw this up, and your entire deployment process becomes a house of cards. Let’s build a foundation of granite instead.

The Humble Dockerfile: Your Blueprint, Not Your Scratch Pad

Your Dockerfile isn’t a suggestion; it’s the single source of truth for your image. The first thing everyone does wrong is treat it like a shell script they found in a ditch. It’s not. It’s a layered document, and each instruction has consequences.

Start with a sane, minimal base image. Using ubuntu:latest because it’s familiar is like using a sledgehammer to crack a nut. You’re dragging hundreds of megabytes of utterly useless binaries into your slim, focused microservice. For Go? Use golang:alpine as your build stage and alpine:latest as your final image. For Python? python:slim. You get the idea. Smaller images mean faster pulls, faster startups, and a smaller attack surface. It’s a trifecta of winning.

Here’s a decent multi-stage build example for a Go application. This is the way.

# Build stage: we need the full Go toolchain here
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download # Layer caching: do this before copying the code!
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /my-app ./cmd/app

# Final stage: we need nothing but the binary and a shell (and even the shell is debatable)
FROM alpine:latest
RUN apk --no-cache add ca-certificates # For HTTPS calls, if needed
WORKDIR /root/
COPY --from=builder /my-app ./
EXPOSE 8080
CMD ["./my-app"]

See what we did there? The final image is based on tiny Alpine. It doesn’t have a compiler, git, or any of the other junk you needed to build the app. It only has what you need to run it. This is Containerization 101, and I’m constantly shocked by how many “production” images fail this basic test.

Picking Your Build Engine: docker build vs. buildah vs. kaniko

You’re in a CI environment, probably on some cloud VM. You can’t just run docker build willy-nilly. Why? Because running a Docker daemon requires root, and giving your CI job root privileges is a fantastic way to get a call from security at 3 AM.

This is where smarter tools come in. My weapon of choice is kaniko. It doesn’t need a daemon. It executes each Dockerfile instruction entirely in userspace and pushes the image when it’s done. It’s purpose-built for CI. You run it as a container itself, which is almost poetically meta.

Here’s how you’d typically run it in a GitHub Actions job:

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    container:
      image: gcr.io/kaniko-project/executor:latest
      args: ["--sleep-forever"]
    steps:
    - name: Checkout code
      uses: actions/checkout@v4
    - name: Build and push with Kaniko
      env:
        REGISTRY: ghcr.io
        REPOSITORY: ${{ github.repository }}
      run: |
        /kaniko/executor \
          --context "${GITHUB_WORKSPACE}" \
          --dockerfile "${GITHUB_WORKSPACE}/Dockerfile" \
          --destination "${REGISTRY}/${REPOSITORY}:${GITHUB_SHA}" \
          --destination "${REGISTRY}/${REPOSITORY}:latest"

buildah is another excellent option, especially if you’re on a Podman kick. The point is, think beyond the classic docker build.

Tagging: The Art of Not Screwing Yourself Later

Tagging your image with latest is fine… for your main branch. It is categorically not fine for anything you might need to roll back or identify. You must tag your image with a unique identifier. The Git commit SHA is the gold standard here. It’s unique, traceable, and immutable for that commit.

In your Kubernetes deployment YAML, you will then reference this exact tag. This makes your deployment perfectly reproducible. You’re not just deploying “whatever latest was at 4:59 PM on a Friday.” You’re deploying the image built from commit a1b2c3d. This will save your sanity.

The Push: Authenticating With Your Registry

This is the part where everyone’s eyes glaze over, but it’s simple. Your CI runner needs permission to push images. For GitHub Container Registry (ghcr.io), you use a GitHub Personal Access Token (PAT). You store it as a secret in your repo (GITHUB_TOKEN is automatically available but often has read-only permissions for packages; you might need to create a custom secret).

The tool will look for authentication based on the destination image’s domain. For kaniko, it automatically uses the standard Docker config.json format. So you can create that file from a secret in your CI job. Here’s the clunky-but-it-works dance for GitHub Actions:

- name: Build and push with Kaniko
  env:
    REGISTRY: ghcr.io
    REPOSITORY: ${{ github.repository }}
    # The secret containing a PAT with `write:packages` scope
    PAT: ${{ secrets.GHCR_PUSH_TOKEN }}
  run: |
    echo "{\"auths\":{\"${REGISTRY}\":{\"auth\":\"$(echo -n username:${PAT} | base64 -w 0)\"}}}" > /kaniko/.docker/config.json
    /kaniko/executor \
      --context "${GITHUB_WORKSPACE}" \
      --dockerfile "${GITHUB_WORKSPACE}/Dockerfile" \
      --destination "${REGISTRY}/${REPOSITORY}:${GITHUB_SHA}"

Yes, it’s a bit of a janky mess to create that JSON file. I don’t make the rules, I just explain them with a pained expression. The key takeaway is that the authentication is configured for the tool, not done in the Dockerfile itself. Never, ever put credentials in your Dockerfile. It’s the equivalent of leaving your password on a post-it note on the front door.

Get this build-and-push step right, and the rest of your pipeline—the deploying to Kubernetes, the rolling updates—becomes straightforward and reliable. Get it wrong, and you’ll be that person debugging failed pulls at midnight. Your choice.