Gin #
Gin adalah framework HTTP paling populer di ekosistem Go. Dengan API yang minimal, performa tinggi (berkat httprouter), dan ekosistem middleware yang matang, Gin menjadi pilihan default untuk membangun REST API dan microservice di Go. Docker Compose melengkapi Gin dengan cara menyediakan environment lokal yang konsisten — aplikasi, database, dan cache berjalan dalam container yang sama sehingga “works on my machine” tidak terjadi lagi.
Artikel ini membahas setup lengkap Docker Compose untuk local development Gin, dari Dockerfile multi-stage hingga integrasi dengan database, ORM, dan hot reload. Setelah membaca artikel ini, kamu akan punya template yang bisa langsung dipakai untuk project Gin baru maupun yang sudah berjalan.
Prasyarat #
Pastikan sudah terinstall di host:
- Docker dan Docker Compose versi terbaru
- Go 1.22+ (opsional, untuk development di host tanpa Docker)
- Git untuk clone repository
Struktur project Gin standar dengan layout cmd/ + internal/:
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
Layout cmd/ untuk entry point dan internal/ untuk kode privat (tidak bisa di-import package lain di luar project) adalah idiom Go yang menjaga agar API publik library-mu tetap bersih. Untuk service kecil, struktur cmd/api saja sudah cukup.
Dockerfile Multi-Stage #
Untuk Gin, Dockerfile multi-stage penting agar image runtime sekecil mungkin — tidak perlu membawa Go toolchain, build cache, atau binary sementara. Stage pertama untuk build, stage kedua hanya berisi binary akhir.
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /app
# Install tools OS yang dibutuhkan saat build
RUN apk add --no-cache git ca-certificates
# Copy dependency descriptor dulu untuk cache layer
COPY go.mod go.sum ./
RUN go mod download
# Copy source code dan build binary statis
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/api ./cmd/api
# Stage kedua: runtime image yang ramping
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
# Jalankan sebagai 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"]
Pattern multi-stage memisahkan build environment (perlu Go toolchain, source code lengkap) dari runtime environment (cukup binary). Image akhir berbasis alpine:3.19 biasanya < 20 MB, padahal image golang:1.22-alpine untuk build bisa > 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[Image final < 20 MB]
end
Untuk development, kamu tidak perlu image sekecil ini — fokus pada kecepatan build dan hot reload. Tapi memisahkan Dockerfile dev dan prod adalah pola yang baik.
Dockerfile untuk Development dengan Hot Reload #
Gin sendiri tidak punya hot reload bawaan, tapi Go punya tool populer bernama Air yang memantau perubahan file dan otomatis rebuild + restart. Ini transformatif untuk DX Go.
# Dockerfile.dev
FROM golang:1.22-alpine
WORKDIR /app
RUN apk add --no-cache git curl bash
# Install Air untuk hot reload
RUN go install github.com/air-verse/air@latest
# Cache dependency: copy manifest dulu
COPY go.mod go.sum ./
RUN go mod download
# Source code di-mount via volume, tidak perlu COPY di sini
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
Konfigurasi Air (.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 memantau file dengan ekstensi go, tpl, tmpl, html, yaml di direktori cmd/ dan internal/. Saat file berubah, delay 1 detik, lalu rebuild. Pattern yang sama juga bisa dicapai dengan CompileDaemon atau fresh, tapi Air adalah yang paling populer.
docker-compose.yml untuk 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 # cache build binary
environment:
- APP_ENV=development
- SERVER_PORT=8080
- DATABASE_URL=postgres://app:dev@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"
# Opsional: adminer untuk akses database via UI
adminer:
image: adminer:4
container_name: gin-adminer
ports:
- "8081:8080"
depends_on:
- db
profiles: ["tools"]
volumes:
db-data:
cache-data:
go-build:
Penjelasan Service #
api — service utama Gin. Pakai Dockerfile.dev dengan Air. Mount source code sebagai bind mount — perubahan file di host langsung terlihat di container. Volume go-build meng-cache binary yang di-rebuild oleh Air agar lebih cepat. Environment variable DATABASE_URL dan REDIS_URL menunjuk ke service db dan cache di network Compose internal.
db — PostgreSQL untuk data persisten. Healthcheck pg_isready agar service api tidak start sebelum database benar-benar siap. Folder db/init bisa berisi SQL script yang di-run saat container pertama kali start.
cache — Redis untuk session, cache, dan rate limiting. Mode --appendonly yes mengaktifkan AOF persistence agar data tidak hilang saat restart.
adminer — opsional (diaktifkan dengan docker compose --profile tools up). UI web untuk mengelola database tanpa harus install 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]
Pola Integrasi Database dengan GORM #
GORM adalah ORM paling populer untuk Go. Untuk database access, pisahkan layer repository dari handler.
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 di 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() {
// Setup database dengan 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 schema (dev only!)
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Fatal("migrate failed: ", err)
}
// Setup Redis
rdb := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
})
// Setup layers
userRepo := repository.NewUserRepository(db)
userSvc := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userSvc)
// Setup 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
}
Middleware Bawaan dan Kustom #
Gin punya banyak middleware bawaan yang bisa kamu pakai langsung.
r := gin.Default() // sudah include Logger + Recovery
// Middleware kustom: 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: simple rate limit pakai Redis
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))
Urutan middleware penting. RequestID harus paling awal agar ID bisa dilog oleh middleware lain. CORS sebelum RateLimit agar preflight OPTIONS tidak ke-rate-limit. Recovery (built-in) paling akhir agar bisa recover dari panic di middleware lain.
Validasi dengan go-playground/validator #
Gin menggunakan go-playground/validator lewat tag binding.
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
}
// lanjut proses
}
Validator bawaan sudah cukup untuk kebanyakan kasus: required, email, min, max, len, oneof, uuid, url. Untuk aturan kompleks, daftarkan custom validator.
Build dan Run #
# Build image development
docker compose build
# Jalankan semua service di background
docker compose up -d
# Lihat log aplikasi
docker compose logs -f api
# Jalankan migration (kalau tidak pakai AutoMigrate)
docker compose exec api ./api migrate
# Akses shell di dalam container
docker compose exec api sh
# Stop semua service
docker compose down
# Stop dan hapus volume (reset total)
docker compose down -v
Akses endpoint:
- API: http://localhost:8080
- Health check: http://localhost:8080/health
- PostgreSQL:
localhost:5432(userapp, passworddev, dbginapp) - Redis:
localhost:6379 - Adminer (opsional):
docker compose --profile tools up -dlalu http://localhost:8081
Test endpoint dengan curl:
# Register user
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Budi","password":"rahasia123"}'
# Get user by id
curl http://localhost:8080/api/v1/users/1
Testing dengan Testcontainers #
Untuk integration test, Testcontainers Go bisa spin up Postgres dan Redis otomatis.
// 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 otomatis pull image Postgres, jalankan container, jalankan test, lalu terminate container. Test tetap deterministik dan tidak bergantung pada state global.
Kapan Gin Kurang Tepat #
Gin adalah pilihan yang solid, tapi ada kondisi di mana alternatif lebih cocok.
Tetap gunakan Gin jika:
✓ REST API konvensional
✓ Butuh middleware ecosystem yang luas
✓ Prioritas performa dan kesederhanaan
✓ Tim familiar dengan Go standard library
Pertimbangkan alternatif jika:
✗ Butuh type-safe handler dengan schema → fiber
✗ Hanya butuh router ringan tanpa framework → chi
✗ Microservice dengan full HTTP control → net/http standard
✗ gRPC service → tetap pakai grpc-go
chi adalah alternatif minimalist yang sangat dekat dengan net/http — cocok untuk service yang ingin kontrol penuh. fiber menawarkan API mirip Express.js dengan performa sedikit di bawah Gin tapi ergonomics lebih familiar untuk developer JavaScript. Untuk service yang hanya butuh routing tanpa banyak middleware, net/http standar (dengan http.ServeMux baru) sudah lebih dari cukup.
Best Practice #
Pisahkan Dockerfile Dev dan Prod #
Gunakan Dockerfile.dev untuk development (dengan Air, full toolchain) dan Dockerfile untuk production (multi-stage ramping, binary statis). Jangan campur concern — production image tidak butuh Air, dan dev image tidak perlu optimasi size.
Cache Dependency Go #
Selalu copy go.mod dan go.sum lebih dulu, jalankan go mod download, baru copy source code. Ini membuat layer dependency tidak invalidate saat source code berubah. Build awal lebih lambat, tapi build berikutnya kilat.
Bind Mount Source Code, Bukan COPY #
Di development, pakai volumes di Compose untuk mount source code ke container. Container pakai Air untuk rebuild. Saat deploy production, COPY source ke image final.
Healthcheck untuk Service Dependent #
PostgreSQL dan Redis harus punya healthcheck. Service api pakai depends_on: condition: service_healthy agar tidak start sebelum dependency siap. Ini tidak menjamin koneksi siap, jadi tambahkan retry loop di kode aplikasi.
Pakai .env untuk Konfigurasi
#
Semua konfigurasi yang bisa berbeda antar environment — port, database URL, secret — simpan di .env. File ini di-.gitignore. Sediakan .env.example di repo untuk onboarding.
Jalankan Sebagai Non-Root di Production #
Tambahkan USER app:app di Dockerfile production. Image development tidak terlalu penting karena biasanya hanya jalan di laptop, tapi production wajib non-root.
Logging Terstruktur #
Gunakan slog (Go 1.21+) atau library seperti zerolog/zap untuk log JSON. Di development, log plain text cukup — tapi di production, log JSON lebih mudah di-parse oleh aggregator (Loki, ELK, Datadog).
Troubleshooting #
Air Tidak Detect Perubahan #
Pastikan bind mount mencakup direktori source code, dan include_dir di .air.toml sesuai struktur project. Di Mac/Windows dengan Docker Desktop, file change events kadang butuh waktu sampai beberapa detik.
Database Connection Refused #
Pakai depends_on: condition: service_healthy di Compose, dan tambahkan retry loop di kode Go (lihat contoh main.go di atas). depends_on saja tidak menjamin database siap menerima koneksi.
Port 8080 Sudah Dipakai #
lsof -i :8080
# atau di Windows
netstat -ano | findstr :8080
Stop proses lain atau ubah port mapping di Compose ("8081:8080").
Go Mod Download Lambat #
Pakai Go module proxy: GOPROXY=https://proxy.golang.org,direct. Atau cache layer dengan volume go-build.
Hot Reload Loop Terlalu Cepat #
Naikkan delay di .air.toml (default 1000 ms). Kalau setiap save trigger banyak rebuild berurutan, naikkan ke 2000-3000 ms.
Ringkasan #
- Gin ideal untuk local development REST API — Postgres, Redis, dan service lain berjalan sebagai container terpisah.
- Dockerfile multi-stage untuk production: builder stage dengan
golang:1.22-alpine, runtime stage ramping denganalpine:3.19. Image akhir < 20 MB.- Dockerfile.dev untuk development: full toolchain + Air untuk hot reload. Build lebih berat tapi iterasi lebih cepat.
- Hot reload dengan Air: pantau file
.go, rebuild, restart server otomatis. Konfigurasi lewat.air.toml.- Layer cache Go: copy
go.mod+go.sumdulu, jalankango mod download, baru copy source. Build inkremental super cepat.- Healthcheck untuk service dependent: Postgres
pg_isready, Redisredis-cli ping. Pakaidepends_on: condition: service_healthydi Compose.- Pisahkan layer: handler (HTTP) → service (logic) → repository (data access). Dependency injection lewat konstruktor di
main.go.- Retry loop untuk database:
depends_ontidak menjamin koneksi siap, jadi tambahkan retry di kode aplikasi.- Testcontainers untuk integration test: otomatis spin up Postgres container, jalankan test, cleanup. Tidak bergantung pada state global.
- Validasi dengan tag
bindingdi struct:required,min,max,oneof. Librarygo-playground/validatorsudah built-in di Gin.- Middleware: Logger + Recovery (built-in), CORS, Request ID, Rate Limit (Redis). Urutan penting — request ID paling awal.
- Best practice: pisahkan Dockerfile dev & prod, cache dependency Go, bind mount source code, healthcheck untuk dependency,
.envuntuk konfigurasi, non-root user untuk production, log terstruktur.- Alternatif: chi untuk router ringan, fiber untuk API mirip Express, net/http standar untuk service minimal.