Blog
Nov 22, 2025 - 15 MIN READ
Building Production-Ready Go APIs in Scratch Containers

Building Production-Ready Go APIs in Scratch Containers

A practical guide to creating ultra-lightweight, secure Go APIs using Gin, GORM, and MariaDB/TiDB, compiled to static binaries and deployed in scratch containers for maximum efficiency.

Julian Morley

The appeal of Go for API development lies not just in its performance and simplicity, but in its ability to compile down to a single static binary. This characteristic enables a deployment pattern that many developers overlook: scratch containers. By building APIs with Gin and GORM against MariaDB or TiDB databases, then deploying the compiled binary to a scratch container, you achieve container images measuring mere megabytes instead of hundreds—without sacrificing functionality or security.

Why Scratch Containers Matter

When you build a typical Go application container, you often start with images like golang:alpine, which weighs around 300MB. The final image might include the Go toolchain, package managers, shell utilities, and various system libraries your application doesn't actually need at runtime. These extras increase your attack surface, slow down deployments, and consume unnecessary storage and bandwidth.

A scratch container contains nothing except your application binary and whatever runtime dependencies you explicitly add. For a statically compiled Go binary with no external dependencies, this means your entire API server can fit in a container image under 20MB. The benefits compound in production: faster deployments, reduced storage costs, minimal attack surface, and improved security posture.

The Stack: Gin, GORM, and MariaDB/TiDB

This stack balances developer productivity with production readiness. Gin provides a lightweight HTTP framework with excellent performance characteristics and an intuitive API for building RESTful services. GORM delivers a full-featured ORM that handles the tedious aspects of database interaction while maintaining enough flexibility for complex queries when needed.

For the database layer, both MariaDB and TiDB offer compelling options depending on your scaling requirements. MariaDB provides battle-tested reliability with excellent performance for traditional workloads. TiDB brings horizontal scalability and MySQL compatibility when you need distributed database capabilities without sacrificing familiar tooling and query patterns.

Building for Static Compilation

The key to successful scratch container deployment lies in producing a truly static binary with no external dependencies. Go makes this straightforward, but you need to be deliberate about your build flags and understand what "static" actually means in this context.

// main.go
package main

import (
    "log"
    "os"
    
    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

type User struct {
    ID        uint   `json:"id" gorm:"primaryKey"`
    Name      string `json:"name"`
    Email     string `json:"email" gorm:"uniqueIndex"`
    CreatedAt int64  `json:"created_at" gorm:"autoCreateTime"`
}

func main() {
    // Database connection
    dsn := os.Getenv("DATABASE_URL")
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }
    
    // Auto-migrate schema
    db.AutoMigrate(&User{})
    
    // Initialize Gin
    r := gin.Default()
    
    // Health check endpoint
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "healthy"})
    })
    
    // User endpoints
    r.GET("/users", func(c *gin.Context) {
        var users []User
        result := db.Find(&users)
        if result.Error != nil {
            c.JSON(500, gin.H{"error": result.Error.Error()})
            return
        }
        c.JSON(200, users)
    })
    
    r.POST("/users", func(c *gin.Context) {
        var user User
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        
        result := db.Create(&user)
        if result.Error != nil {
            c.JSON(500, gin.H{"error": result.Error.Error()})
            return
        }
        
        c.JSON(201, user)
    })
    
    r.GET("/users/:id", func(c *gin.Context) {
        var user User
        if err := db.First(&user, c.Param("id")).Error; err != nil {
            c.JSON(404, gin.H{"error": "User not found"})
            return
        }
        c.JSON(200, user)
    })
    
    // Start server
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    if err := r.Run(":" + port); err != nil {
        log.Fatal("Failed to start server:", err)
    }
}

The magic happens in how you compile this application. Standard Go compilation produces a binary that dynamically links to system libraries, particularly libc. For a scratch container to work, you need a completely static binary:

# Multi-stage build for Go API
FROM golang:1.21-alpine AS builder

# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /build

# Copy dependency files
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Build static binary
# CGO_ENABLED=0: Disable CGO to avoid dynamic linking
# -ldflags="-s -w": Strip debug info and symbol tables to reduce size
# -a: Force rebuilding of packages
# -installsuffix cgo: Use different install suffix when CGO is disabled
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-s -w" \
    -a -installsuffix cgo \
    -o api .

# Final stage: scratch container
FROM scratch

# Copy CA certificates for HTTPS requests
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copy timezone data if needed
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo

# Copy the binary
COPY --from=builder /build/api /api

# Expose port
EXPOSE 8080

# Run the binary
ENTRYPOINT ["/api"]

This Dockerfile implements a multi-stage build pattern that separates compilation from runtime. The builder stage uses golang:1.21-alpine to compile your application with all necessary build tools. The critical build flags deserve attention:

CGO_ENABLED=0 disables CGO, which prevents the compiler from linking to C libraries. This is essential for scratch containers because those C libraries won't exist at runtime. Some Go packages require CGO, but the standard library and most common packages including Gin and GORM work perfectly without it.

The -ldflags="-s -w" flags strip debugging information and symbol tables from the binary. This significantly reduces binary size—often by 30-50%—with no runtime performance impact. In production, you typically don't need this information in the binary itself; you can maintain separate debug symbols if needed.

Handling Database Drivers

The pure Go MySQL driver that GORM uses by default works perfectly in scratch containers because it requires no CGO. This is one reason MariaDB and TiDB work so well in this pattern—they both speak the MySQL protocol, and the Go driver is entirely self-contained.

Your go.mod file should include:

module github.com/yourusername/api

go 1.21

require (
    github.com/gin-gonic/gin v1.9.1
    gorm.io/driver/mysql v1.5.2
    gorm.io/gorm v1.25.5
)

The MySQL driver in GORM's ecosystem is pure Go, which means it compiles to your static binary without external dependencies. This contrasts with some other database drivers that require CGO and platform-specific libraries.

Configuration and Secrets Management

Scratch containers have no shell, no environment, and no utilities. This means you can't debug by exec-ing into the container—there's nothing to exec. Your application must be self-sufficient and handle all configuration through environment variables or configuration files.

For database connection strings, never hardcode credentials. Use environment variables:

// Database configuration
type Config struct {
    DatabaseURL string
    Port        string
    Environment string
}

func LoadConfig() Config {
    return Config{
        DatabaseURL: getEnv("DATABASE_URL", ""),
        Port:        getEnv("PORT", "8080"),
        Environment: getEnv("ENVIRONMENT", "production"),
    }
}

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

In Kubernetes or Docker Compose, inject these environment variables from secrets:

# docker-compose.yml
version: '3.8'

services:
  api:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=user:password@tcp(mariadb:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
      - PORT=8080
      - ENVIRONMENT=production
    depends_on:
      - mariadb
    restart: unless-stopped

  mariadb:
    image: mariadb:11
    environment:
      - MYSQL_ROOT_PASSWORD=rootpassword
      - MYSQL_DATABASE=dbname
      - MYSQL_USER=user
      - MYSQL_PASSWORD=password
    volumes:
      - mariadb_data:/var/lib/mysql
    restart: unless-stopped

volumes:
  mariadb_data:

For Kubernetes deployments, use Secrets and ConfigMaps:

apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
type: Opaque
stringData:
  database-url: "user:password@tcp(mariadb-service:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: go-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: go-api
  template:
    metadata:
      labels:
        app: go-api
    spec:
      containers:
      - name: api
        image: your-registry/go-api:latest
        ports:
        - containerPort: 8080
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: api-secrets
              key: database-url
        - name: PORT
          value: "8080"
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "200m"

TiDB Considerations

If you're using TiDB instead of MariaDB, the connection process remains identical because TiDB maintains MySQL protocol compatibility. However, TiDB offers additional features you might want to leverage:

// TiDB-specific optimizations
func setupTiDB(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        // TiDB handles connection pooling efficiently
        PrepareStmt: true,
    })
    if err != nil {
        return nil, err
    }
    
    sqlDB, err := db.DB()
    if err != nil {
        return nil, err
    }
    
    // TiDB can handle more connections than traditional MySQL
    sqlDB.SetMaxIdleConns(20)
    sqlDB.SetMaxOpenConns(100)
    sqlDB.SetConnMaxLifetime(time.Hour)
    
    return db, nil
}

TiDB's distributed architecture means you can scale horizontally by adding TiKV nodes rather than vertically scaling a single database server. Your Go API doesn't need to change—TiDB handles the distribution transparently.

Observability in Scratch Containers

Without a shell or debugging tools, observability becomes critical. Your application must expose metrics and logs properly:

import (
    "github.com/gin-gonic/gin"
    "log"
    "os"
)

func setupLogging() {
    // Use structured JSON logging for production
    gin.SetMode(gin.ReleaseMode)
    log.SetOutput(os.Stdout)
    log.SetFlags(log.LstdFlags | log.Lshortfile)
}

func prometheusMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start)
        
        // Log request details
        log.Printf(`{"method":"%s","path":"%s","status":%d,"duration_ms":%d}`,
            c.Request.Method,
            c.Request.URL.Path,
            c.Writer.Status(),
            duration.Milliseconds(),
        )
    }
}

For production systems, consider adding Prometheus metrics:

import (
    "github.com/gin-gonic/gin"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "endpoint", "status"},
    )
    
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "Duration of HTTP requests in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "endpoint"},
    )
)

func init() {
    prometheus.MustRegister(httpRequestsTotal)
    prometheus.MustRegister(httpRequestDuration)
}

func metricsMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start).Seconds()
        
        httpRequestsTotal.WithLabelValues(
            c.Request.Method,
            c.FullPath(),
            fmt.Sprintf("%d", c.Writer.Status()),
        ).Inc()
        
        httpRequestDuration.WithLabelValues(
            c.Request.Method,
            c.FullPath(),
        ).Observe(duration)
    }
}

func main() {
    r := gin.Default()
    r.Use(metricsMiddleware())
    
    // Metrics endpoint
    r.GET("/metrics", gin.WrapH(promhttp.Handler()))
    
    // ... rest of your routes
}

Security Considerations

Scratch containers provide security through minimalism. With no shell, no package manager, and no system utilities, the attack surface shrinks dramatically. An attacker who gains code execution inside your container has nothing to work with—no tools to escalate privileges, no utilities to download malware, no interpreters to run scripts.

However, you still need to follow security best practices:

  1. Run as non-root user: Even in scratch containers, avoid running as root:
FROM scratch

# Copy passwd file with non-root user
COPY --from=builder /etc/passwd /etc/passwd

# Copy the binary
COPY --from=builder /build/api /api

# Use non-root user
USER nobody:nobody

ENTRYPOINT ["/api"]

To create the passwd file in the builder stage:

FROM golang:1.21-alpine AS builder

# Create non-root user
RUN echo "nobody:x:65534:65534:Nobody:/:" > /etc/passwd

# ... rest of build steps
  1. Keep dependencies updated: Regularly update your Go dependencies and rebuild containers to patch vulnerabilities:
go get -u ./...
go mod tidy
  1. Scan images: Use tools like Trivy or Grype to scan for vulnerabilities:
docker build -t go-api:latest .
trivy image go-api:latest
  1. Sign images: Use Docker Content Trust or Sigstore to sign your images and verify integrity.

Performance Characteristics

Scratch containers offer tangible performance benefits beyond just size. The smaller image means faster deployment times—critical when scaling horizontally or recovering from failures. With no unnecessary processes or services running, your container consumes minimal memory and CPU when idle.

In production, a well-optimized Go API in a scratch container typically uses 10-30MB of memory at idle and scales efficiently under load. The static binary starts instantly—no interpreter warmup, no dependency resolution, just immediate readiness to serve requests.

Compare this to equivalent APIs built with Node.js or Python, which often require 100-200MB just for the runtime before your application code loads. The difference compounds when you're running dozens or hundreds of instances across a cluster.

Debugging Without a Shell

The biggest challenge with scratch containers is debugging. You can't exec into the container to poke around. This forces you to build robust logging and instrumentation into your application from the start—which turns out to be good practice regardless.

For development, maintain a separate Dockerfile that includes debug tools:

# Dockerfile.debug
FROM golang:1.21-alpine

RUN apk add --no-cache curl jq

COPY --from=builder /build/api /api

ENTRYPOINT ["/api"]

Use this debug image in development and testing environments where you might need to troubleshoot, but deploy the scratch container to production.

Real-World Example: Complete API

Here's a complete example showing a production-ready API with proper error handling, middleware, and database interaction:

package main

import (
    "fmt"
    "log"
    "os"
    "time"
    
    "github.com/gin-gonic/gin"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

type Config struct {
    DatabaseURL string
    Port        string
    Environment string
}

type User struct {
    ID        uint      `json:"id" gorm:"primaryKey"`
    Name      string    `json:"name" binding:"required"`
    Email     string    `json:"email" binding:"required,email" gorm:"uniqueIndex"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

func loadConfig() Config {
    return Config{
        DatabaseURL: getEnv("DATABASE_URL", ""),
        Port:        getEnv("PORT", "8080"),
        Environment: getEnv("ENVIRONMENT", "production"),
    }
}

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

func setupDatabase(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Silent),
        PrepareStmt: true,
    })
    if err != nil {
        return nil, fmt.Errorf("failed to connect to database: %w", err)
    }
    
    sqlDB, err := db.DB()
    if err != nil {
        return nil, fmt.Errorf("failed to get database instance: %w", err)
    }
    
    sqlDB.SetMaxIdleConns(10)
    sqlDB.SetMaxOpenConns(100)
    sqlDB.SetConnMaxLifetime(time.Hour)
    
    if err := db.AutoMigrate(&User{}); err != nil {
        return nil, fmt.Errorf("failed to migrate database: %w", err)
    }
    
    return db, nil
}

func setupRouter(db *gorm.DB) *gin.Engine {
    gin.SetMode(gin.ReleaseMode)
    r := gin.New()
    r.Use(gin.Recovery())
    r.Use(loggingMiddleware())
    
    r.GET("/health", healthCheck(db))
    r.GET("/ready", readinessCheck(db))
    
    api := r.Group("/api/v1")
    {
        api.GET("/users", getUsers(db))
        api.POST("/users", createUser(db))
        api.GET("/users/:id", getUser(db))
        api.PUT("/users/:id", updateUser(db))
        api.DELETE("/users/:id", deleteUser(db))
    }
    
    return r
}

func loggingMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        start := time.Now()
        c.Next()
        duration := time.Since(start)
        
        log.Printf(`{"method":"%s","path":"%s","status":%d,"duration_ms":%d,"ip":"%s"}`,
            c.Request.Method,
            c.Request.URL.Path,
            c.Writer.Status(),
            duration.Milliseconds(),
            c.ClientIP(),
        )
    }
}

func healthCheck(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "healthy"})
    }
}

func readinessCheck(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        sqlDB, err := db.DB()
        if err != nil {
            c.JSON(503, gin.H{"status": "not ready", "error": "database unavailable"})
            return
        }
        
        if err := sqlDB.Ping(); err != nil {
            c.JSON(503, gin.H{"status": "not ready", "error": "database ping failed"})
            return
        }
        
        c.JSON(200, gin.H{"status": "ready"})
    }
}

func getUsers(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        var users []User
        if err := db.Find(&users).Error; err != nil {
            c.JSON(500, gin.H{"error": "Failed to fetch users"})
            return
        }
        c.JSON(200, users)
    }
}

func createUser(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        var user User
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        
        if err := db.Create(&user).Error; err != nil {
            c.JSON(500, gin.H{"error": "Failed to create user"})
            return
        }
        
        c.JSON(201, user)
    }
}

func getUser(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        var user User
        if err := db.First(&user, c.Param("id")).Error; err != nil {
            c.JSON(404, gin.H{"error": "User not found"})
            return
        }
        c.JSON(200, user)
    }
}

func updateUser(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        var user User
        if err := db.First(&user, c.Param("id")).Error; err != nil {
            c.JSON(404, gin.H{"error": "User not found"})
            return
        }
        
        if err := c.ShouldBindJSON(&user); err != nil {
            c.JSON(400, gin.H{"error": err.Error()})
            return
        }
        
        if err := db.Save(&user).Error; err != nil {
            c.JSON(500, gin.H{"error": "Failed to update user"})
            return
        }
        
        c.JSON(200, user)
    }
}

func deleteUser(db *gorm.DB) gin.HandlerFunc {
    return func(c *gin.Context) {
        if err := db.Delete(&User{}, c.Param("id")).Error; err != nil {
            c.JSON(500, gin.H{"error": "Failed to delete user"})
            return
        }
        c.JSON(204, nil)
    }
}

func main() {
    config := loadConfig()
    
    if config.DatabaseURL == "" {
        log.Fatal("DATABASE_URL environment variable is required")
    }
    
    db, err := setupDatabase(config.DatabaseURL)
    if err != nil {
        log.Fatal("Failed to setup database:", err)
    }
    
    router := setupRouter(db)
    
    log.Printf("Starting server on port %s in %s mode", config.Port, config.Environment)
    
    if err := router.Run(":" + config.Port); err != nil {
        log.Fatal("Failed to start server:", err)
    }
}

Conclusion

Building Go APIs with Gin and GORM, then deploying them in scratch containers, represents a pragmatic approach to modern API development. You get the performance and simplicity of Go, the productivity of battle-tested frameworks, the flexibility to choose between MariaDB and TiDB based on your scaling needs, and deployment artifacts that are secure, minimal, and lightning-fast to deploy.

The pattern requires more upfront consideration than throwing together a typical containerized application, but the benefits manifest immediately in production. Your containers start faster, consume less memory, deploy quicker, and present a minimal attack surface. The discipline of building for scratch containers forces you to think carefully about dependencies, configuration, and observability—making your application more robust regardless of how you ultimately deploy it.

For teams building APIs at scale, particularly in Kubernetes environments where rapid scaling and efficient resource utilization matter, this approach delivers measurable improvements in deployment speed, operational costs, and security posture. The initial investment in properly structuring your build process and application configuration pays dividends every time you deploy.

References

Docker. (2025). Best practices for writing Dockerfiles. Docker Documentation. https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

Gin Web Framework. (2025). Gin documentation. https://gin-gonic.com/docs/

GORM. (2025). The fantastic ORM library for Golang. GORM Documentation. https://gorm.io/docs/

MariaDB Foundation. (2025). MariaDB server documentation. https://mariadb.org/documentation/

PingCAP. (2025). TiDB documentation. TiDB. https://docs.pingcap.com/tidb/stable

The Go Programming Language. (2025). Effective Go. Go Documentation. https://go.dev/doc/effective_go

Julian Morley • © 2025