How do I package my Golang app for production? A Docker Guide.

Learn how to Dockerize a Go application with a Docker build, create a minimal container image, publish it, and deploy it on Hostly.

Share
How do I package my Golang app for production? A Docker Guide.
Photo by Brett Jordan / Unsplash

A Go application can be compiled into a standalone executable, which makes it particularly well suited to container deployment. The Go compiler is only required while building the application; the finished container can contain little more than the compiled binary itself.

In this tutorial, we will create a small HTTP service using only Go’s standard library. The service will read its greeting from an environment variable, return a JSON response, and listen on a configurable port.

We will then:

  1. Create a multi-stage Docker image.
  2. Test the image locally.
  3. Publish it to Docker Hub.
  4. Deploy it on Hostly.

No web framework or third-party Go dependency is required.

What you will need

Before starting, make sure you have:

  • Go installed
  • Docker Desktop or Docker Engine
  • A free Docker Hub account
  • A text editor or IDE
  • A terminal
  • AND of course, we recommend at least a free hostly.ninja account!

This tutorial uses Go 1.26.5, which is the latest stable Go release as of August 4, 2026. Go 1.27 is still under development at the time of writing.

1. Create the project

Create a new directory and move into it:

mkdir hello-go-api
cd hello-go-api

Initialize a Go module:

go mod init example.com/hello-go-api

The module path normally identifies where a Go project can be downloaded, such as its Git repository. For this self-contained example, example.com/hello-go-api is sufficient.

The command creates a go.mod file, which records the module name, supported Go version, and eventually any external dependencies used by the application.

At this point, the project contains:

hello-go-api/
└── go.mod

2. Create the HTTP service

Create a file named main.go:

package main

import (
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"time"
)

type greetingResponse struct {
	Message string `json:"message"`
}

func main() {
	port := environmentOrDefault("PORT", "3000")
	target := environmentOrDefault("GREETING_TARGET", "World")

	mux := http.NewServeMux()

	mux.HandleFunc("GET /", func(response http.ResponseWriter, request *http.Request) {
		response.Header().Set("Content-Type", "application/json")

		result := greetingResponse{
			Message: "Hello, " + target + "!",
		}

		if err := json.NewEncoder(response).Encode(result); err != nil {
			log.Printf("could not write response: %v", err)
		}
	})

	server := &http.Server{
		Addr:              ":" + port,
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      10 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	log.Printf("server listening on port %s", port)

	if err := server.ListenAndServe(); err != nil &&
		!errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

func environmentOrDefault(key string, fallback string) string {
	value := os.Getenv(key)

	if value == "" {
		return fallback
	}

	return value
}

The application exposes one GET endpoint at / and returns a JSON response:

{
  "message": "Hello, World!"
}

The greeting can be changed through the GREETING_TARGET environment variable. The port can similarly be changed through PORT, although it defaults to 3000.

Listening on all network interfaces

The server address is constructed like this:

Addr: ":" + port,

An address such as :3000 listens on port 3000 across the available network interfaces. This allows Docker and Hostly to send traffic to the application.

Binding only to localhost:3000 would make the process reachable only from inside the container itself.

Setting server timeouts

Instead of calling http.ListenAndServe directly, the example creates an http.Server and configures several timeouts:

server := &http.Server{
	Addr:              ":" + port,
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,
	ReadTimeout:       10 * time.Second,
	WriteTimeout:      10 * time.Second,
	IdleTimeout:       60 * time.Second,
}

These limits prevent connections from remaining open indefinitely while slowly sending headers, reading requests, or writing responses. Go’s net/http package exposes these settings through the http.Server type.

For a larger application, the exact timeout values should reflect its expected request sizes and response times. The values here are a reasonable starting point for a small JSON API.

3. Run the application locally

Start the application directly:

go run .

The log output should show:

server listening on port 3000

Open the following address in your browser:

http://localhost:3000

Alternatively, use curl:

curl http://localhost:3000

The response should be:

{
  "message": "Hello, World!"
}

Stop the service with Ctrl+C.

Test the environment variable

Start the application again with a different greeting target:

GREETING_TARGET=Go go run .

On Windows PowerShell, use:

$env:GREETING_TARGET = "Go"
go run .

Request the endpoint again:

curl http://localhost:3000

The response should now be:

{
  "message": "Hello, Go!"
}

The application code has not changed. Only its runtime configuration is different.

4. Add a .dockerignore file

Docker sends the project directory to the builder as its build context. Files that are not needed for the build should be excluded.

Create a file named .dockerignore:

.git/
.gitignore

.env
.env.*

*.log
coverage.out

hello-go-api

The final entry excludes a locally compiled binary named hello-go-api, should one exist.

Environment files are excluded because they may contain passwords, API keys, or other sensitive configuration. Environment-specific values should be supplied when the container runs rather than embedded in the image.

5. Create the Dockerfile

Create a file named Dockerfile without a file extension:

FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS build

ARG TARGETOS
ARG TARGETARCH

WORKDIR /src

RUN apk add --no-cache ca-certificates

COPY go.mod ./

RUN go mod download

COPY main.go ./

RUN CGO_ENABLED=0 \
    GOOS=$TARGETOS \
    GOARCH=$TARGETARCH \
    go build \
    -trimpath \
    -o /out/server \
    .

FROM scratch

COPY --from=build \
    /etc/ssl/certs/ca-certificates.crt \
    /etc/ssl/certs/ca-certificates.crt

COPY --from=build /out/server /server

USER 10001:10001

EXPOSE 3000

ENTRYPOINT ["/server"]

The project should now contain:

hello-go-api/
├── main.go
├── go.mod
├── .dockerignore
└── Dockerfile

Unlike the Node.js and Python examples, this Dockerfile contains two FROM instructions. Each one begins a separate build stage.

Docker calls this a multi-stage build. Files can be copied from one stage into another, while build tools and intermediate files are left out of the final image.

6. Understanding the build stage

The first stage compiles the application:

FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS build

The official Go image contains the Go compiler and related development tools. We use the Alpine variant because it keeps the builder comparatively small, although the builder’s size does not directly determine the size of the finished image. The entire stage will be discarded after the binary has been copied out of it.

The golang:1.26.5-alpine image is an official multi-platform image and is also published under aliases such as 1.26-alpine.

AS build gives the stage a name. Later instructions can refer to it using:

COPY --from=build

Build for the target platform

ARG TARGETOS
ARG TARGETARCH

Docker BuildKit supplies these values automatically. They describe the operating system and CPU architecture for which the final image is being built.

The builder itself uses:

--platform=$BUILDPLATFORM

This means the compiler runs using the architecture of the build machine, while Go cross-compiles the binary for the requested target architecture.

For an ordinary local build, the target will usually be Linux on AMD64 or ARM64.

Install trusted CA certificates

RUN apk add --no-cache ca-certificates

Our example service does not make outbound HTTPS requests, but many real applications do. Trusted certificate-authority information is required when a program connects to HTTPS APIs, databases, or other TLS-enabled services.

The certificate bundle will later be copied into the final image.

Copy the module file first

COPY go.mod ./

RUN go mod download

The module definition is copied before the source code so Docker can cache the dependency-download step.

This application uses only the Go standard library, so there are no external modules to download. A larger project will usually also contain a go.sum file. Its Dockerfile would typically copy both module files:

COPY go.mod go.sum ./

Docker can then reuse the downloaded dependencies whenever the application source changes without the module files changing.

Compile a static binary

RUN CGO_ENABLED=0 \
    GOOS=$TARGETOS \
    GOARCH=$TARGETARCH \
    go build \
    -trimpath \
    -o /out/server \
    .

go build compiles the application and writes the result to /out/server.

CGO_ENABLED=0 prevents the binary from depending on a C runtime library. For this standard-library application, that produces a self-contained executable that can run in an otherwise empty container image.

GOOS and GOARCH select the target operating system and architecture.

The -trimpath option removes local filesystem paths from the compiled output. This avoids embedding build-machine directory names in the binary and makes builds more reproducible.

7. Understanding the runtime stage

The second stage starts from scratch:

FROM scratch

scratch is an empty image. It contains no Linux distribution, shell, package manager, compiler, or debugging tools.

We only copy the files that the application needs:

COPY --from=build \
    /etc/ssl/certs/ca-certificates.crt \
    /etc/ssl/certs/ca-certificates.crt

COPY --from=build /out/server /server

The final image therefore contains:

  • The compiled Go application
  • Trusted CA certificates

The Go compiler, source code, module cache, Alpine package manager, and other build tools are not included.

Separating the build environment from the runtime environment reduces image size and removes tools that are unnecessary once the application is running. Docker specifically identifies multi-stage builds and minimal runtime images as ways to reduce the final image’s attack surface.

Run as a non-root user

USER 10001:10001

The container runs with numeric user and group ID 10001 rather than as root.

Because scratch has no /etc/passwd file, there are no named operating-system users. Docker can nevertheless start the process using a numeric identity.

The application does not need elevated permissions. Port 3000 is above the range of privileged ports, and the service does not need to modify operating-system files.

Document the port

EXPOSE 3000

EXPOSE documents the port on which the application normally listens.

It does not publish the port automatically. Local Docker runs still require a -p mapping, and Hostly must still be configured with container port 3000.

Start the binary

ENTRYPOINT ["/server"]

When a container starts, Docker runs the compiled application directly.

There is no shell script, Go command, framework launcher, or package manager between the container runtime and the server process.

8. Build the image

Build the image from the project directory:

docker build -t hello-go-api:1.0.0 .

The -t option gives the image a name and version tag:

hello-go-api:1.0.0

The final period tells Docker to use the current directory as the build context.

Docker will:

  1. Start the Go build stage.
  2. Download any declared dependencies.
  3. Compile the application.
  4. Start the empty runtime stage.
  5. Copy the binary and CA certificates into it.
  6. Save the finished image.

Confirm that the image exists:

docker image ls

Because the final image does not contain the Go SDK or an operating-system distribution, it should be considerably smaller than the builder image.

9. Run the image locally

Start a container from the image:

docker run \
  --rm \
  -p 3000:3000 \
  hello-go-api:1.0.0

The port mapping follows this structure:

HOST_PORT:CONTAINER_PORT

Traffic sent to port 3000 on your computer is forwarded to port 3000 inside the container.

Test it with:

curl http://localhost:3000

The response should be:

{
  "message": "Hello, World!"
}

Stop the container with Ctrl+C.

Supply the environment variable

Run the image again with GREETING_TARGET:

docker run \
  --rm \
  -p 3000:3000 \
  -e GREETING_TARGET=Container \
  hello-go-api:1.0.0

Request the endpoint:

curl http://localhost:3000

The response should now be:

{
  "message": "Hello, Container!"
}

The environment variable is provided when the container starts. It is not stored permanently in the image.

10. Publish the image to Docker Hub

Create a public Docker Hub repository named:

hello-go-api

Authenticate from your terminal:

docker login

Tag the local image with your Docker Hub username:

docker tag \
  hello-go-api:1.0.0 \
  YOUR_USERNAME/hello-go-api:1.0.0

Replace YOUR_USERNAME with your actual Docker Hub username.

Push the image:

docker push YOUR_USERNAME/hello-go-api:1.0.0

The docker push command uploads a tagged image to Docker Hub or another container registry. Authentication credentials are managed through docker login.

After the upload finishes, the complete image address is:

docker.io/YOUR_USERNAME/hello-go-api:1.0.0

Using an explicit version tag makes it possible to identify exactly which application version a deployment is running.

11. Publish a multi-platform image

A normal docker build produces an image for one CPU architecture. That may be AMD64 on a typical server or ARM64 on an Apple Silicon computer.

The Dockerfile is already prepared for Go cross-compilation through TARGETOS and TARGETARCH, so you can publish both variants with Docker Buildx:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t YOUR_USERNAME/hello-go-api:1.0.0 \
  --push \
  .

Docker publishes both variants under the same image name. When a platform pulls the image, it selects the variant matching its operating system and architecture.

Go is particularly convenient here because the compiler can produce binaries for other supported operating systems and architectures without requiring the final binary to be compiled through emulation.

12. Deploy the image on Hostly

CTA Image

Create a free workspace to follow this tutorial and deploy your new app!

Create an account and get started!

Create a new service in Hostly and provide:

  • Your desired hostly.ninja subdomain
  • Image URL: docker.io/YOUR_USERNAME/hello-go-api:1.0.0
  • Container port: 3000

Add the following environment variable:

GREETING_TARGET=Hostly

The application does not require secrets, volumes, or configuration files, so those sections can remain empty.

Click Deploy.

Hostly will pull the image, start the compiled Go service, route traffic to port 3000, and provide SSL for the selected subdomain.

Hostly checks whether it can establish a TCP connection to the configured port, so the application does not require a separate health-check endpoint.

Once the deployment is ready, open the assigned URL. The response should be:

{
  "message": "Hello, Hostly!"
}

We used a public Docker Hub repository to keep the tutorial simple. Hostly can also pull images from private repositories when the corresponding registry credentials are configured.

What you have built

You started with a small Go program and produced a portable container image that can run locally or on Hostly.

The Go-specific parts of the process were:

  1. Creating a Go module with go mod init.
  2. Building the HTTP service with the standard library.
  3. Compiling a static executable with CGO_ENABLED=0.
  4. Separating compilation and runtime using a multi-stage build.
  5. Leaving the Go compiler and source code out of the final image.
  6. Running the binary from an otherwise empty scratch image.
  7. Running the process with a non-root numeric user.
  8. Supplying environment-specific configuration when the container starts.
  9. Publishing architecture-specific binaries under one multi-platform image.

A larger Go application may use a router, database driver, migrations, templates, or additional modules, but its deployment contract remains straightforward: compile the service, package the binary, expose its port, and provide its runtime configuration.