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@latestmain.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:2Build 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:latestExpected output:
pushing localhost:5000/myapp:latest (digest: sha256:3f4a2b...)...
pushed successfullyInspecting 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:latestPushing 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.0For Artifactory or any private registry, ensure docker login registry.example.com has run first. authn.DefaultKeychain reads from ~/.docker/config.json automatically.