Go services are the comfortable choice on small containers. A statically linked binary typically runs in 10–30 MB, which leaves the rest of a Starter plan for whatever the code actually does. One detail causes nearly every Go deployment failure.
Binding the right interface
Go's ListenAndServe defaults to every interface when you pass an empty host, and the common mistake is writing the address the other way around:
// Correct
addr := ":" + port
http.ListenAndServe(addr, mux)
// Wrong — binds loopback only
http.ListenAndServe("localhost:"+port, mux)
In Go, ":8080" means all interfaces and "0.0.0.0:8080" means the same thing explicitly. "localhost:8080" means loopback only. Writing "http://0.0.0.0:3000" as though it were Node also fails, because that is not a valid listen address.
Reading the port from the environment:
package main
import (
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Printf("listening on %s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}
Building inside the container
Ship a source tree and compile on the container. You avoid cross-compilation problems, and the Go module cache makes repeat builds fast.
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app/server ./cmd/server
CGO_ENABLED=0 produces a static binary with no libc dependency, which means it runs on Alpine. -trimpath strips local filesystem paths from the binary, and -ldflags="-s -w" drops the symbol table and debug info — smaller binary, and no build paths leaked into your executable.
Set that as the startup command, or build once via the console and set the command to run the binary directly for faster restarts.
Prefer a multi-stage Dockerfile
Building in the container works, but a multi-stage build ships only the binary:
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM alpine:latest
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
COPY --from=build /out/server /server
USER app
EXPOSE 3000
ENTRYPOINT ["/server"]
The final image contains a binary and nothing else — no toolchain, no source, no module cache. Running as a non-root user costs nothing and removes a class of problems if the service ever handles untrusted input.
Set a read or write timeout
The zero value in Go's http.Server means no timeout at all, which lets a single slow client hold a connection open indefinitely. On a small container, enough of those exhausts the process. Set them explicitly:
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}