Scratch Image Builder

A program that builds minimal OCI container images entirely in Go, without Docker. It takes a pre-compiled static Go binary, packages it into a scratch-based image by constructing tar layers and OCI config, then pushes the result to a container registry using go-containerregistry. The resulting image is typically 5-15 MB with no OS, shell, or package manager.

Setup

mkdir scratch-image-builder
cd scratch-image-builder
go mod init github.com/yourorg/scratch-image-builder
go get github.com/google/go-containerregistry@latest

main.go

package main

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/name"
	v1 "github.com/google/go-containerregistry/pkg/v1"
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
)

// createBinaryLayer builds a compressed tar layer containing a single executable
// at path with mode 0755. ModTime is fixed to epoch for deterministic layer digests —
// using time.Now() would produce a different digest on every build and defeat registry caching.
func createBinaryLayer(path string, content []byte) (v1.Layer, error) {
	var buf bytes.Buffer
	gw := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gw)

	// Fixed epoch time — critical for reproducible layer digests
	epoch := time.Unix(0, 0).UTC()

	tw.WriteHeader(&tar.Header{
		Name:     "app/",
		Typeflag: tar.TypeDir,
		Mode:     0755,
		ModTime:  epoch,
	})

	tw.WriteHeader(&tar.Header{
		Name:    path[1:], // strip leading slash for tar
		Size:    int64(len(content)),
		Mode:    0755,
		ModTime: epoch,
	})
	tw.Write(content)

	tw.Close()
	gw.Close()

	return tarball.LayerFromReader(&buf)
}

// assembleImage appends layer to a scratch base image and sets the entrypoint,
// exposed port, and environment variables in the OCI config.
func assembleImage(layer v1.Layer) (v1.Image, error) {
	img, err := mutate.AppendLayers(empty.Image, layer)
	if err != nil {
		return nil, err
	}

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

	cfg.Config.Entrypoint = []string{"/app/server"}
	cfg.Config.Env = []string{"PORT=8080"}
	cfg.Config.ExposedPorts = map[string]struct{}{"8080/tcp": {}}
	cfg.Author = "go-devops-builder"

	return mutate.ConfigFile(img, cfg)
}

func main() {
	if len(os.Args) != 3 {
		fmt.Fprintln(os.Stderr, "builds a scratch OCI image from a static binary and pushes it to a registry")
		fmt.Fprintf(os.Stderr, "\nusage: %s <binary-path> <destination-ref>\n", os.Args[0])
		fmt.Fprintln(os.Stderr, "\nexamples:")
		fmt.Fprintln(os.Stderr, "  go run main.go ./myserver localhost:5000/myapp:latest")
		fmt.Fprintln(os.Stderr, "  go run main.go ./myserver ghcr.io/myorg/myapp:v1.0.0")
		fmt.Fprintln(os.Stderr, "\nnote: registry auth uses ~/.docker/config.json")
		os.Exit(1)
	}

	binaryPath := os.Args[1]
	destRef := os.Args[2]

	binary, err := os.ReadFile(binaryPath)
	if err != nil {
		log.Fatalf("reading binary: %v", err)
	}

	layer, err := createBinaryLayer("/app/server", binary)
	if err != nil {
		log.Fatalf("creating layer: %v", err)
	}

	img, err := assembleImage(layer)
	if err != nil {
		log.Fatalf("assembling image: %v", err)
	}

	digest, _ := img.Digest()
	fmt.Printf("pushing %s (digest: %s)...\n", destRef, digest)

	ref, err := name.ParseReference(destRef)
	if err != nil {
		log.Fatalf("parsing reference: %v", err)
	}

	if err := remote.Write(ref, img,
		remote.WithAuthFromKeychain(authn.DefaultKeychain),
	); err != nil {
		log.Fatalf("pushing image: %v", err)
	}

	fmt.Printf("pushed successfully\n")
}

Running It

Start a local registry if you don't have one:

docker run -d -p 5000:5000 --name registry registry:2

Build a static binary to package. A minimal HTTP server works well as a test target:

# CGO_ENABLED=0 is required — scratch images have no libc
CGO_ENABLED=0 go build -o myserver .

# build the OCI image and push to your local registry
go run main.go ./myserver localhost:5000/myapp:latest

Expected output:

pushing localhost:5000/myapp:latest (digest: sha256:3f4a2b...)...
pushed successfully

Inspecting the Result

Query the registry API to confirm the image arrived:

# list all repositories
curl http://localhost:5000/v2/_catalog
# {"repositories":["myapp"]}

# list tags
curl http://localhost:5000/v2/myapp/tags/list
# {"name":"myapp","tags":["latest"]}

# fetch the manifest — shows layers, config digest, and media type
curl http://localhost:5000/v2/myapp/manifests/latest \
  -H "Accept: application/vnd.oci.image.manifest.v1+json"

Or use Docker:

docker pull localhost:5000/myapp:latest
docker image inspect localhost:5000/myapp:latest

# run it — exits immediately if your binary has no HTTP server
docker run --rm -p 8080:8080 localhost:5000/myapp:latest

Pushing to a Real Registry

For GitHub Container Registry:

echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
go run main.go ./myserver ghcr.io/yourorg/myapp:v1.0.0

For Artifactory or any private registry, ensure docker login registry.example.com has run first. authn.DefaultKeychain reads from ~/.docker/config.json automatically.

💻 Run locally

Copy the code above and run it on your machine

© 2026 ByteLearn.dev. Free courses for developers. · Privacy