Deploy Go Gin APIs with Docker, docker-compose, and Kubernetes. Covers multi-stage Dockerfiles, K8s (PDB, NetworkPolicy, HPA), Trivy image scanning, OpenTelemetry observability, graceful shutdown, CI/CD pipelines, and production configuration. Use when containerizing a Go API, setting up local dev with Docker, deploying to Kubernetes, or configuring CI/CD for a Gin application. Also activate when the user mentions Docker build, docker-compose, K8s deployment, health probes, environment variables, or 12-factor app config for a Go/Gin project.
40:T237e,
Package and deploy Gin APIs to production. This skill covers the essential deployment patterns: multi-stage Docker builds, local dev with docker-compose, and Kubernetes manifests with health checks.
Build a minimal production image from the cmd/api/main.go entry point (same structure as the golang-gin-api skill).
# syntax=docker/dockerfile:1
# ── Stage 1: Builder ──────────────────────────────────────────────────────────
FROM golang:1.24-bookworm AS builder
WORKDIR /build
# Cache dependencies before copying source
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build a statically linked binary; CGO_ENABLED=0 required for distroless
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build \
-ldflags="-s -w" \
-trimpath \
-o /app/server \
./cmd/api
# ── Stage 2: Runtime ─────────────────────────────────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
# distroless nonroot runs as UID 65532 — no root needed
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Why distroless: No shell, no package manager — drastically reduces attack surface. Final image is ~10 MB vs ~800 MB with golang base.
Critical: CGO_ENABLED=0 is mandatory for distroless/scratch — it produces a statically linked binary with no libc dependency.
Expose /health for container orchestrators. The endpoint from golang-gin-api is used directly by K8s probes.
// internal/handler/health_handler.go
package handler
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"log/slog"
)
type HealthHandler struct {
db DBPinger
logger *slog.Logger
}
// DBPinger is satisfied by *sql.DB and *sqlx.DB
type DBPinger interface {
PingContext(ctx context.Context) error
}
func NewHealthHandler(db DBPinger, logger *slog.Logger) *HealthHandler {
return &HealthHandler{db: db, logger: logger}
}
func (h *HealthHandler) Check(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
status := "ok"
httpStatus := http.StatusOK
if err := h.db.PingContext(ctx); err != nil {
h.logger.Error("health: db ping failed", "error", err)
status = "degraded"
httpStatus = http.StatusServiceUnavailable
}
c.JSON(httpStatus, gin.H{"status": status})
}
Register in main.go:
healthHandler := handler.NewHealthHandler(db, logger)
r.GET("/health", healthHandler.Check)
| Probe | Checks | On failure |
|---|---|---|
| Liveness | Is the process alive? | Restart container |
| Readiness | Can the app serve traffic? | Remove from load balancer (no restart) |
Use a single /health endpoint for both, or split:
/health/live — returns 200 if process is running (no DB check)/health/ready — returns 200 only when DB is reachableArchitectural recommendation: For most Gin APIs, one /health endpoint that pings the DB is sufficient. Split only when startup time causes false liveness failures.
Never hardcode values. Read all configuration from the environment.
// internal/config/config.go
package config
import (
"fmt"
"os"
"time"
)
type Config struct {
Port string
DatabaseURL string
MigrationsPath string
RedisURL string
JWTSecret string
ReadTimeout time.Duration
WriteTimeout time.Duration
ShutdownTimeout time.Duration
GinMode string
}
func Load() (*Config, error) {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
readTimeout := parseDuration(os.Getenv("READ_TIMEOUT"), 10*time.Second)
writeTimeout := parseDuration(os.Getenv("WRITE_TIMEOUT"), 10*time.Second)
shutdownTimeout := parseDuration(os.Getenv("SHUTDOWN_TIMEOUT"), 30*time.Second)
migrationsPath := os.Getenv("MIGRATIONS_PATH")
if migrationsPath == "" {
migrationsPath = "db/migrations"
}
return &Config{
Port: port,
DatabaseURL: dbURL,
MigrationsPath: migrationsPath,
RedisURL: os.Getenv("REDIS_URL"),
JWTSecret: jwtSecret,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
ShutdownTimeout: shutdownTimeout,
GinMode: os.Getenv("GIN_MODE"), // "release" in production
}, nil
}
func parseDuration(s string, fallback time.Duration) time.Duration {
if s == "" {
return fallback
}
d, err := time.ParseDuration(s)
if err != nil {
return fallback
}
return d
}
# Version control
.git
.gitignore
# Go build artifacts
*.test
*.out
coverage.html
# Local development
.env
.env.*
docker-compose*.yml
air.toml
# Documentation and plans
*.md
docs/
plans/
# CI
.github/
Why: Excluding .git and *.md keeps the Docker build context small. Excluding .env prevents secrets from leaking into the image.
# docker-compose.yml