Shipping Your Own Docker Syntax With BuildKit
# syntax= line at the top of a Dockerfile names an image. BuildKit pulls that image and lets it decide what your build file means. Write one in Go, push it to a registry, and anyone can use your build language with a plain docker build.You have probably seen this line at the top of a Dockerfile and never thought about it much:
# syntax=docker/dockerfile:1That line is an image reference. When BuildKit sees it, it pulls docker/dockerfile:1, runs it, and hands it your build file. That image is the thing that actually understands FROM, RUN and COPY. Docker calls it a frontend, and here is the fun part: you can write your own.
In this post we will build a frontend called gobuild. Instead of a Dockerfile, you give it this:
# syntax=ghcr.io/rossedman/gobuild:v1
go: "1.26"
main: ./cmd/hello
ldflags: -s -wand docker build turns it into a 2.6 MB image with nothing in it but your binary and a CA bundle. It cross-compiles for other architectures without emulation, takes a VERSION build arg, and works with docker compose. Notice the # syntax= line is a YAML comment, so the file is still valid YAML. Let’s dive into it.
How # syntax Works
- BuildKit reads the first line
Before parsing anything, BuildKit looks for a
# syntax=directive at the top of the build file. - It pulls and starts the frontend image
The frontend is a normal container image. BuildKit runs it and talks to it over gRPC on stdin and stdout.
- The frontend asks for files and returns a build graph
It reads your build file and build context through that connection, then answers with LLB, BuildKit’s low-level build graph: “take this image, run this command, copy this file”.
- BuildKit runs the graph
Caching, parallelism, multi-platform output and pushing all come for free, because they live in BuildKit, not in the frontend.
So a frontend is really a compiler from your file format to LLB. Ours is under 200 lines of Go. All of this works with a plain docker build on any recent Docker, since BuildKit has been the default builder since Docker 23.
The Spec
First, the language itself. Three fields, and we are strict about them, because a typo in a build file should fail loudly instead of quietly building the wrong thing.
package main
import (
"bytes"
"errors"
"fmt"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// Spec is the whole build language: a Go version, a package to build
// and optional linker flags.
type Spec struct {
Go string `yaml:"go"`
Main string `yaml:"main"`
LDFlags string `yaml:"ldflags"`
}
var goVersion = regexp.MustCompile(`^1\.[0-9]+(\.[0-9]+)?$`)
func parseSpec(dt []byte) (*Spec, error) {
var s Spec
dec := yaml.NewDecoder(bytes.NewReader(dt))
dec.KnownFields(true) // a typo like "mian:" is an error, not a silent default
if err := dec.Decode(&s); err != nil {
return nil, fmt.Errorf("parsing build file: %w", err)
}
if s.Go == "" {
return nil, errors.New(`build file needs a "go" version, like go: "1.26"`)
}
if !goVersion.MatchString(s.Go) {
return nil, fmt.Errorf("go version %q should look like 1.26 or 1.26.1", s.Go)
}
if s.Main == "" {
s.Main = "."
}
if !strings.HasPrefix(s.Main, ".") {
return nil, fmt.Errorf("main %q should be a relative package path like ./cmd/app", s.Main)
}
return &s, nil
}The version check isn’t just tidiness. The go field ends up inside an image reference (golang:<go>-alpine), so something like 1.26-alpine@sha256:... must not sneak through. This is plain Go, so it gets plain unit tests too, which is a nice change from testing Dockerfiles.
The Frontend
Here is the whole thing, then we’ll walk through it. It uses BuildKit v0.34.
package main
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"github.com/moby/buildkit/client/llb"
"github.com/moby/buildkit/frontend/dockerui"
"github.com/moby/buildkit/frontend/gateway/client"
"github.com/moby/buildkit/frontend/gateway/grpcclient"
"github.com/moby/buildkit/util/appcontext"
dockerspec "github.com/moby/docker-image-spec/specs-go/v1"
ocispecs "github.com/opencontainers/image-spec/specs-go/v1"
)
// Build args that are passed through to `go build` as environment variables.
var goEnvArgs = []string{"GOPROXY", "GOPRIVATE", "GOFLAGS"}
var versionArg = regexp.MustCompile(`^[A-Za-z0-9._+-]+$`)
func main() {
if err := grpcclient.RunFromEnvironment(appcontext.Context(), build); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func build(ctx context.Context, c client.Client) (*client.Result, error) {
bc, err := dockerui.NewClient(c)
if err != nil {
return nil, err
}
// Read the file that was passed with -f and turn it into a Spec.
src, err := bc.ReadEntrypoint(ctx, "yaml")
if err != nil {
return nil, err
}
spec, err := parseSpec(src.Data)
if err != nil {
return nil, err
}
ldflags := spec.LDFlags
if v, ok := bc.BuildArgs["VERSION"]; ok {
// The linker splits -ldflags on spaces, so keep VERSION to one token.
if !versionArg.MatchString(v) {
return nil, fmt.Errorf("VERSION %q may only contain letters, digits and ._+-", v)
}
ldflags = strings.TrimSpace(ldflags + " -X main.version=" + v)
}
// The build context: the directory at the end of `docker build`.
buildCtx, err := bc.MainContext(ctx)
if err != nil {
return nil, err
}
rb, err := bc.Build(ctx, func(ctx context.Context, platform *ocispecs.Platform, idx int) (*dockerui.BuildResult, error) {
buildPlatform := bc.BuildPlatforms[0]
target := buildPlatform
if platform != nil {
target = *platform
}
// Compile on the machine doing the build and let Go cross-compile
// for the target. No emulation needed.
golang := llb.Image("docker.io/library/golang:"+spec.Go+"-alpine",
llb.Platform(buildPlatform),
llb.WithMetaResolver(c),
)
runOpts := []llb.RunOption{
llb.Args([]string{"go", "build", "-trimpath", "-ldflags", ldflags, "-o", "/out/app", spec.Main}),
llb.Dir("/src"),
llb.AddEnv("CGO_ENABLED", "0"),
llb.AddEnv("GOOS", target.OS),
llb.AddEnv("GOARCH", target.Architecture),
llb.AddEnv("GOARM", strings.TrimPrefix(target.Variant, "v")),
llb.AddMount("/src", *buildCtx, llb.Readonly),
llb.AddMount("/root/.cache/go-build", llb.Scratch(), llb.AsPersistentCacheDir("gobuild-cache", llb.CacheMountShared)),
llb.AddMount("/go/pkg/mod", llb.Scratch(), llb.AsPersistentCacheDir("gobuild-mod", llb.CacheMountShared)),
llb.WithCustomNamef("go build %s (%s/%s)", spec.Main, target.OS, target.Architecture),
}
for _, name := range goEnvArgs {
if v, ok := bc.BuildArgs[name]; ok {
runOpts = append(runOpts, llb.AddEnv(name, v))
}
}
run := golang.Run(runOpts...)
out := run.AddMount("/out", llb.Scratch())
// The final image is just the binary and CA certificates.
final := llb.Scratch().
File(llb.Copy(out, "/app", "/app"), llb.WithCustomName("copy binary")).
File(llb.Copy(golang, "/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs/ca-certificates.crt", &llb.CopyInfo{CreateDestPath: true}), llb.WithCustomName("copy CA certificates"))
def, err := final.Marshal(ctx, llb.Platform(target))
if err != nil {
return nil, err
}
res, err := c.Solve(ctx, client.SolveRequest{Definition: def.ToPB()})
if err != nil {
return nil, err
}
ref, err := res.SingleRef()
if err != nil {
return nil, err
}
img := &dockerspec.DockerOCIImage{
Image: ocispecs.Image{
Platform: target,
RootFS: ocispecs.RootFS{Type: "layers"},
},
// DockerOCIImage has its own Config that shadows Image.Config.
// Set this one or the image ends up with no entrypoint.
Config: dockerspec.DockerOCIImageConfig{
ImageConfig: ocispecs.ImageConfig{
Entrypoint: []string{"/app"},
User: "65534:65534",
Env: []string{"SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"},
},
},
}
return &dockerui.BuildResult{Reference: ref, Image: img}, nil
})
if err != nil {
return nil, err
}
return rb.Finalize()
}That’s a lot of words for a “hello world”, so let’s break it down.
Talking to BuildKit
grpcclient.RunFromEnvironment sets up the gRPC connection to BuildKit and calls build. Inside, dockerui.NewClient is a helper from BuildKit itself (the Dockerfile frontend uses it too) that handles the boring parts: which file was passed with -f, build args, .dockerignore, target platforms. ReadEntrypoint gives us the bytes of gobuild.yaml, and MainContext gives us the build context as an LLB state we can mount.
The Build Graph
Nothing is actually built while this code runs. llb.Image, Run and Copy only describe steps, and BuildKit runs them when we call c.Solve. In plain words, our graph says:
- Start from
golang:1.26-alpineon the build machine’s platform. - Mount the build context read-only at
/src, plus two cache mounts so Go’s build cache and module cache survive between builds. - Run
go buildwithGOOS/GOARCHset to the target platform, writing to an empty/outmount. - Start a new image from
scratchand copy in/out/appand the CA bundle from the Go image.
Steps 1 and 3 are the cross-compiling trick. The Go toolchain always runs natively and only the output is foreign, so --platform linux/arm64 doesn’t need QEMU at all.
We use llb.Args instead of a shell string, so nothing in ldflags or main is ever interpreted by a shell. llb.WithMetaResolver(c) matters too: it pulls in the Go image’s config, so things like PATH are set and go can actually be found.
The Image Config
Finally we describe the image: run /app, as user 65534 (nobody), with SSL_CERT_FILE pointing at our bundle so HTTPS works from scratch. bc.Build calls our function once per target platform and rb.Finalize() stitches the results into one image, or a multi-platform index if you asked for several.
Shipping It
A frontend is just an image whose entrypoint is our binary. It doesn’t need a shell or anything else, so scratch is perfect.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY *.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /gobuild .
FROM scratch
COPY --from=build /gobuild /bin/gobuild
ENTRYPOINT ["/bin/gobuild"]Ship it for both Intel and ARM so it works on everyone’s laptop. I keep a GitHub token in 1password and load it with direnv, which makes logging in to GitHub’s registry painless. The token needs the write:packages scope.
➜ echo $GITHUB_TOKEN | docker login ghcr.io -u rossedman --password-stdin
➜ docker buildx build --platform linux/amd64,linux/arm64 \
-t ghcr.io/rossedman/gobuild:v1 --push .Using It
Here is the project we’re building. A tiny app that prints its platform and version:
package main
import (
"fmt"
"runtime"
)
var version = "dev"
func main() {
fmt.Printf("hello from %s/%s, version %s\n", runtime.GOOS, runtime.GOARCH, version)
}Put gobuild.yaml from the top of this post next to go.mod, and point docker build at it with -f:
➜ docker build -f gobuild.yaml -t hello .
➜ docker run --rm hello
hello from linux/amd64, version devThe build log shows BuildKit fetching our frontend first, then running the steps we described:
#1 [internal] load build definition from gobuild.yaml
#2 resolve image config for docker-image://ghcr.io/rossedman/gobuild:v1
...
#8 go build ./cmd/hello (linux/amd64)
#9 copy binary
#10 copy CA certificatesAnd the image is tiny:
➜ docker image ls hello
REPOSITORY TAG IMAGE ID CREATED SIZE
hello latest ... ... 2.64MBBuild Args
Our frontend reads VERSION and bakes it into the binary:
➜ docker build -f gobuild.yaml --build-arg VERSION=1.4.0 -t hello:1.4.0 .
➜ docker run --rm hello:1.4.0
hello from linux/amd64, version 1.4.0If your modules live somewhere private, GOPROXY, GOPRIVATE and GOFLAGS pass straight through to go build the same way.
Other Platforms
Because we cross-compile, other architectures are just a flag. Here’s an ARM binary built on an Intel machine, exported straight to disk:
➜ docker buildx build -f gobuild.yaml --platform linux/arm64 -o type=local,dest=out .
➜ file out/app
out/app: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, ...Or build both and push a multi-platform image in one go:
➜ docker buildx build -f gobuild.yaml --platform linux/amd64,linux/arm64 \
-t ghcr.io/rossedman/hello:v1 --push .Docker Compose
Compose doesn’t need to know anything special. Point dockerfile at the YAML file:
services:
hello:
build:
context: .
dockerfile: gobuild.yaml
args:
VERSION: "2.0.0"
image: hello:composePinning The Frontend
A tag like :v1 can move. For builds you care about, pin the digest, exactly like you would with a base image. BuildKit refuses to run a frontend whose digest doesn’t match.
# syntax=ghcr.io/rossedman/gobuild:v1@sha256:<digest>
go: "1.26"
main: ./cmd/hellodocker buildx imagetools inspect ghcr.io/rossedman/gobuild:v1 prints the digest.
Without The Syntax Line
You can also choose the frontend from the command line with the BUILDKIT_SYNTAX build arg. It wins over the # syntax= line, which makes it handy for trying a new version without editing files:
➜ docker build -f gobuild.yaml --build-arg BUILDKIT_SYNTAX=ghcr.io/rossedman/gobuild:v2 .If there’s no syntax line and no build arg, BuildKit falls back to the regular Dockerfile parser, which has opinions about YAML:
ERROR: failed to build: failed to solve: dockerfile parse error on line 1: unknown instruction: go:Errors Are Part Of The Language
Whatever error the frontend returns is exactly what the person running docker build sees. That’s why the spec is strict. Here’s a typo:
ERROR: failed to build: failed to solve: parsing build file: yaml: unmarshal errors:
line 3: field mian not found in type main.SpecAnd a VERSION with spaces in it, which would otherwise smuggle extra flags into the linker:
ERROR: failed to build: failed to solve: VERSION "1.2.3 -X main.evil=1" may only contain letters, digits and ._+-Iterating Locally
You don’t want to push to ghcr.io every time you change a line. Run a registry locally and point the syntax line at it:
➜ docker run -d --name registry -p 5000:5000 registry:2
➜ docker build -t localhost:5000/gobuild:dev . && docker push localhost:5000/gobuild:dev# syntax=localhost:5000/gobuild:dev
go: "1.26"
main: ./cmd/helloDocker trusts localhost registries over plain HTTP, so there’s nothing else to configure. Rebuild, push, run docker build again.
Conclusion
That’s it! The # syntax line turns out to be a plugin system for docker build, and a frontend is just a Go program that turns your file format into a build graph. Everything hard (caching, parallelism, multi-platform images, pushing) is still BuildKit’s job.
The full source, with tests and an example app, is on GitHub. Ours is deliberately small, but it’s the same mechanism behind docker/dockerfile:1 itself. Once you see it that way, a lot of ideas open up: a frontend for your team’s standard service layout, one that reads a package.json or pyproject.toml directly, or one that enforces your base images and labels. Happy hacking!