Right, so you’ve built something brilliant in Go. It’s fast, it’s tested, and it works on your machine. Now we need to get it running somewhere that isn’t your machine, ideally without losing our minds. The good news is that Go’s static binary superpower makes this almost criminally easy compared to the dependency hell of other languages. The slightly-less-good news is that each platform has its own particular brand of kookiness. Let’s break it down.

The Almighty Static Binary

First, a quick victory lap for Go. When you run go build, it isn’t just compiling your code; it’s bundling up everything your code needs to run into a single, self-contained executable. No need for a system-wide installation of lib-whatever-the-hell version 12.3. This binary is your application. This is your deployable artifact. Cherish it.

This is why you can do something as gloriously simple as copying a binary into a bare-bones Linux container and it just runs. Let’s build one properly for a web server.

// main.go
package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello from the glorious static binary! Host: %s\n", os.Getenv("HOSTNAME"))
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080" // Default for Cloud Run and many others
    }
    fmt.Printf("Server starting on port %s\n", port)
    http.ListenAndServe(":"+port, nil)
}

Build it for your local machine: go build -o myapp .. Now build it for a Linux server, even if you’re on a Mac or Windows machine:

# The magic of cross-compilation. GOOS=linux GOARCH=amd64 tells the Go compiler to target Linux on x86-64.
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 .

Boom. You now have myapp-linux-amd64, a binary you can scp to any modern Linux cloud server, make executable (chmod +x), and run. This is the fundamental unit of deployment. Everything else is just packaging and orchestration.

The “Just Throw It in a Container” Method (Docker)

Even though you often don’t need much more than the binary, the world runs on containers. It’s the lingua franca of deployment. The Go Dockerfile is a thing of beauty because it can be so stupidly simple. We use a multi-stage build: one stage to build the binary, and a second, much smaller stage to run it. This keeps the final image tiny and secure.

# Dockerfile
# Stage 1: Build the binary
FROM golang:1.21-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /myapp

# Stage 2: Build the tiny runtime image
FROM gcr.io/distroless/static-debian12
# Or use Alpine: FROM alpine:latest
# But if you use Alpine, you might need to add CA certificates:
# RUN apk --no-cache add ca-certificates

COPY --from=builder /myapp /myapp
# Use the non-root user for security. This is a best practice the cool kids follow.
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/myapp"]

Why distroless? It’s a Google-made image that contains literally nothing but your app and its language runtime essentials (and in the case of a static Go binary, it doesn’t even need that). There’s no shell, no package manager, nothing for an attacker to leverage. It’s the ultimate “less is more” security play. Build it: docker build -t my-go-app ..

Deploying to Cloud Run

Google’s Cloud Run is a perfect match for Go. It’s a serverless platform that runs your container. You give it the Dockerfile, it runs the container, scales it to zero when not in use, and charges you per request. It’s fantastic for APIs, microservices, and anything that responds to HTTP requests.

The key thing to understand about Cloud Run is that your instance must start fast and listen on the port defined by the $PORT environment variable. Cloud Run will inject this variable automatically; your app must use it. Our code example above already does this. The platform will unceremoniously kill your instance if it doesn’t start listening on that port within a timeout.

Deploying is a one-liner with the GCloud CLI:

gcloud run deploy my-go-service \
  --source . \
  --region us-central1 \
  --allow-unauthenticated

This command will build your Dockerfile in the cloud and deploy it. It’s dead simple.

Deploying to Fly.io

Fly.io is like if you took a container platform and gave it a delightful developer experience and a global footprint. It’s fantastic for getting something running close to users worldwide without the complexity of AWS.

Fly uses a fly.toml configuration file. You can get started with fly launch, which will generate this file for you. The critical parts for Go are getting the build and the port right.

# fly.toml
app = "your-unique-app-name-here"

[[services]]
  internal_port = 8080 # The port YOUR app listens on (what we used $PORT for in Cloud Run)
  protocol = "tcp"

  [services.concurrency]
    hard_limit = 25
    soft_limit = 20

  [[services.ports]]
    handlers = ["http"]
    port = 80 # The external port Fly routes to

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443 # The external port Fly routes to

# This build section is a nifty Fly feature
# It lets you deploy without a Dockerfile by building your Go app on their servers.
[build]
  builder = "golang"
  build_args = { GOOS = "linux", GOARCH = "amd64" }

You can deploy with just fly deploy. If you have a Dockerfile, it’ll use that. If you don’t, it will use the [build] section to build your Go app directly. It’s incredibly flexible.

The Serverless Twist: AWS Lambda

Lambda is a different beast. It doesn’t run a long-lived HTTP server. Instead, it runs your function in response to events. To make our Go HTTP server work here, we need an adapter that translates the Lambda event (an API Gateway request) into an http.Request and then writes the handler’s response back. The aws-lambda-go library provides this magic.

First, the code:

// main.go for Lambda
package main

import (
    "context"
    "fmt"
    "net/http"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/awslabs/aws-lambda-go-api-proxy/gorillamux"
    // You could also use the simpler `chi` or `net/http` adapter
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello from Lambda! Requested path: %s", r.URL.Path)
}

func main() {
    // ... your router setup (using Gorilla Mux, Chi, etc.) would go here ...
    // For this example, let's assume you have a router `r`

    // This is the critical line: it starts the Lambda runtime and listens for events.
    lambda.Start(Handler)
}

// Handler is the function that Lambda will call
func Handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
    // The adapter does the heavy lifting of converting the Lambda event into an http.Request,
    // passing it to your router, and converting the http.ResponseWriter back to a Lambda response.
    adapter := gorillamux.NewV2(YourRouter)
    return adapter.ProxyWithContext(ctx, req)
}

The build is the same (GOOS=linux GOARCH=amd64 go build), but the packaging is different. You zip the binary and upload that zip file to Lambda. The Lambda runtime expects your handler to be named a specific way (in this case, the Handler function). The cold start performance of Go on Lambda is excellent, one of its biggest selling points.

The big gotcha? State. Your function can be frozen and thawed between invocations. Never assume anything persists in memory between calls. If you need a database connection, use a connection pool but be prepared to reconnect if the connection dies during a freeze.