Gin #

Gin is the most popular HTTP framework in the Go ecosystem. With a minimal API, high performance (thanks to httprouter), and a mature middleware ecosystem, Gin is the default choice for building REST APIs and microservices in Go. Docker Compose complements Gin by providing a consistent local environment — the application, database, and cache run in the same containers, so “works on my machine” no longer happens.

This article covers a complete Docker Compose setup for Gin local development, from multi-stage Dockerfiles to database, ORM, and hot reload integration. After reading it, you’ll have a template you can use directly for both new and existing Gin projects.

Prerequisites #

Make sure the host has installed:

  • Docker and Docker Compose (latest versions)
  • Go 1.22+ (optional, for host-side development without Docker)
  • Git for cloning repositories

A standard Gin project structure with the cmd/ + internal/ layout:

my-gin-app/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── handler/
│   │   └── user.go
│   ├── service/
│   │   └── user.go
│   ├── repository/
│   │   └── user.go
│   └── model/
│       └── user.go
├── go.mod
├── go.sum
├── Dockerfile
├── docker-compose.yml
├── .air.toml
├── .env
└── .dockerignore

The cmd/ layout for the entry point and internal/ for private code (not importable by other packages outside the project) is the Go idiom that keeps your library’s public API clean. For small services, a cmd/api structure alone is enough.

Multi-Stage Dockerfile #

For Gin, a multi-stage Dockerfile matters so the runtime image is as small as possible — no need to carry the Go toolchain, build caches, or temporary binaries. The first stage builds; the second stage only contains the final binary.

# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Install OS tools needed during the build
RUN apk add --no-cache git ca-certificates

# Copy dependency descriptors first for layer caching
COPY go.mod go.sum ./
RUN go mod download

# Copy source code and build a static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/api ./cmd/api

# Second stage: a slim runtime image
FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app

# Run as non-root
RUN addgroup -S app && adduser -S app -G app
USER app:app

COPY --from=builder /out/api /app/api

EXPOSE 8080

ENTRYPOINT ["/app/api"]

The multi-stage pattern separates the build environment (needs the Go toolchain and full source) from the runtime environment (just the binary). The final image based on alpine:3.19 is usually < 20 MB, while the golang:1.22-alpine build image can exceed 300 MB.

flowchart LR
    subgraph BUILD["Stage: builder (golang:1.22-alpine)"]
        SRC[Source code + go.mod] --> COMPILE[go build]
        COMPILE --> BIN1[Binary /out/api]
    end
    subgraph RUN["Stage: runtime (alpine:3.19)"]
        BIN1 --> COPY[COPY --from=builder]
        COPY --> IMG[Final image < 20 MB]
    end

For development, you don’t need such a small image — focus on build speed and hot reload. But separating dev and prod Dockerfiles is a good pattern.

A Development Dockerfile with Hot Reload #

Gin has no built-in hot reload, but Go has a popular tool called Air that watches file changes and automatically rebuilds + restarts. It’s transformative for Go DX.

# Dockerfile.dev
FROM golang:1.22-alpine

WORKDIR /app

RUN apk add --no-cache git curl bash

# Install Air for hot reload
RUN go install github.com/air-verse/air@latest

# Cache dependencies: copy the manifest first
COPY go.mod go.sum ./
RUN go mod download

# Source code is mounted via a volume, no COPY needed here
EXPOSE 8080

CMD ["air", "-c", ".air.toml"]

The Air configuration (.air.toml):

root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"

[build]
  args_bin = []
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ./cmd/api"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = true
  follow_symlink = false
  full_bin = ""
  include_dir = ["cmd", "internal"]
  include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"]
  kill_delay = "0s"
  log = "build-errors.log"
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  time = true

[misc]
  clean_on_exit = true

Air watches files with go, tpl, tmpl, html, yaml extensions in the cmd/ and internal/ directories. When a file changes, it waits 1 second, then rebuilds. The same pattern can be achieved with CompileDaemon or fresh, but Air is the most popular.

docker-compose.yml for Development #

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: gin-app:dev
    container_name: gin-api
    ports:
      - "8080:8080"
    volumes:
      - ./cmd:/app/cmd
      - ./internal:/app/internal
      - ./go.mod:/app/go.mod
      - ./go.sum:/app/go.sum
      - go-build:/app/tmp  # build binary cache
    environment:
      - APP_ENV=development
      - SERVER_PORT=8080
      - DATABASE_URL=postgres://app:pass@db:5432/ginapp?sslmode=disable
      - REDIS_URL=redis://cache:6379/0
      - JWT_SECRET=local-dev-secret-change-me
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    command: air -c .air.toml

  db:
    image: postgres:16-alpine
    container_name: gin-db
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=ginapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d ginapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: gin-cache
    command: redis-server --appendonly yes
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

  # Optional: adminer for UI database access
  adminer:
    image: adminer:4
    container_name: gin-adminer
    ports:
      - "8081:8080"
    depends_on:
      - db
    profiles: ["tools"]

volumes:
  db-data:
  cache-data:
  go-build:

Service Explanations #

api — the main Gin service. Uses Dockerfile.dev with Air. Source code is bind-mounted — file changes on the host appear in the container immediately. The go-build volume caches Air’s rebuilt binary for faster builds. The DATABASE_URL and REDIS_URL environment variables point to the db and cache services on Compose’s internal network.

db — PostgreSQL for persistent data. The pg_isready healthcheck keeps api from starting before the database is truly ready. The db/init folder can hold SQL scripts run when the container first starts.

cache — Redis for sessions, caching, and rate limiting. The --appendonly yes mode enables AOF persistence so data isn’t lost on restart.

adminer — optional (activated with docker compose --profile tools up). A web UI for managing the database without installing a psql client.

flowchart LR
    DEV[Developer] -->|edit code| HOST[Host filesystem]
    HOST -->|bind mount| CONTAINER[Gin container]
    CONTAINER -->|read/write| DB[(PostgreSQL)]
    CONTAINER -->|read/write| REDIS[(Redis)]
    HOST -->|localhost:8080| BROWSER[Browser/Postman]

Database Integration with GORM #

GORM is the most popular ORM for Go. For database access, separate the repository layer from the handlers.

Model (internal/model/user.go):

package model

import "time"

type User struct {
    ID        uint64    `gorm:"primaryKey" json:"id"`
    Email     string    `gorm:"size:255;not null;uniqueIndex" json:"email"`
    Name      string    `gorm:"size:255;not null" json:"name"`
    Password  string    `gorm:"size:255;not null" json:"-"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

func (User) TableName() string {
    return "users"
}

Repository (internal/repository/user.go):

package repository

import (
    "context"
    "errors"
    "gorm.io/gorm"
    "myapp/internal/model"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
    return r.db.WithContext(ctx).Create(u).Error
}

func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).First(&u, id).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &u, err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &u, err
}

func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]model.User, error) {
    var users []model.User
    err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&users).Error
    return users, err
}

Service (internal/service/user.go):

package service

import (
    "context"
    "errors"
    "golang.org/x/crypto/bcrypt"
    "myapp/internal/model"
    "myapp/internal/repository"
)

type UserService struct {
    repo *repository.UserRepository
}

func NewUserService(repo *repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Register(ctx context.Context, email, name, password string) (*model.User, error) {
    existing, _ := s.repo.FindByEmail(ctx, email)
    if existing != nil {
        return nil, errors.New("email already registered")
    }
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }
    u := &model.User{
        Email:    email,
        Name:     name,
        Password: string(hashed),
    }
    if err := s.repo.Create(ctx, u); err != nil {
        return nil, err
    }
    return u, nil
}

Handler (internal/handler/user.go):

package handler

import (
    "net/http"
    "strconv"
    "github.com/gin-gonic/gin"
    "myapp/internal/service"
)

type UserHandler struct {
    svc *service.UserService
}

func NewUserHandler(svc *service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

type createUserRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Name     string `json:"name" binding:"required,min=2"`
    Password string `json:"password" binding:"required,min=8"`
}

func (h *UserHandler) Create(c *gin.Context) {
    var req createUserRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    u, err := h.svc.Register(c.Request.Context(), req.Email, req.Name, req.Password)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusCreated, u)
}

func (h *UserHandler) Get(c *gin.Context) {
    id, err := strconv.ParseUint(c.Param("id"), 10, 64)
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
        return
    }
    u, err := h.svc.Get(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    if u == nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
        return
    }
    c.JSON(http.StatusOK, u)
}

Wiring in main.go:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/redis/go-redis/v9"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "myapp/internal/handler"
    "myapp/internal/model"
    "myapp/internal/repository"
    "myapp/internal/service"
)

func main() {
    // Set up the database with retry
    var db *gorm.DB
    var err error
    dsn := os.Getenv("DATABASE_URL")
    for i := 0; i < 30; i++ {
        db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
        if err == nil {
            break
        }
        log.Printf("waiting for db: %v", err)
        time.Sleep(2 * time.Second)
    }
    if err != nil {
        log.Fatal("db connection failed: ", err)
    }

    // Auto-migrate the schema (dev only!)
    if err := db.AutoMigrate(&model.User{}); err != nil {
        log.Fatal("migrate failed: ", err)
    }

    // Set up Redis
    rdb := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_URL"),
    })

    // Set up layers
    userRepo := repository.NewUserRepository(db)
    userSvc := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userSvc)

    // Set up Gin
    if os.Getenv("APP_ENV") == "production" {
        gin.SetMode(gin.ReleaseMode)
    }
    r := gin.Default()

    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })

    api := r.Group("/api/v1")
    {
        api.POST("/users", userHandler.Create)
        api.GET("/users/:id", userHandler.Get)
    }

    // Graceful shutdown
    srv := r
    go func() {
        if err := srv.Run(":8080"); err != nil {
            log.Printf("server stopped: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down...")
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    _ = ctx
}

Built-in and Custom Middleware #

Gin has many built-in middlewares you can use directly.

r := gin.Default()  // already includes Logger + Recovery

// Custom middleware: request ID
func RequestID() gin.HandlerFunc {
    return func(c *gin.Context) {
        rid := c.GetHeader("X-Request-ID")
        if rid == "" {
            rid = uuid.NewString()
        }
        c.Set("requestID", rid)
        c.Writer.Header().Set("X-Request-ID", rid)
        c.Next()
    }
}

// Middleware: CORS
func CORS() gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
        c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        if c.Request.Method == "OPTIONS" {
            c.AbortWithStatus(204)
            return
        }
        c.Next()
    }
}

// Middleware: a simple Redis rate limit
func RateLimit(rdb *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := c.ClientIP()
        count, _ := rdb.Incr(c.Request.Context(), "rl:"+key).Result()
        if count > 100 {
            c.AbortWithStatusJSON(429, gin.H{"error": "rate limit exceeded"})
            return
        }
        c.Next()
    }
}

// Register
r.Use(RequestID())
r.Use(CORS())
r.Use(RateLimit(rdb))

Middleware order matters. RequestID must be first so the ID can be logged by other middleware. CORS before RateLimit so preflight OPTIONS requests don’t get rate-limited. Recovery (built-in) last so it can recover from panics in other middleware.

Validation with go-playground/validator #

Gin uses go-playground/validator via the binding tag.

type createOrderRequest struct {
    ProductID uint64  `json:"product_id" binding:"required"`
    Quantity  int     `json:"quantity" binding:"required,min=1,max=100"`
    Notes     string  `json:"notes" binding:"max=500"`
    Email     string  `json:"email" binding:"required,email"`
    Tags      []string `json:"tags" binding:"required,min=1,dive,min=2,max=30"`
}

func createOrder(c *gin.Context) {
    var req createOrderRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }
    // continue processing
}

The built-in validators cover most cases: required, email, min, max, len, oneof, uuid, url. For complex rules, register a custom validator.

Build and Run #

# Build the development image
docker compose build

# Run all services in the background
docker compose up -d

# View application logs
docker compose logs -f api

# Run migrations (if not using AutoMigrate)
docker compose exec api ./api migrate

# Get a shell inside the container
docker compose exec api sh

# Stop all services
docker compose down

# Stop and remove volumes (full reset)
docker compose down -v

Endpoint access:

  • API: http://localhost:8080
  • Health check: http://localhost:8080/health
  • PostgreSQL: localhost:5432 (user app, password dev, db ginapp)
  • Redis: localhost:6379
  • Adminer (optional): docker compose --profile tools up -d then http://localhost:8081

Test the endpoint with curl:

# Register a user
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Budi","password":"rahasia123"}'

# Get a user by id
curl http://localhost:8080/api/v1/users/1

Testing with Testcontainers #

For integration tests, Testcontainers Go can automatically spin up Postgres and Redis.

// internal/repository/user_test.go
package repository_test

import (
    "context"
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/modules/redis"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "myapp/internal/model"
    "myapp/internal/repository"
)

func setupDB(t *testing.T) (*gorm.DB, func()) {
    ctx := context.Background()

    pgC, err := postgres.RunContainer(ctx,
        testcontainers.WithImage("postgres:16-alpine"),
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        t.Fatal(err)
    }

    dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
    db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        t.Fatal(err)
    }
    db.AutoMigrate(&model.User{})

    cleanup := func() {
        pgC.Terminate(ctx)
    }
    return db, cleanup
}

func TestUserRepository_Create(t *testing.T) {
    db, cleanup := setupDB(t)
    defer cleanup()

    repo := repository.NewUserRepository(db)
    u := &model.User{
        Email:    "[email protected]",
        Name:     "Test",
        Password: "hashed",
    }
    err := repo.Create(context.Background(), u)
    assert.NoError(t, err)
    assert.NotZero(t, u.ID)
}

Testcontainers automatically pulls the Postgres image, runs the container, runs the test, then terminates the container. Tests stay deterministic and independent of global state.

When Gin Is Less Suitable #

Gin is a solid choice, but there are conditions where alternatives fit better.

Keep using Gin if:
  ✓ Conventional REST APIs
  ✓ You need a broad middleware ecosystem
  ✓ Performance and simplicity are priorities
  ✓ The team is familiar with the Go standard library

Consider alternatives if:
  ✗ You need type-safe handlers with schemas → fiber
  ✗ You only need a lightweight router without a framework → chi
  ✗ Microservices with full HTTP control → net/http standard
  ✗ gRPC services → keep using grpc-go

chi is a minimalist alternative very close to net/http — fitting for services wanting full control. fiber offers an Express.js-like API with performance slightly below Gin but more familiar ergonomics for JavaScript developers. For services that only need routing without much middleware, the standard net/http (with the new http.ServeMux) is more than enough.

Best Practices #

Separate Dev and Prod Dockerfiles #

Use Dockerfile.dev for development (with Air, full toolchain) and Dockerfile for production (slim multi-stage, static binary). Don’t mix concerns — the production image doesn’t need Air, and the dev image doesn’t need size optimization.

Cache Go Dependencies #

Always copy go.mod and go.sum first, run go mod download, then copy the source code. This keeps the dependency layer from being invalidated when source changes. The first build is slower, but subsequent builds are lightning fast.

Bind-Mount Source Code, Don’t COPY #

In development, use volumes in Compose to mount source code into the container. The container uses Air to rebuild. When deploying to production, COPY the source into the final image.

Healthchecks for Dependent Services #

PostgreSQL and Redis must have healthchecks. The api service uses depends_on: condition: service_healthy so it doesn’t start before dependencies are ready. This doesn’t guarantee the connection is ready, so add a retry loop in the application code too.

Use .env for Configuration #

All configuration that can differ between environments — ports, database URLs, secrets — goes in .env. This file goes in .gitignore. Provide .env.example in the repo for onboarding.

Run as Non-Root in Production #

Add USER app:app in the production Dockerfile. The development image matters less since it usually only runs on laptops, but production must be non-root.

Structured Logging #

Use slog (Go 1.21+) or libraries like zerolog/zap for JSON logs. In development, plain-text logs are fine — but in production, JSON logs are easier for aggregators to parse (Loki, ELK, Datadog).

Troubleshooting #

Air Doesn’t Detect Changes #

Make sure the bind mount covers the source directories, and include_dir in .air.toml matches the project structure. On Mac/Windows with Docker Desktop, file change events can sometimes take a few seconds.

Database Connection Refused #

Use depends_on: condition: service_healthy in Compose, and add a retry loop in the Go code (see the main.go example above). depends_on alone doesn’t guarantee the database accepts connections.

Port 8080 Already in Use #

lsof -i :8080
# or on Windows
netstat -ano | findstr :8080

Stop the other process or change the port mapping in Compose ("8081:8080").

Slow go mod Downloads #

Use the Go module proxy: GOPROXY=https://proxy.golang.org,direct. Or cache layers with the go-build volume.

Hot Reload Looping Too Fast #

Raise delay in .air.toml (default 1000 ms). If every save triggers many sequential rebuilds, raise it to 2000-3000 ms.

Summary #

  • Gin is ideal for local REST API development — Postgres, Redis, and other services run as separate containers.
  • Multi-stage Dockerfiles for production: a builder stage with golang:1.22-alpine, a slim runtime stage with alpine:3.19. The final image is < 20 MB.
  • Dockerfile.dev for development: full toolchain + Air for hot reload. Heavier builds but faster iteration.
  • Hot reload with Air: watches .go files, rebuilds, restarts the server automatically. Configured via .air.toml.
  • Go layer caching: copy go.mod + go.sum first, run go mod download, then copy the source. Super-fast incremental builds.
  • Healthchecks for dependent services: Postgres pg_isready, Redis redis-cli ping. Use depends_on: condition: service_healthy in Compose.
  • Separate layers: handler (HTTP) → service (logic) → repository (data access). Dependency injection via constructors in main.go.
  • Retry loops for databases: depends_on doesn’t guarantee a ready connection, so add retries in the application code.
  • Testcontainers for integration tests: automatically spins up a Postgres container, runs tests, cleans up. Independent of global state.
  • Validation with binding tags on structs: required, email, min, max, oneof. The go-playground/validator library is built into Gin.
  • Middleware: Logger + Recovery (built-in), CORS, Request ID, Rate Limit (Redis). Order matters — request ID first.
  • Best practices: separate dev & prod Dockerfiles, cache Go dependencies, bind-mount source code, healthchecks for dependencies, .env for configuration, non-root users for production, structured logs.
  • Alternatives: chi for a lightweight router, fiber for an Express-like API, standard net/http for minimal services.

← Previous: Micronaut   Next: Fiber →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact