Skip to main content

Deploying Go Applications to the Cloud: Docker, Cloud Run, and ECS

Created: March 7, 2026 6 min read

Introduction

Go’s small binary size and fast startup make it ideal for containerized cloud deployments. A Go binary in a scratch or distroless container is typically 10-20MB — much smaller than Node.js or Java equivalents. This guide covers the full deployment pipeline from Dockerfile to production. See Go Installation Guide, Go Ecosystem Overview, Go Best Practices for more context.

Dockerizing a Go Application

Optimized Multi-Stage Dockerfile

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Download dependencies first (cached layer)
COPY go.mod go.sum ./
RUN go mod download

# Build the binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-w -s" -o server ./cmd/server

# Production stage — minimal image
FROM gcr.io/distroless/static-debian12

WORKDIR /app

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

# Copy any static files if needed
# COPY --from=builder /app/static ./static

EXPOSE 8080

USER nonroot:nonroot

ENTRYPOINT ["/app/server"]

Why these flags:

  • CGO_ENABLED=0 — pure Go binary, no C dependencies
  • GOOS=linux GOARCH=amd64 — cross-compile for Linux
  • -ldflags="-w -s" — strip debug info (~30% smaller binary)
  • distroless/static — no shell, no package manager, minimal attack surface
# Build and test locally
docker build -t myapp:latest .
docker run -p 8080:8080 -e PORT=8080 myapp:latest

# Check image size
docker images myapp
# REPOSITORY   TAG       IMAGE ID       SIZE
# myapp        latest    abc123def456   18.2MB

Health Check Endpoint

Always implement a health check for container orchestrators:

// cmd/server/main.go
package main

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

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{
            "status": "ok",
            "time":   time.Now().UTC().Format(time.RFC3339),
        })
    })

    mux.HandleFunc("/api/users", handleUsers)

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    srv := &http.Server{
        Addr:         ":" + port,
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    if err := srv.ListenAndServe(); err != nil {
        panic(err)
    }
}

Google Cloud Run

Cloud Run is the simplest production deployment for Go — serverless containers that scale to zero:

# Authenticate
gcloud auth login
gcloud config set project YOUR_PROJECT_ID

# Build and push to Google Container Registry
gcloud builds submit --tag gcr.io/YOUR_PROJECT/myapp:latest

# Or use Artifact Registry (recommended)
gcloud artifacts repositories create myapp \
    --repository-format=docker \
    --location=us-central1

docker tag myapp:latest us-central1-docker.pkg.dev/YOUR_PROJECT/myapp/server:latest
docker push us-central1-docker.pkg.dev/YOUR_PROJECT/myapp/server:latest

# Deploy to Cloud Run
gcloud run deploy myapp \
    --image us-central1-docker.pkg.dev/YOUR_PROJECT/myapp/server:latest \
    --platform managed \
    --region us-central1 \
    --allow-unauthenticated \
    --port 8080 \
    --memory 256Mi \
    --cpu 1 \
    --min-instances 0 \
    --max-instances 10 \
    --set-env-vars "GIN_MODE=release,LOG_LEVEL=info"

# Set secrets from Secret Manager
gcloud run services update myapp \
    --set-secrets "DATABASE_URL=database-url:latest" \
    --region us-central1

# Get the service URL
gcloud run services describe myapp \
    --region us-central1 \
    --format 'value(status.url)'

Cloud Run with Cloud SQL (PostgreSQL)

# Create Cloud SQL instance
gcloud sql instances create mydb \
    --database-version POSTGRES_16 \
    --tier db-f1-micro \
    --region us-central1

# Create database and user
gcloud sql databases create myapp --instance mydb
gcloud sql users create appuser --instance mydb --password secret

# Connect Cloud Run to Cloud SQL via Unix socket
gcloud run services update myapp \
    --add-cloudsql-instances YOUR_PROJECT:us-central1:mydb \
    --set-env-vars "DB_HOST=/cloudsql/YOUR_PROJECT:us-central1:mydb,DB_USER=appuser,DB_NAME=myapp" \
    --set-secrets "DB_PASSWORD=db-password:latest" \
    --region us-central1
// Connect to Cloud SQL via Unix socket
import (
    "database/sql"
    "fmt"
    "os"
    _ "github.com/lib/pq"
)

func connectDB() (*sql.DB, error) {
    host := os.Getenv("DB_HOST")  // /cloudsql/project:region:instance
    user := os.Getenv("DB_USER")
    pass := os.Getenv("DB_PASSWORD")
    name := os.Getenv("DB_NAME")

    dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=disable",
        host, user, pass, name)

    return sql.Open("postgres", dsn)
}

AWS ECS Fargate

# Create ECR repository
aws ecr create-repository --repository-name myapp --region us-east-1

# Login and push
aws ecr get-login-password --region us-east-1 | \
    docker login --username AWS --password-stdin \
    123456789.dkr.ecr.us-east-1.amazonaws.com

docker tag myapp:latest 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
docker push 123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest
// task-definition.json
{
  "family": "myapp",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512",
  "executionRoleArn": "arn:aws:iam::123456789:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "myapp",
      "image": "123456789.dkr.ecr.us-east-1.amazonaws.com/myapp:latest",
      "portMappings": [{"containerPort": 8080, "protocol": "tcp"}],
      "environment": [
        {"name": "GIN_MODE", "value": "release"},
        {"name": "PORT", "value": "8080"}
      ],
      "secrets": [
        {"name": "DATABASE_URL", "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789:secret:myapp/database-url"}
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "wget -qO- http://localhost:8080/health || exit 1"],
        "interval": 30,
        "timeout": 5,
        "retries": 3,
        "startPeriod": 10
      },
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/myapp",
          "awslogs-region": "us-east-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}
# Register task definition
aws ecs register-task-definition --cli-input-json file://task-definition.json

# Create service
aws ecs create-service \
    --cluster myapp-cluster \
    --service-name myapp \
    --task-definition myapp:1 \
    --desired-count 2 \
    --launch-type FARGATE \
    --network-configuration "awsvpcConfiguration={subnets=[subnet-xxx,subnet-yyy],securityGroups=[sg-xxx],assignPublicIp=ENABLED}"

# Update service (deploy new version)
aws ecs update-service \
    --cluster myapp-cluster \
    --service myapp \
    --task-definition myapp:2

Graceful Shutdown

Always implement graceful shutdown to avoid dropping requests during deployments:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    srv := &http.Server{
        Addr:    ":" + getPort(),
        Handler: setupRoutes(),
    }

    // Start server in goroutine
    go func() {
        log.Printf("Starting server on %s", srv.Addr)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("Server error: %v", err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down server...")

    // Give in-flight requests 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server stopped")
}

func getPort() string {
    if port := os.Getenv("PORT"); port != "" {
        return port
    }
    return "8080"
}

CI/CD with GitHub Actions

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

env:
  PROJECT_ID: your-gcp-project
  SERVICE: myapp
  REGION: us-central1

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - run: go test ./...
      - run: go vet ./...

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_CREDENTIALS }}

      - uses: google-github-actions/setup-gcloud@v2

      - name: Build and push
        run: |
          gcloud builds submit \
            --tag gcr.io/$PROJECT_ID/$SERVICE:$GITHUB_SHA

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy $SERVICE \
            --image gcr.io/$PROJECT_ID/$SERVICE:$GITHUB_SHA \
            --platform managed \
            --region $REGION \
            --allow-unauthenticated

Environment Configuration

// config/config.go — load config from environment
package config

import (
    "fmt"
    "os"
    "strconv"
)

type Config struct {
    Port        string
    DatabaseURL string
    LogLevel    string
    MaxConns    int
}

func Load() (*Config, error) {
    maxConns, err := strconv.Atoi(getEnv("DB_MAX_CONNS", "10"))
    if err != nil {
        return nil, fmt.Errorf("invalid DB_MAX_CONNS: %w", err)
    }

    return &Config{
        Port:        getEnv("PORT", "8080"),
        DatabaseURL: requireEnv("DATABASE_URL"),
        LogLevel:    getEnv("LOG_LEVEL", "info"),
        MaxConns:    maxConns,
    }, nil
}

func getEnv(key, defaultVal string) string {
    if val := os.Getenv(key); val != "" {
        return val
    }
    return defaultVal
}

func requireEnv(key string) string {
    val := os.Getenv(key)
    if val == "" {
        panic(fmt.Sprintf("required environment variable %s is not set", key))
    }
    return val
}

Resources

Comments

Share this article

Scan to read on mobile