hupden/ projects
blogtools
← blog

FROM scratch Go images have no /tmp

#go#docker#containers

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:

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:

Other ways out

Worth knowing so you pick deliberately rather than cargo-culting my 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.