Deploy a Go App — Static Binary, Tiny Image, Smallest Footprint You Can Run
Go is the best-fitting workload on a small container platform, for one reason: it compiles to a single static binary with no interpreter, no runtime to install, and no dependency tree to resolve at boot. A multi-stage Dockerfile builds it and copies that one file into a scratch or distroless image, giving you a container measured in single-digit megabytes that starts instantly. The one thing to know up front: deploy Go with a Dockerfile, not the zip uploader — the zip flow's build agent detects Python, Node.js, and prebuilt .NET, and Go isn't among them.
Use a Dockerfile, not the zip flow
The zip upload path hands your project to a build agent that infers the runtime from your source. It handles Python, Node.js, and prebuilt .NET publish output. Go isn't one of its runtimes, so a Go project uploaded as a zip won't be detected.
That's not a limitation in practice, because Go is the language that least needs build inference. Your build is two commands, and you already know them. Write a Dockerfile, push it by git, and the platform builds it — the git deploy path builds the Dockerfile at your repository root, which is exactly what you want here.
The Dockerfile
# build
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server ./cmd/server
# run
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
The details that matter:
CGO_ENABLED=0produces a genuinely static binary. Without it you link against the build image's libc and the binary won't run inscratchordistroless/static— this is the single most common Go container failure.-ldflags="-s -w"strips symbols and debug info, cutting the binary size meaningfully for no runtime cost.- Copy
go.mod/go.sumand rungo mod downloadbefore copying the source. Dependencies change far less often than code, so this layer caches and your rebuilds get much faster. distroless/staticgives you CA certificates and timezone data, whichscratchdoesn't. If your app makes HTTPS calls — and it does —scratchwill fail TLS verification unless you copy certs in yourself. Distroless is the sensible default.
Bind to 0.0.0.0
Same rule as every other stack, and worth repeating because it produces a successful deploy that returns 502:
http.ListenAndServe(":8080", mux) // all interfaces — correct
A bare :8080 binds all interfaces, which is what you want. 127.0.0.1:8080 binds loopback only and is unreachable from outside the container. There's no HTTP health check between the router and your container, so nothing catches this at deploy time — more on diagnosing that.
Bind an HTTPS port to container port 8080 and TLS is terminated at the edge with an automatic certificate.
Why it fits a small quota so well
A Go service is close to the floor of what a container can cost:
- No runtime. There's no interpreter or VM resident before your code runs — the binary is the process.
- Fast start. No JIT warm-up, no module resolution, no dependency loading. Redeploys are quick, which matters because a redeploy is a real gap and your startup time is most of it.
- Small images. A stripped static binary in a distroless base keeps your disk quota essentially free for actual data.
One thing to be deliberate about: Go's garbage collector targets a heap roughly twice your live data by default, and it doesn't know about your container's memory limit unless you tell it. On a tight quota, set a soft memory limit so the GC works harder instead of getting OOM-killed:
import "runtime/debug"
func init() {
debug.SetMemoryLimit(200 << 20) // 200 MiB, below your container limit
}
Or set GOMEMLIMIT=200MiB as an environment variable — same effect, no code change. Leave headroom below your actual quota, because the limit governs the heap, not the whole process.
Where data goes
Everything a Go binary writes lands in the container's writable layer, which is discarded when the container is recreated — on every redeploy. If your service keeps a SQLite file, a cache directory, or uploads, mount a volume and write inside it.
Worth noting for Go specifically: mattn/go-sqlite3 requires cgo, which conflicts with CGO_ENABLED=0. Either use a pure-Go driver like modernc.org/sqlite and keep your static build, or accept cgo and base the final image on something with libc (debian:bookworm-slim or alpine with musl considerations). Pure-Go is the simpler path.
Workers don't need a port at all
If you're building a bot, a queue consumer, or a poller rather than an HTTP service, skip the port binding entirely. An app with no exposed port is a worker — nothing to route, nothing to health-check. A Go Discord bot is about as small as a hosted process gets, and it sits comfortably at the bottom of the preset ladder.
FAQ
Can I deploy Go by uploading a zip?
No. The zip flow's build agent detects Python, Node.js, and prebuilt .NET. Use a Dockerfile with git push or Docker push instead.
Why doesn't my Go binary run in a scratch image?
Almost always because it was built without CGO_ENABLED=0, so it's dynamically linked against libc that isn't present. Rebuild with cgo disabled, or use a base image that has libc.
Should I use scratch or distroless?
Distroless static for most apps — it includes CA certificates and timezone data. Use scratch only if you're copying those in yourself and know you don't need them.
How do I stop Go from being OOM-killed on a small quota?
Set GOMEMLIMIT (or debug.SetMemoryLimit) below your container's memory limit. Go's GC otherwise sizes the heap without regard to the container's ceiling.
What port should my Go server listen on?
Whatever you bind in the panel — commonly 8080. Listen on :8080, not 127.0.0.1:8080, or the container is unreachable.
Does SQLite work in a static Go build?
Not with mattn/go-sqlite3, which needs cgo. Use a pure-Go driver such as modernc.org/sqlite, or drop the static build and use a libc-based image.
Wrapping up
Most of the effort in deploying an app to a small container goes into shrinking the gap between "my runtime is loaded" and "my code is running." Go doesn't have that gap — the binary is the whole thing. Build it statically, put it in a distroless image, tell the GC what your memory ceiling is, and you get a service that starts fast, redeploys fast, and leaves almost all of your quota for the work you actually care about.