A Go Web Service on a Free Container Tier: A 15 MB Image and Tens of Megabytes of RAM
If a free container tier was designed for one language, it was Go. A Go web service compiles to a single static binary, starts in milliseconds and idles in a few tens of megabytes, so 512 MB and a quarter of a CPU is not a constraint, it is a surplus. This guide builds a small HTTP service the way it should be built for a container: a two-stage Dockerfile, a tiny image, a health endpoint, graceful shutdown, and the port read from the environment.
The service
Standard library only. net/http is enough for most services and it keeps the binary small. Go 1.22 and later route by method and path pattern, so no router package is needed for this:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /api/time", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"now": time.Now().UTC().Format(time.RFC3339)})
})
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Printf("listening on :%s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
log.Println("shutting down")
srv.Shutdown(ctx)
}Three things in there earn their keep on a container host. PORT from the environment, so the platform decides the port. ReadHeaderTimeout, so a slow client cannot hold a connection open forever on a small instance. And the SIGTERM handler: when a container is stopped, or a free container goes to sleep after 15 minutes without traffic, the runtime sends SIGTERM first; a server that shuts down cleanly finishes in-flight requests instead of dropping them.
The Dockerfile
FROM golang:1-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 .
FROM alpine:3
RUN apk add --no-cache ca-certificates && adduser -D -u 10001 app
USER app
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]golang:1-alpinetracks the current Go 1.x release, so the tag does not go stale. Pingolang:1.27-alpineif you want reproducible builds.CGO_ENABLED=0produces a static binary;-s -wstrips symbols and debug info. The service above builds to roughly 7 MB.- The final image is Alpine plus certificates plus the binary: about 15 MB.
scratchwould be smaller still, but Alpine gives you a shell for debugging and the CA bundle for outbound HTTPS. - Running as a non-root user costs nothing and is the first thing a security scan looks for.
If you deploy without a Dockerfile, SnapDeploy generates one when it sees go.mod: a two-stage build with CGO_ENABLED=0 onto an Alpine runtime, much like the above. The generated builder uses a Go 1.21 image; if your go.mod declares a newer Go, the toolchain directive downloads it during the build. For control over the version, keep your own Dockerfile.
What it uses at runtime
Measured on the service above: about 10 MB of resident memory idle, under 30 MB while handling a few hundred requests a second, and start-up in well under a second. Against a 512 MB container that leaves room for in-memory caches, a SQLite file for read-mostly data, or an embedded queue, none of which a Node or JVM service could afford in the same box. Wake-up on the free tier is about 60 seconds, but almost all of that is the platform starting the container; the Go process itself is ready as soon as it is scheduled.
If you want to see the numbers yourself, GODEBUG=gctrace=1 prints heap sizes at each collection, and /proc/self/status inside the container shows VmRSS.
Deploying
- Push the repository to GitHub with the Dockerfile.
- In SnapDeploy, Containers → Deploy from GitHub → choose the repository. Confirm the port (8080 here).
- Deploy. Go builds are quick; the first build is a few minutes because the base images are pulled and modules downloaded, later builds reuse the cached module layer.
- Hit
/healthon thecontainers.snapdeploy.appURL, then/api/time.
When Go still needs more than the free tier
- It has to answer at any hour. Free containers sleep after 15 minutes without traffic. Always-On Small is $12/month for the same 512 MB; a Go service rarely needs the 2 GB Medium.
- It needs a database that persists. The container filesystem is ephemeral, so the SQLite trick above is for caches and read-only data. PostgreSQL Mini is $29/month; a $1 DB Sprint Pack gives you 12 hours of it to test against.
- It needs a custom domain. Always-On again; free containers live on
containers.snapdeploy.app.
Adding a database without adding bloat
The one dependency most services need is a database driver. With pgx the binary grows by a few megabytes and the service still idles well under 30 MB. Read the connection string from DATABASE_URL, which a SnapDeploy add-on injects and every other provider gives you to paste, and keep the pool small: a free container will never need more than a handful of connections, and a PostgreSQL Mini allows 50 in total.
cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
cfg.MaxConns = 4
pool, err := pgxpool.NewWithConfig(ctx, cfg)Make the health handler run pool.Ping(ctx) with a one-second timeout. When the container wakes after sleeping, the first request will re-establish the pool; a ping with a timeout turns a hung database into a clear 503 instead of a slow page.
Static files inside the binary
If the service also serves a small front end, the embed package puts the files into the executable, so the image stays one binary and there is nothing to copy or mount:
//go:embed static/*
var static embed.FS
mux.Handle("GET /", http.FileServerFS(static))Go 1.22 added http.FileServerFS; on older versions use http.FileServer(http.FS(static)). Either way the files are read from memory, which is the fastest static file server you will run on a quarter of a CPU.
What sleep looks like in the logs
Because the service handles SIGTERM, a container going to sleep after 15 quiet minutes leaves a clean trail: shutting down, then the process exits with code 0. A service that ignores the signal is killed after the platform's stop timeout and exits with code 137, which looks like a crash in the logs and drops any request that was in flight. Ten lines of shutdown code are the difference between "sleeps politely" and "keeps dying every night" when you read the runtime log a week later.
Free tier: 4 containers, 512 MB and 0.25 vCPU each, 100 container-hours a month, 10 deploys a day, no card. Details on the free tier docs page; the Docker hosting page covers what happens when you bring your own image instead of a repository.