You wrote a small Go web service and now you want it online. Go makes this easier than almost any other language: it compiles to one file that needs nothing else to run. So your Docker image doesn't need an operating system, a package manager or a shell, only your binary. This guide shows how to deploy a Go app with Docker: build that tiny image with a two-stage Dockerfile, put it online with an HTTPS address, and avoid the seven errors that break most first attempts.
What you'll build
- A Go HTTP server with a
/healthzendpoint, a built-in health check command and graceful shutdown. - A two-stage Dockerfile: stage 1 compiles with the full Go toolchain, stage 2 copies only the binary into a distroless image that runs as a non-root user.
- A live deployment on Hubfly space with an HTTPS address.
The compiled server is about 6 MB. While idle, the Metrics tab shows about 1 MB of memory. On the free Shared tier (0.3 vCPU, 256 MB RAM) that leaves plenty of room.
You need Go 1.22 or newer installed locally (this guide uses Go 1.27.3), Docker if you want to test the image locally, and your code in a Git repository.
Step 1: Write a server that's ready for containers
Create a folder called shop-api and add main.go:
package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" ) func main() { port := os.Getenv("PORT") if port == "" { port = "8080" } // "app healthcheck" lets a distroless image check itself (it has no curl). if len(os.Args) > 1 && os.Args[1] == "healthcheck" { c := http.Client{Timeout: 2 * time.Second} resp, err := c.Get("http://127.0.0.1:" + port + "/healthz") if err != nil || resp.StatusCode != http.StatusOK { os.Exit(1) } os.Exit(0) } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello from Go! Server time: %s\n", time.Now().UTC().Format(time.RFC3339)) }) srv := &http.Server{ Addr: "0.0.0.0:" + port, // not localhost: the platform's proxy must reach it Handler: mux, ReadHeaderTimeout: 5 * time.Second, } go func() { log.Printf("serving http://%s", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal(err) } }() // Finish in-flight requests when the platform stops or redeploys the container. stop, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() <-stop.Done() ctx, done := context.WithTimeout(context.Background(), 10*time.Second) defer done() log.Println("shutting down") srv.Shutdown(ctx) } Then create the module:
go mod init shop-api go run . Open http://localhost:8080 and you'll see the greeting. curl localhost:8080/healthz returns ok.
Why each part matters:
-
0.0.0.0, notlocalhost. Inside a container,localhostmeans "only this container". The platform's proxy connects from outside, so a server on127.0.0.1gives you a 502. More on that in 502 Bad Gateway in Docker? Bind to 0.0.0.0. -
PORTfrom the environment lets you change the port without rebuilding. - The
healthcheckargument matters because the final image has nocurlor shell. The binary checks itself instead. - Graceful shutdown means a redeploy doesn't cut off requests halfway through.
The GET /healthz style of route needs Go 1.22 or newer and a go line of 1.22 or higher in go.mod. go mod init writes your current version, so you're fine. See "What can go wrong" if you're updating an older project.
Step 2: Write the two-stage Dockerfile
Add a Dockerfile next to main.go:
# syntax=docker/dockerfile:1 # Stage 1: build a static binary FROM golang:1.27 AS build WORKDIR /src COPY go.* ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app . # Stage 2: copy only the binary into a tiny runtime image FROM gcr.io/distroless/static-debian13:nonroot COPY --from=build /out/app /app USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/app"] And a .dockerignore so local junk doesn't end up in the build:
.git *.md bin/ tmp/ What each line does:
-
COPY go.* ./thengo mod downloadcopiesgo.mod(andgo.sumif you have one) first. Docker caches the downloaded modules, so changing your code doesn't re-download them. Usego.*rather thango.mod go.sum: a module with no dependencies has nogo.sum, and some builders fail on a file that doesn't exist. -
CGO_ENABLED=0is the important one. It produces a fully static binary. Without it, Go links against the system C library, and the binary can't start in an image that doesn't have one. -
-trimpath -ldflags="-s -w"removes local file paths and debug symbols. In this example the binary goes from about 8.9 MB to about 6 MB. -
distroless/static-debian13:nonrootcontains CA certificates, time zone data and a non-root user, and almost nothing else. HTTPS calls to other APIs work, and there's no shell for an attacker to use.
If you have Docker locally, check it before deploying:
docker build -t shop-api . docker run --rm -p 8080:8080 shop-api Step 3: Deploy the Go app's Docker image on Hubfly space
Push the code (with the Dockerfile at the repository root) to GitHub, GitLab or any Git server, then:
- In the dashboard, click Create Project, name it, pick a region and click Create Project.
- Click Create → Create Container. Name the container
shop-apiand choose Git repository. - Connect the repository: GitHub App for your own repos, or Self-hosted Git with any public HTTPS URL (an access token is only needed for private repos). Pick the branch. If the app lives in a subfolder of a monorepo, put that folder in Repository directory.
- Leave Automatically detect the build on. A
Dockerfileat the root of the build folder is used automatically. (If you'd rather not commit one, untick it and paste the Dockerfile into Custom Dockerfile. The form then also asks for a run command; enter/app. It's ignored when a Dockerfile is used.) - Choose Shared Tier (free) or Dedicated Tier, click Continue through the remaining steps and click Start Build.
The build log shows each Dockerfile step. For this app the whole build, including pulling golang:1.27, compiling and deploying, took under 2 minutes. The compile step alone took about 26 seconds on the builder.
Why a build platform rather than pushing an image yourself: you skip the registry step entirely, and the build runs on linux/amd64 servers, so you won't accidentally ship an ARM image built on an Apple Silicon laptop.
Step 4: Give it an HTTPS address
- Open the container, go to Network and click Add Domain.
- Keep Auto Endpoint. Under Suggested ports the dashboard scans the running container. Your app shows up as 8080 · Open now. Click it.
- Leave TLS / SSL and Force HTTPS on and click Create Subdomain.
You get an address like https://shop-api.eu2.hubfly.app with a certificate already in place. Open /healthz and you should see ok.
If port 8080 doesn't appear under Suggested ports, your app isn't listening on 0.0.0.0. Fix that before going further.
To use your own domain later, see How to use your root domain as a custom domain.
Step 5: Check it's the build you expect
Go embeds build information in every binary. go version -m reads it from a file:
go build -o app . && go version -m app To see the same thing from the deployed app, add a temporary route (import runtime/debug):
mux.HandleFunc("GET /version", func(w http.ResponseWriter, r *http.Request) { bi, _ := debug.ReadBuildInfo() fmt.Fprint(w, bi) }) Remove it after checking: it exposes your dependency versions. The output looks like this:
Look for CGO_ENABLED=0, GOOS=linux and GOARCH=amd64. Then open the container's Logs tab:
The startup line is marked ERR. Nothing is wrong: Go's log package writes to stderr by default, and the log viewer labels stderr lines that way. If you'd rather see them as normal output, add log.SetOutput(os.Stdout) at the top of main.
The Metrics tab confirms how light the app is:
What it costs
On the Shared Tier the container costs nothing within the free 360 compute hours a month, and it sleeps after 15 minutes without traffic. An always-on container uses 730 hours a month, so the free hours suit services that sleep. Creating resources needs a $5 prepaid credit, which your usage is drawn from.
For an always-on service, a Dedicated container with 0.5 vCPU, 512 MB RAM and 1 GB of disk costs:
| Item | Math | Per month |
|---|---|---|
| CPU | 0.5 vCPU × $0.004 × 730 h | $1.46 |
| RAM | 0.5 GB × $0.002 × 730 h | $0.73 |
| Storage | 1 GB × $0.00001536 × 730 h | $0.01 |
| Total | $2.20 |
If you already run Kubernetes or another container platform, the same image runs there unchanged.
What can go wrong
COPY cache key: source "go.sum*" not found in build context
The Hubfly space builder doesn't accept a wildcard that matches no files. This happens with COPY go.mod go.sum* ./ when your module has no dependencies (so no go.sum). Use COPY go.* ./, which always matches go.mod.
exec /app: no such file or directory when the container starts
The file exists. What's missing is the C library it was linked against. You built without CGO_ENABLED=0 on the full golang image, which has a C compiler, so Go produced a dynamically linked binary. Run file app locally: it should say statically linked. Add CGO_ENABLED=0 to the build line. If you really need cgo (SQLite through mattn/go-sqlite3, for example), use gcr.io/distroless/base-debian13 as the runtime image instead.
exec format error
The image was built for ARM (an Apple Silicon Mac, for example) and the server runs x86. Build on the platform, as in step 3, or build locally with docker buildx build --platform linux/amd64.
502 Bad Gateway, or no port under Suggested ports
The server listens on localhost or 127.0.0.1. Bind to 0.0.0.0 as in step 1. Some example servers default to localhost:8080 and take an address flag; pass it in the Dockerfile, for example CMD ["-addr", "0.0.0.0:8080"].
Every route returns 404 page not found, even though the code looks right
Routes like "GET /healthz" need the Go 1.22 router. If your go.mod says go 1.21 or lower, Go keeps the old router for compatibility, and it treats "GET /healthz" as a literal path. Change the line to go 1.22 or higher (or run go mod edit -go=1.22) and rebuild.
Calls to other HTTPS APIs fail with x509: certificate signed by unknown authority
You're on FROM scratch, which has no CA certificates. Use distroless/static (it includes them), or copy /etc/ssl/certs/ca-certificates.crt from the build stage.
A health check like curl -f http://localhost/ always fails
Distroless has no curl and no shell, so CMD-SHELL checks can't run. Use the binary itself in exec form: in a Dockerfile, HEALTHCHECK --interval=30s --timeout=3s CMD ["/app", "healthcheck"]; in Compose, test: ["CMD", "/app", "healthcheck"]. The Docker health checks guide explains intervals and retries.
FAQ
How do I deploy a Go app with Docker?
Build it in a golang stage with CGO_ENABLED=0, copy the binary into a minimal runtime image (distroless/static or scratch), push the code to Git and let a build platform build and run it. Then add an HTTPS endpoint on the port the app listens on.
Should I use scratch or distroless for Go?
Distroless static for most apps. It's only slightly bigger than scratch but includes CA certificates, time zone data and a non-root user, which you'd otherwise copy in by hand. Pick scratch only if you want the absolute minimum and don't call HTTPS APIs or use time zones.
Why is my Go Docker image so big?
You're probably shipping the build stage. The golang image contains the whole toolchain. Use two stages and copy only the binary into the final one.
Do I need CGO_ENABLED=0?
For a static binary on scratch or distroless/static, yes. The golang:*-alpine images have no C compiler, so cgo is already off there, but setting it explicitly makes the Dockerfile safe on any base.
What does -ldflags="-s -w" do?
It strips the symbol table and debug information. The binary gets smaller (here from about 8.9 MB to 6 MB). Stack traces from panics still work.
How do I run database migrations before my Go app starts?
Run them as a separate one-off job that must finish before the app starts, rather than inside main. In Docker Compose that's a migrate service plus depends_on with condition: service_completed_successfully on your app.
Can I deploy a Go app for free?
On Hubfly space the Shared tier is free within 360 compute hours a month (you add $5 of prepaid credit before creating resources). It sleeps when idle and wakes on the next request, which suits side projects and staging.
Go further on Hubfly space
- Deploy on every push: trigger a new build from GitHub Actions after your tests pass. How to deploy on every push with GitHub Actions.
- Add a database: a managed PostgreSQL database for about $2 a month, reachable from your container over the private network.
- Release safely: run two versions behind a load balancer and send 10% of traffic to the new one first. Canary releases with a weighted load balancer.





