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.
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.
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.
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.
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.
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"
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.
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
}
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:
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
go get -u ./...
go mod tidy
docker build -t go-api:latest .
trivy image go-api:latest
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.
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.
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)
}
}
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.
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
Continuous Integration: Building Enterprise-Grade CI Pipelines
A comprehensive guide to implementing robust Continuous Integration practices that balance speed with safety in enterprise environments.
Infrastructure as Code: The Terraform Supremacy and Its Challengers
A critical examination of Infrastructure as Code tools in 2025, with Terraform's dominance facing serious competition from Pulumi, CloudFormation, and emerging alternatives.