How to Deploy Go to AWS ECS, GCP Cloud Run, or Azure Container Apps

Deploy Go to AWS ECS, GCP Cloud Run, or Azure Container Apps by building a Docker image, pushing it to a registry, and using platform-specific CLI commands to launch the service.

The container is the contract

You wrote a Go service. It works on localhost:8080. Now you need it on the internet. You have three major clouds: AWS ECS, GCP Cloud Run, and Azure Container Apps. They look different in the console. They have different pricing models. They have different management tools.

They all speak the same language: containers.

You don't need three different deployment scripts. You don't need to rewrite your code for each platform. You build one Docker image. You push it to a registry. You tell the cloud to pull and run that image. The container is the unit of deployment. The cloud is just the shelf.

Think of your Go binary as a recipe. The Docker image is the pre-packaged meal kit. AWS, GCP, and Azure are different restaurants. You don't cook the meal at each restaurant. You ship the meal kit. The restaurant just heats it up and serves it. The container is the standard box that fits on any shelf.

The smallest deployable unit

Here's the minimal setup: a Go binary, a multi-stage Dockerfile, and the push command. This code compiles to a single static binary. It runs in a container with no operating system overhead.

package main

import (
	"fmt"
	"net/http"
)

// main starts the HTTP server on port 8080.
func main() {
	// Register the root handler to respond to requests.
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Write a simple response to the client.
		fmt.Fprint(w, "Hello from Go")
	})

	// Listen on port 8080, which is the standard for containerized web services.
	http.ListenAndServe(":8080", nil)
}

Here's the Dockerfile that builds the binary and packages it into a minimal image. Multi-stage builds are the standard pattern. They keep the final image small by separating the build environment from the runtime environment.

# syntax=docker/dockerfile:1
FROM golang:1.22 AS builder

# Set the working directory inside the container.
WORKDIR /app

# Copy go.mod and go.sum first to leverage Docker layer caching.
COPY go.mod go.sum ./
RUN go mod download

# Copy the rest of the source code.
COPY . .

# Build the binary with CGO disabled for a static binary.
RUN CGO_ENABLED=0 go build -o server .

# Use a scratch image for the final stage to keep it tiny.
FROM scratch

# Copy the binary from the builder stage.
COPY --from=builder /app/server /server

# Expose port 8080 for documentation purposes.
EXPOSE 8080

# Run the binary.
ENTRYPOINT ["/server"]

Multi-stage builds work in two phases. The first stage uses a full Go image to compile the code. It downloads dependencies, caches them, and runs go build. The second stage starts from scratch, which is an empty image. It copies only the compiled binary from the first stage. The final image contains nothing but your binary. No shell. No package manager. No compiler.

The CGO_ENABLED=0 flag is essential. By default, Go can link C libraries if your code uses CGO. Disabling CGO forces a pure Go build. The result is a statically linked binary that carries all its dependencies inside itself. This is why scratch works. Other languages need a runtime or shared libraries. Go needs nothing.

Run gofmt on your code before building. The tool decides indentation and formatting. Most editors run it on save. Don't argue about formatting. Let the tool decide.

Production-ready service structure

Real services need more than a hello world. They need health checks. They need context propagation. They need explicit error handling. Here's a realistic handler that follows Go conventions.

// HandleRequest processes an incoming HTTP request with proper error handling.
func HandleRequest(w http.ResponseWriter, r *http.Request) {
	// Check the request context for cancellation or timeout.
	if err := r.Context().Err(); err != nil {
		http.Error(w, "Request cancelled", http.StatusServiceUnavailable)
		return
	}

	// Perform business logic here.
	result, err := doWork(r.Context())
	if err != nil {
		// Return a 500 error if the internal logic fails.
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Write the success response.
	w.WriteHeader(http.StatusOK)
	fmt.Fprint(w, result)
}

Error handling in Go is explicit. You see if err != nil everywhere. The community accepts this verbosity because it forces you to handle the unhappy path. Don't hide errors. Don't use panics for control flow. Return the error and let the caller decide.

Functions that perform work should accept context.Context as the first parameter. Name it ctx. Pass it through to database calls and HTTP requests. Respect cancellation. If the client disconnects, the context cancels. Your code should stop working immediately. Goroutine leaks happen when a goroutine waits on a channel that never closes. Always provide a cancellation path using context.

Context is plumbing. Run it through every long-lived call site.

Here's the helper function and a health check endpoint. Cloud providers poll the health check to determine if the container is ready. Without it, the cloud assumes the service is broken and kills it.

// doWork performs the core business logic using the provided context.
func doWork(ctx context.Context) (string, error) {
	// Select on the context to detect cancellation during work.
	select {
	case <-ctx.Done():
		return "", ctx.Err()
	default:
		// Simulate work and return a result.
		return "Done", nil
	}
}

// HealthCheck returns a 200 OK to signal the service is alive.
func HealthCheck(w http.ResponseWriter, r *http.Request) {
	// Cloud providers poll this endpoint to determine if the container is ready.
	w.WriteHeader(http.StatusOK)
}

If you define methods on types, name the receiver with one or two letters matching the type. Use (h *Handler) ServeHTTP, not (this *Handler) or (self *Handler). This is the community standard. It keeps code concise and readable.

Deploying to the clouds

Once the image is in the registry, each cloud provider has a single command to deploy it. The commands differ, but the input is always the same: an image reference.

Here's the sequence: build the image, push it, then deploy to the target platform. Each cloud command tells the platform to pull the image and run it.

# Build and tag the image for the registry.
docker build -t my-registry/my-app:v1 .

# Push the image so the cloud can pull it.
docker push my-registry/my-app:v1

# Deploy to AWS ECS using the task definition and service.
aws ecs create-service \
	--cluster my-cluster \
	--service-name my-service \
	--task-definition my-task-def \
	--desired-count 1

# Deploy to GCP Cloud Run with automatic scaling.
gcloud run deploy my-app \
	--image my-registry/my-app:v1 \
	--platform managed \
	--region us-central1

# Deploy to Azure Container Apps with the up command.
az containerapp up \
	--name my-app \
	--image my-registry/my-app:v1 \
	--resource-group my-rg

AWS ECS requires a task definition to describe the container. This JSON document maps the image to resources and ports. The task definition is the blueprint. The service is the running instance.

AWS ECS requires a task definition to describe the container. This JSON document maps the image to resources and ports.

{
	"family": "my-task-def",
	"networkMode": "awsvpc",
	"containerDefinitions": [
		{
			"name": "my-app",
			"image": "my-registry/my-app:v1",
			"portMappings": [
				{
					"containerPort": 8080,
					"hostPort": 8080
				}
			],
			"memory": 256,
			"cpu": 256
		}
	]
}

The family field names the task definition. The networkMode sets the networking model. awsvpc gives the container its own elastic network interface. The containerDefinitions array lists the containers. Each definition specifies the image, ports, memory, and CPU. The portMappings section maps the container port to the host port.

GCP Cloud Run is serverless. You don't manage servers. You deploy the image. Cloud Run scales to zero when there's no traffic. It scales up when requests arrive. You pay per request. The gcloud run deploy command creates a revision and routes traffic to it.

Azure Container Apps is also serverless. It supports event-driven scaling. You can integrate with Event Grid for triggers. The az containerapp up command creates the app and configures the revision. Container Apps supports Dapr for service-to-service communication out of the box.

Pitfalls and runtime errors

Deployment fails when assumptions break. The most common issues are build configuration, image tags, and health checks.

If you build without disabling CGO, the binary links to system libraries. When you run it in a minimal image, the runtime panics with error while loading shared libraries: libc.so.6: cannot open shared object file. Always use CGO_ENABLED=0 for container builds.

Never use the latest tag in production. Tags are mutable. If you push a new image with the same tag, the cloud might pull the new version unexpectedly. Use semantic versioning. Tag images with v1.0.0, v1.0.1. Pin your deployment to a specific tag.

If you forget the health check endpoint, the cloud provider marks the container as unhealthy. AWS ECS reports HEALTHY checks failing. GCP Cloud Run returns 502 Bad Gateway. Azure Container Apps reports Container is not healthy. Add a /health endpoint that returns 200 OK.

Don't bake secrets into the image. Secrets in the image are visible to anyone with access to the registry. Use environment variables or cloud secret managers. Inject secrets at runtime. The cloud provider passes them as environment variables or mounts them as volumes.

The compiler rejects the program with loop variable i captured by func literal if you capture loop variables in goroutines. This became a hard error in Go 1.22. Always assign the loop variable to a new variable inside the loop before passing it to a goroutine.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Use context.Context to signal shutdown. Close channels when the sender is done. Don't leave goroutines hanging.

Static binaries are the goal. Dynamic links are the enemy of portability.

When to use each platform

Use AWS ECS when you need fine-grained control over infrastructure, custom networking, or are already deep in the AWS ecosystem with Fargate or EC2 launch types.

Use GCP Cloud Run when you want serverless simplicity with automatic scaling to zero and pay-per-request billing without managing servers.

Use Azure Container Apps when you are building event-driven microservices on Azure and need built-in integration with Event Grid or Dapr for service-to-service communication.

Use a single Docker image across all three when you want portability. The container is the contract. The cloud is just the host.

Pick the cloud that matches your team's skills. The Go binary doesn't care.

Where to go next