Skip to main content
โšก Calmops

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

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.

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