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 dependenciesGOOS=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
- Google Cloud Run Go Quickstart
- AWS ECS Developer Guide
- Distroless Containers
- Go Docker Best Practices
Comments