# How to deploy a Go app with Docker using a tiny image

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.

[**Try it on Hubfly space →**](https://www.hubfly.space/?ref=deploy-go-app-docker)

## What you'll build

- A Go HTTP server with a `/healthz` endpoint, 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`:

```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:

```bash
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`, not `localhost`.** Inside a container, `localhost` means "only this container". The platform's proxy connects from outside, so a server on `127.0.0.1` gives you a 502. More on that in [502 Bad Gateway in Docker? Bind to 0.0.0.0](https://blog.hubfly.space/docker-502-bad-gateway-bind-0-0-0-0?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker).
- **`PORT` from the environment** lets you change the port without rebuilding.
- **The `healthcheck` argument** matters because the final image has no `curl` or 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`:

```docker
# 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:

```txt
.git
*.md
bin/
tmp/
```

What each line does:

- **`COPY go.* ./` then `go mod download`** copies `go.mod` (and `go.sum` if you have one) first. Docker caches the downloaded modules, so changing your code doesn't re-download them. Use `go.*` rather than `go.mod go.sum`: a module with no dependencies has no `go.sum`, and some builders fail on a file that doesn't exist.
- **`CGO_ENABLED=0`** is 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:nonroot`** contains 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:

```bash
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:

1. In the dashboard, click **Create Project**, name it, pick a region and click **Create Project**.
2. Click **Create** → **Create Container**. Name the container `shop-api` and choose **Git repository**.
3. 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**.
4. Leave **Automatically detect the build** on. A `Dockerfile` at 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.)
5. Choose **Shared Tier** (free) or **Dedicated Tier**, click **Continue** through the remaining steps and click **Start Build**.

![The build pipeline: triggered, built and deployed](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/54a34188-aaab-4d61-aaf6-219ed0b480cd.png)

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

1. Open the container, go to **Network** and click **Add Domain**.
2. Keep **Auto Endpoint**. Under **Suggested ports** the dashboard scans the running container. Your app shows up as **8080 · Open now**. Click it.
3. Leave **TLS / SSL** and **Force HTTPS** on and click **Create Subdomain**.

![Add Domain finds port 8080 open in the running container](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/931236cc-77fa-4b5b-8fd6-6f9dfd38be68.png)

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](https://blog.hubfly.space/root-domain-custom-domain?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker).

## Step 5: Check it's the build you expect

Go embeds build information in every binary. `go version -m` reads it from a file:

```bash
go build -o app . && go version -m app
```

To see the same thing from the deployed app, add a temporary route (import `runtime/debug`):

```go
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:

![Build info from the running app: go1.27.3, CGO_ENABLED=0, linux/amd64](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/5cb8ff2b-2618-4cf7-b333-af28de42fa8e.png)

Look for `CGO_ENABLED=0`, `GOOS=linux` and `GOARCH=amd64`. Then open the container's **Logs** tab:

![Runtime log showing the server serving on 0.0.0.0:8080](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/2f3bf687-3df3-4478-8462-4f1517c129a6.png)

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:

![Metrics: about 1 MB of memory reported while idle](https://eager-gecko.eu1.hubfly.app/blog-images/blog/2026/11/66629813-aa58-44b0-b974-e0c4ebadaf21.png)

## 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](https://blog.hubfly.space/docker-health-checks-postgres-redis?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker) 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](https://blog.hubfly.space/deploy-on-push-github-actions-webhook?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker).
- **Add a database:** [a managed PostgreSQL database for about $2 a month](https://blog.hubfly.space/managed-postgresql-database-2-dollars-a-month?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker), 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](https://blog.hubfly.space/canary-release-weighted-load-balancer?utm_source=hashnode&utm_medium=referral&utm_campaign=deploy-go-app-docker).

[**Try it on Hubfly space →**](https://www.hubfly.space/?ref=deploy-go-app-docker)

