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 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