My Go API ships as a FROM scratch image: a statically linked binary and
nothing else. About 10MB, no base distro, no package manager, no shell, no
attack surface that isn't my own code. It is a genuinely nice way to ship a Go
service and I recommend it.
Then I added a feature that copies a SQLite database to a temp file before reading it, and the first deploy came back with this:
open /tmp/fail2ban-1274093991.sqlite3: no such file or directory
I spent a few minutes looking for the file it couldn't find before noticing
that the missing thing was /tmp.
Why it fails
os.CreateTemp("", ...) and os.MkdirTemp("", ...) resolve an empty directory
argument through os.TempDir(), which on Linux is $TMPDIR or, if that's
unset, the literal string /tmp. Neither function creates that parent — it is
not their job, and every other Linux system you will ever run on already has
it, mode 1777, since before you were writing software.
FROM scratch means the image is empty. Not minimal — empty. There is no
/tmp, because there is no anything. The only file in that image is the one
byte-for-byte binary I copied in.
So this is not a Go bug and not a Docker bug. It is the ordinary assumption that the filesystem exists, meeting the one base image where it doesn't.
Why the tests couldn't catch it
They passed. They still pass. go test runs on my laptop and in the CI
runner, both of which have a real /tmp, so the code under test is exercised
in an environment where its assumption holds. Even a test that ran the exact
production binary would have passed — the binary is not what's broken.
The defect doesn't live in the code. It lives in the gap between the code and the image, and there is no Go tooling that can see across that gap, because from the compiler's point of view nothing is wrong.
That's a whole family of bugs with the same shape, and if you ship scratch you
will meet the rest of them eventually:
- No CA bundle. Any outbound HTTPS call fails with
x509: certificate signed by unknown authority. Fix: copy/etc/ssl/certs/ca-certificates.crtfrom the builder. - No
/etc/passwdor/etc/group.user.Current()errors, and aUSER appline in the Dockerfile can't resolve a name that isn't in a file that isn't there — numeric UIDs still work. - No timezone database.
time.LoadLocationfails for any named zone — onlyUTCandLocalwork. Fix:import _ "time/tzdata", and Go embeds a copy in the binary. - No
/tmp. This post.
Every one of them is invisible in tests and obvious in production, which is a bad combination of properties.
The fix
One line, and it is smaller than it looks:
FROM golang:1.26-alpine AS builder
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o api ./cmd/api
FROM scratch
COPY --from=builder /tmp /tmp
COPY --from=builder /app/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
COPY --from=builder /tmp /tmp copies the builder's temp directory into the
final image. The builder's /tmp is empty and mode 1777 — I checked rather
than assumed:
$ docker run --rm --entrypoint sh golang:1.26-alpine -c 'stat -c "%a %U %n" /tmp; ls -A /tmp | wc -l'
1777 root /tmp
0
COPY preserves the mode, so you get a correctly-permissioned, world-writable,
sticky-bit temp directory for free. That matters more than it sounds: the
obvious alternative is RUN mkdir /tmp && chmod 1777 /tmp, and you cannot run
that in a scratch stage because RUN needs a shell and there isn't one.
Borrowing the directory from a stage that does have a userland is the way
around the chicken-and-egg problem, and it's the same trick as copying the CA
bundle.
Two details worth knowing before you copy this:
- Copy
/tmpfrom a stage you control. If your builder ever leaves files in/tmp, they land in your final image, and the whole point ofscratchwas knowing exactly what's in there. Verify withdocker export $(docker create your-image) | tar -tf -, which lists every path in the image — useful generally, since you can'tdocker execa shell into an image that has none. - This gives you a directory, not durability. It's in the container's writable
layer, so it's gone on recreate. For genuine scratch space that's usually
what you want; if it isn't, you want a volume or a
tmpfsmount.
Other ways out
Worth knowing so you pick deliberately rather than cargo-culting my Dockerfile:
- Set
TMPDIRto a path you do provide — a mounted volume, or atmpfs.os.TempDir()honors it. Good when you want the temp files on a specific filesystem anyway. - Pass an explicit directory instead of
""as the first argument toMkdirTemp/CreateTemp. Honest, and it moves the decision into code where a reader can see it — at the cost of hardcoding a path the image had better contain. - Use
gcr.io/distroless/staticinstead ofscratch. It ships/tmp, a CA bundle,/etc/passwdand tzdata, and is still tiny. If you're hitting the second or third item on the missing-pieces list above, this is probably the right answer andscratchis costing you more than it's saving. - Don't use a temp file. In my case the temp copy is load-bearing — the source database is on a read-only mount and SQLite needs somewhere it can write. But "do I actually need the filesystem here" is worth one minute of thought before you spend twenty on the Dockerfile.
The general lesson
FROM scratch's entire selling point is that nothing is in the image. That is
also its entire failure mode. Every base image you've ever used is a pile of
assumptions you never had to notice, and choosing scratch is choosing to
notice all of them at once — usually one at a time, in production, on deploy
day.
The tell is that the error message was completely accurate and I still misread
it. no such file or directory on /tmp/fail2ban-1274093991.sqlite3 means one
of those path components doesn't exist, and I assumed it was the last one
because the last one is the one my code was about to create. It was the
first one. When a path error surprises you, check the directories, not the file.