Updated Jul 20, 2026

04 - Container Image Building

📋 Jump to Takeaways

🎁 What if your CI can't mount the Docker socket? How do you build and push container images with nothing but Go code and a registry endpoint?

Imports in code snippets are trimmed for brevity. See the full example linked at the end for complete, compilable source.

OCI Image Specification

The Open Container Initiative (OCI) image spec defines a standard format for container images. An image consists of a manifest (JSON describing the image), a config (runtime settings like entrypoint, env vars, working directory), and an ordered list of layers (filesystem changesets stored as compressed tar archives). Every registry (Docker Hub, GCR, ECR, GHCR) speaks this same protocol.

Understanding this structure means you can build, inspect, and push images entirely in Go without a Docker daemon running. This is critical for CI/CD pipelines running in containers themselves, serverless build systems, and custom deployment tooling.

go-containerregistry Library

Google's go-containerregistry library provides a pure-Go implementation for interacting with OCI images and registries. No Docker socket required.

go get github.com/google/go-containerregistry

Key packages:

Package Purpose
v1 Core image types and interfaces
v1/empty Empty base image (scratch)
v1/mutate Add layers, set config
v1/tarball Create layers from tar archives
v1/remote Push/pull from registries
v1/remote/transport Auth and transport

Creating Layers Programmatically

A layer is a tar.gz archive representing filesystem additions. You create one by writing files into a tar buffer:

package main

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"time"

	v1 "github.com/google/go-containerregistry/pkg/v1"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
)

func createLayer(files map[string][]byte) (v1.Layer, error) {
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gw)

	for name, content := range files {
		header := &tar.Header{
			Name:    name,
			Size:    int64(len(content)),
			Mode:    0755,
			ModTime: time.Now(),
		}
		if err := tw.WriteHeader(header); err != nil {
			return nil, err
		}
		if _, err := tw.Write(content); err != nil {
			return nil, err
		}
	}

	tw.Close()
	gw.Close()

	return tarball.LayerFromReader(&buf)
}

This gives you full control. Add binaries, config files, certificates, or anything else directly into the image filesystem.

Mutating Base Images

The mutate package lets you take any base image and modify it. You can add layers, set the entrypoint, configure environment variables, and change the working directory:

import (
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	v1 "github.com/google/go-containerregistry/pkg/v1"
)

func buildImage(binaryLayer v1.Layer) (v1.Image, error) {
	// Start from scratch (empty image)
	base := empty.Image

	// Add the layer containing our binary
	img, err := mutate.AppendLayers(base, binaryLayer)
	if err != nil {
		return nil, err
	}

	// Set runtime configuration
	cfg, err := img.ConfigFile()
	if err != nil {
		return nil, err
	}

	cfg.Config.Entrypoint = []string{"/app/server"}
	cfg.Config.Env = []string{
		"PORT=8080",
		"ENV=production",
	}
	cfg.Config.WorkingDir = "/app"
	cfg.Config.ExposedPorts = map[string]struct{}{
		"8080/tcp": {},
	}

	img, err = mutate.ConfigFile(img, cfg)
	if err != nil {
		return nil, err
	}

	return img, nil
}

You can also start from a real base image pulled from a registry instead of empty.Image:

import "github.com/google/go-containerregistry/pkg/v1/remote"

ref, _ := name.ParseReference("gcr.io/distroless/static:nonroot")
base, _ := remote.Image(ref)
img, _ := mutate.AppendLayers(base, myLayer)

Pushing to Registries with Authentication

The remote package handles pushing. Authentication uses the same credential helpers as Docker:

import (
	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/name"
	"github.com/google/go-containerregistry/pkg/v1/remote"
)

func pushImage(img v1.Image, destination string) error {
	ref, err := name.ParseReference(destination)
	if err != nil {
		return err
	}

	// Uses ~/.docker/config.json or credential helpers
	return remote.Write(ref, img,
		remote.WithAuthFromKeychain(authn.DefaultKeychain),
	)
}

For programmatic auth (CI/CD environments), provide credentials directly:

auth := &authn.Basic{
	Username: "oauth2accesstoken",
	Password: os.Getenv("GCR_TOKEN"),
}

err := remote.Write(ref, img, remote.WithAuth(auth))

Image Scanning Concepts

Once you can build images in Go, you can also inspect them for vulnerabilities. The workflow:

  1. Extract layers: iterate over img.Layers() and read the filesystem contents
  2. Parse package manifests: find dpkg/status, apk/installed, Go go.sum files
  3. Match against vulnerability databases: compare package versions against CVE feeds

Libraries like aquasecurity/trivy use this approach. Your custom tooling can hook into the build pipeline to reject images with critical CVEs before they reach a registry.

Scratch Image Builder

A program that builds minimal OCI container images entirely in Go, no Docker daemon required. It reads a pre-compiled static binary, constructs a tar layer, assembles a scratch-based image with entrypoint and environment config, and pushes the result to a container registry. The output is a 5-15 MB image with zero OS dependencies.

Input: A statically-compiled Go binary path and a destination registry reference (e.g., go run main.go myserver ghcr.io/myorg/myapp:v1.0.0)

Output: A pushed OCI image with digest confirmation, containing only your binary in a scratch container

Full source: examples/scratch-image-builder

Key Takeaways

  • OCI images are manifests + config + ordered tar.gz layers, a simple format you can construct entirely in Go
  • go-containerregistry eliminates the Docker daemon dependency, enabling image builds in any environment
  • Creating layers is just writing tar archives; mutating images is composing layers with config changes
  • Registry auth integrates with Docker credential helpers or accepts programmatic tokens for CI/CD
  • Building from scratch produces minimal images with tiny attack surfaces, ideal for production Go services
  • This approach enables custom build pipelines, image scanning, and registry management as native Go tooling

🎁 Your images are built and pushed. How do you tell Kubernetes to actually run them, all from Go code that creates deployments, watches pods, and rolls back failures?

💻 Examples

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue
© 2026 ByteLearn.dev. Free courses for developers. · Privacy