Every project starts the same way: I copy a Dockerfile from the last project, change three lines, and pretend I wrote it from scratch. So I finally sat down and built the one I keep copying. Is it the best Dockerfile in the world? No. Does it work on everything I throw at it? Suspiciously, yes.
Let's rip the bandage off and look at the whole thing first, then talk about the parts that matter.
# Compile
FROM golang:1.21.3-alpine3.18 AS base
WORKDIR /app
ENV GO111MODULE="on"
ENV GOOS="linux"
ENV CGO_ENABLED=0
COPY . /app
RUN /bin/sh -c "go build -o app -a ."
# Dev
FROM base AS dev
WORKDIR /app
RUN go install github.com/cosmtrek/air@latest \
&& go install github.com/go-delve/delve/cmd/dlv@latest \
&& go install github.com/swaggo/swag/cmd/swag@v1.8.12
EXPOSE 8000
EXPOSE 2346
ENTRYPOINT ["air"]
# Run
FROM alpine:latest
WORKDIR /app
RUN apk update \
&& apk add --no-cache \
ca-certificates \
curl \
tzdata \
&& update-ca-certificates
COPY --from=base /app/app /app/app
COPY --from=base /app/migrations/ /app/migrations/
COPY --from=base /app/web/ /app/web/
EXPOSE 8080
ENTRYPOINT ["/app/app"]
The base stage: where the magic compiles
This stage grabs the official golang:1.21.3-alpine3.18 image, copies the source in and builds it. With CGO_ENABLED=0 you get a statically linked binary that doesn't need libc, which is exactly what makes the tiny final image possible.
One small habit that pays off forever: I always name the binary app. Same name in every project means every COPY, ENTRYPOINT and muscle-memory command just works. Future me is lazy, and I like to keep him happy.
The dev stage: live reload, no excuses
This stage only exists locally — I select it through a Docker Compose override. It installs air for live reload, dlv for debugging, and swag for generating API docs. Port 2346 is open so you can attach a remote debugger and feel like a real engineer.
The run stage: small, boring, perfect
The final image is plain Alpine with the binary copied in and nothing else compiled. We add ca-certificates (for outbound HTTPS), tzdata (so timestamps aren't a lie), and curl — the one non-obvious pick.
Why curl? Because Docker's HEALTHCHECK needs something inside the container to hit your endpoint. Leave it out and your health check fails silently, your orchestrator marks the container unhealthy, and you spend an evening blaming everything except the missing 2 MB binary. Ask me how I know.
So is it any good?
It's a good starting point, which is all a Dockerfile should be. It builds, it's small, it gives you live reload locally and a lean image in prod. Copy it, change three lines, and pretend you wrote it from scratch. That's the circle of life.
// This is the exact image that ships every one of my mini-APIs.