Complete Guide: PostgreSQL & pgAdmin Docker Setup for Go Developers
Learn how to set up PostgreSQL 17 and pgAdmin in Docker containers while running your Go application locally. This comprehensive guide includes Docker Compose configuration, complete Go code examples with pgx/v5, connection pooling best practices, and a full Docker commands cheatsheet. Perfect for developers who want isolated, portable database environments without containerizing their entire application.
PostgreSQL & pgAdmin with Docker for Go + Gin + GORM
Table of Contents
- Overview
- Prerequisites
- Project Setup
- Docker Compose Configuration
- Starting the Services
- Go Project Structure
- GORM Database Setup
- Building REST API with Gin
- Complete Implementation
- Testing the API
- Best Practices
- Troubleshooting
- Docker Commands Cheatsheet
Overview
This guide shows you how to build a production-ready REST API using:
- Gin Gonic - High-performance HTTP web framework
- GORM - Developer-friendly ORM for Go
- PostgreSQL 17 - Running in Docker
- pgAdmin 4 - Running in Docker
- Your Go application - Running locally on your host machine
This architecture gives you the best development experience with hot reload, easy debugging, and isolated database management.
Prerequisites
Required Software:
- Docker & Docker Compose
- Go 1.21 or higher
- Your favorite code editor (VS Code, GoLand, etc.)
Verify installations:
docker --version
docker-compose --version
go version
Project Setup
Step 1: Create Project Structure
mkdir my-gin-api
cd my-gin-api
Your final project structure:
my-gin-api/
├── docker-compose.yml
├── .env
├── .gitignore
├── go.mod
├── go.sum
├── main.go
├── config/
│ └── database.go
├── models/
│ └── user.go
├── controllers/
│ └── user_controller.go
├── routes/
│ └── routes.go
├── middlewares/
│ └── error_handler.go
└── postgres-data/ # Auto-created by Docker
Step 2: Create Configuration Files
Create .gitignore
:
# .gitignore
postgres-data/
.env
*.log
tmp/
Create .env
file:
# .env
# Application
PORT=8080
GIN_MODE=debug
# PostgreSQL Configuration
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=my_gin_db
# pgAdmin Configuration
PGADMIN_EMAIL=admin@example.com
PGADMIN_PASSWORD=admin_password_here
PGADMIN_PORT=5050
# Database Connection String
DATABASE_URL=host=localhost user=postgres password=your_secure_password_here dbname=my_gin_db port=5432 sslmode=disable TimeZone=UTC
⚠️ Important: Replace all passwords with your own secure passwords!
Docker Compose Configuration
Create docker-compose.yml
:
version: "3.8"
services:
postgres:
image: postgres:17-alpine
container_name: postgres-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_HOST_AUTH_METHOD: md5
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- ./postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
PGADMIN_CONFIG_SERVER_MODE: "False"
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
ports:
- "${PGADMIN_PORT}:80"
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
pgadmin-data:
Starting the Services
Step 1: Start Docker Containers
# Start in detached mode
docker-compose up -d
# Verify containers are running
docker-compose ps
# Check logs
docker-compose logs -f
Expected output:
✔ Container postgres-db Healthy
✔ Container pgadmin Started
Step 2: Access pgAdmin
Open browser: http://localhost:5050
Login with:
- Email:
admin@example.com
- Password:
admin_password_here
Add PostgreSQL Server:
- Click "Add New Server"
- General Tab: Name =
Local PostgreSQL
- Connection Tab:
- Host:
postgres
(container name, not localhost!) - Port:
5432
- Database:
my_gin_db
- Username:
postgres
- Password: (from .env)
- Host:
- Check "Save password"
- Click "Save"
Go Project Structure
Step 1: Initialize Go Module
go mod init github.com/yourusername/my-gin-api
Step 2: Install Dependencies
go get -u github.com/gin-gonic/gin
go get -u gorm.io/gorm
go get -u gorm.io/driver/postgres
go get -u github.com/joho/godotenv
GORM Database Setup
Create config/database.go
:
// config/database.go
package config
import (
"fmt"
"log"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
// ConnectDatabase establishes database connection with GORM
func ConnectDatabase() {
var err error
// Get database connection string from environment
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
// Configure GORM
config := &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
NowFunc: func() time.Time {
return time.Now().UTC()
},
QueryFields: true,
}
// Connect to PostgreSQL
DB, err = gorm.Open(postgres.Open(dsn), config)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Get underlying SQL database
sqlDB, err := DB.DB()
if err != nil {
log.Fatalf("Failed to get database instance: %v", err)
}
// Set connection pool settings
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
sqlDB.SetConnMaxLifetime(time.Hour)
log.Println("✅ Database connected successfully!")
}
// GetDB returns the database instance
func GetDB() *gorm.DB {
return DB
}
// CloseDatabase closes the database connection
func CloseDatabase() {
sqlDB, err := DB.DB()
if err != nil {
log.Printf("Error getting database instance: %v", err)
return
}
if err := sqlDB.Close(); err != nil {
log.Printf("Error closing database: %v", err)
} else {
log.Println("Database connection closed")
}
}
Building REST API with Gin
Step 1: Create Models
Create models/user.go
:
// models/user.go
package models
import (
"time"
"gorm.io/gorm"
)
// User represents a user in the system
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name" binding:"required,min=2,max=100"`
Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email" binding:"required,email"`
Age int `gorm:"type:int" json:"age" binding:"omitempty,gte=0,lte=150"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// UserResponse is used for API responses (excludes soft delete info)
type UserResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Age int `json:"age"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToResponse converts User to UserResponse
func (u *User) ToResponse() UserResponse {
return UserResponse{
ID: u.ID,
Name: u.Name,
Email: u.Email,
Age: u.Age,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}
// CreateUserInput for creating new users
type CreateUserInput struct {
Name string `json:"name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"omitempty,gte=0,lte=150"`
}
// UpdateUserInput for updating existing users
type UpdateUserInput struct {
Name string `json:"name" binding:"omitempty,min=2,max=100"`
Email string `json:"email" binding:"omitempty,email"`
Age *int `json:"age" binding:"omitempty,gte=0,lte=150"`
}
Step 2: Create Controllers
Create controllers/user_controller.go
:
// controllers/user_controller.go
package controllers
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/yourusername/my-gin-api/config"
"github.com/yourusername/my-gin-api/models"
)
// GetUsers retrieves all users
// @Summary Get all users
// @Description Get list of all users
// @Tags users
// @Produce json
// @Success 200 {array} models.UserResponse
// @Router /users [get]
func GetUsers(c *gin.Context) {
var users []models.User
if err := config.DB.Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to retrieve users",
})
return
}
// Convert to response format
var response []models.UserResponse
for _, user := range users {
response = append(response, user.ToResponse())
}
c.JSON(http.StatusOK, gin.H{
"data": response,
"count": len(response),
})
}
// GetUser retrieves a single user by ID
// @Summary Get user by ID
// @Description Get user details by ID
// @Tags users
// @Produce json
// @Param id path int true "User ID"
// @Success 200 {object} models.UserResponse
// @Router /users/{id} [get]
func GetUser(c *gin.Context) {
id := c.Param("id")
var user models.User
if err := config.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
c.JSON(http.StatusOK, gin.H{
"data": user.ToResponse(),
})
}
// CreateUser creates a new user
// @Summary Create new user
// @Description Create a new user with provided details
// @Tags users
// @Accept json
// @Produce json
// @Param user body models.CreateUserInput true "User details"
// @Success 201 {object} models.UserResponse
// @Router /users [post]
func CreateUser(c *gin.Context) {
var input models.CreateUserInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
user := models.User{
Name: input.Name,
Email: input.Email,
Age: input.Age,
}
if err := config.DB.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to create user",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "User created successfully",
"data": user.ToResponse(),
})
}
// UpdateUser updates an existing user
// @Summary Update user
// @Description Update user details by ID
// @Tags users
// @Accept json
// @Produce json
// @Param id path int true "User ID"
// @Param user body models.UpdateUserInput true "Updated user details"
// @Success 200 {object} models.UserResponse
// @Router /users/{id} [put]
func UpdateUser(c *gin.Context) {
id := c.Param("id")
var user models.User
if err := config.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "User not found",
})
return
}
var input models.UpdateUserInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}
// Update only provided fields
if input.Name != "" {
user.Name = input.Name
}
if input.Email != "" {
user.Email = input.Email
}
if input.Age != nil {
user.Age = *input.Age
}
if err := config.DB.Save(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update user",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User updated successfully",
"data": user.ToResponse(),
})
}
// DeleteUser deletes a user (soft delete)
// @Summary Delete user
// @Description Soft delete user by ID
// @Tags users
// @Param id path int true "User ID"
// @Success 200 {object} map[string]string
// @Router /users/{id} [delete]
func DeleteUser(c *gin.Context) {
id := c.Param("id")
if err := config.DB.Delete(&models.User{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to delete user",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "User deleted successfully",
})
}
// SearchUsers searches users by name or email
// @Summary Search users
// @Description Search users by name or email
// @Tags users
// @Produce json
// @Param q query string true "Search query"
// @Success 200 {array} models.UserResponse
// @Router /users/search [get]
func SearchUsers(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Search query is required",
})
return
}
var users []models.User
searchPattern := "%" + query + "%"
if err := config.DB.Where("name ILIKE ? OR email ILIKE ?", searchPattern, searchPattern).
Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to search users",
})
return
}
var response []models.UserResponse
for _, user := range users {
response = append(response, user.ToResponse())
}
c.JSON(http.StatusOK, gin.H{
"data": response,
"count": len(response),
})
}
Step 3: Create Middleware
Create middlewares/error_handler.go
:
// middlewares/error_handler.go
package middlewares
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// ErrorHandler middleware for handling panics
func ErrorHandler() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic recovered: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Internal server error",
})
c.Abort()
}
}()
c.Next()
}
}
// CORS middleware
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
Step 4: Setup Routes
Create routes/routes.go
:
// routes/routes.go
package routes
import (
"github.com/gin-gonic/gin"
"github.com/yourusername/my-gin-api/controllers"
"github.com/yourusername/my-gin-api/middlewares"
)
// SetupRoutes configures all application routes
func SetupRoutes(router *gin.Engine) {
// Apply middleware
router.Use(middlewares.ErrorHandler())
router.Use(middlewares.CORS())
// Health check endpoint
router.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"message": "Server is running",
})
})
// API v1 routes
v1 := router.Group("/api/v1")
{
// User routes
users := v1.Group("/users")
{
users.GET("", controllers.GetUsers)
users.GET("/:id", controllers.GetUser)
users.POST("", controllers.CreateUser)
users.PUT("/:id", controllers.UpdateUser)
users.DELETE("/:id", controllers.DeleteUser)
users.GET("/search", controllers.SearchUsers)
}
}
}
Complete Implementation
Create main.go
:
// main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/yourusername/my-gin-api/config"
"github.com/yourusername/my-gin-api/models"
"github.com/yourusername/my-gin-api/routes"
)
func main() {
// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("⚠️ No .env file found, using system environment variables")
}
// Connect to database
config.ConnectDatabase()
defer config.CloseDatabase()
// Auto-migrate database schema
if err := config.DB.AutoMigrate(&models.User{}); err != nil {
log.Fatalf("Failed to migrate database: %v", err)
}
log.Println("✅ Database migration completed")
// Set Gin mode
ginMode := os.Getenv("GIN_MODE")
if ginMode == "" {
ginMode = gin.DebugMode
}
gin.SetMode(ginMode)
// Create Gin router
router := gin.Default()
// Setup routes
routes.SetupRoutes(router)
// Get port from environment
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
// Create HTTP server
srv := &http.Server{
Addr: fmt.Sprintf(":%s", port),
Handler: router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
}
// Start server in a goroutine
go func() {
log.Printf("🚀 Server starting on port %s", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Failed to start server: %v", err)
}
}()
// Wait for interrupt signal for graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("🛑 Shutting down server...")
// Graceful shutdown with 5 second timeout
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("✅ Server exited")
}
Run Your Application
# Install dependencies
go mod tidy
# Run the application
go run main.go
Expected output:
✅ Database connected successfully!
✅ Database migration completed
🚀 Server starting on port 8080
[GIN-debug] Listening and serving HTTP on :8080
Testing the API
Using cURL
1. Health Check:
curl http://localhost:8080/health
2. Create User:
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"age": 30
}'
3. Get All Users:
curl http://localhost:8080/api/v1/users
4. Get Single User:
curl http://localhost:8080/api/v1/users/1
5. Update User:
curl -X PUT http://localhost:8080/api/v1/users/1 \
-H "Content-Type: application/json" \
-d '{
"name": "John Smith",
"age": 31
}'
6. Search Users:
curl "http://localhost:8080/api/v1/users/search?q=john"
7. Delete User:
curl -X DELETE http://localhost:8080/api/v1/users/1
Using Postman
Import this collection or create requests manually:
Base URL: http://localhost:8080/api/v1
Endpoints:
- GET
/users
- Get all users - GET
/users/:id
- Get user by ID - POST
/users
- Create new user - PUT
/users/:id
- Update user - DELETE
/users/:id
- Delete user - GET
/users/search?q=query
- Search users
Best Practices
1. Use GORM Hooks for Timestamps
GORM automatically handles created_at
and updated_at
when you use gorm.Model
or define these fields.
2. Connection Pooling
Already configured in config/database.go
:
sqlDB.SetMaxIdleConns(10) // Maximum idle connections
sqlDB.SetMaxOpenConns(100) // Maximum open connections
sqlDB.SetConnMaxLifetime(time.Hour) // Connection lifetime
3. Validation with Gin Binding
Use struct tags for automatic validation:
type CreateUserInput struct {
Name string `json:"name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email"`
Age int `json:"age" binding:"omitempty,gte=0,lte=150"`
}
4. Use Soft Deletes
GORM's DeletedAt
field enables soft deletes:
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
5. Environment-Based Configuration
Use different .env
files:
# .env.development
GIN_MODE=debug
# .env.production
GIN_MODE=release
Load specific file:
godotenv.Load(".env.production")
6. Database Migrations
GORM AutoMigrate is great for development:
config.DB.AutoMigrate(&models.User{}, &models.Post{})
For production, consider using migration tools like:
7. Error Handling
Create custom error types:
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
8. Logging
Use structured logging with zerolog or zap:
go get github.com/rs/zerolog
9. Rate Limiting
Add rate limiting middleware:
go get github.com/ulule/limiter/v3
10. API Documentation
Use Swagger for API documentation:
go get -u github.com/swaggo/swag/cmd/swag
go get -u github.com/swaggo/gin-swagger
go get -u github.com/swaggo/files
Troubleshooting
Problem: "Failed to connect to database"
Solutions:
-
Verify PostgreSQL container is running:
docker-compose ps
-
Check connection string in
.env
:DATABASE_URL=host=localhost user=postgres password=... dbname=my_gin_db port=5432 sslmode=disable
-
Test PostgreSQL connection:
docker exec -it postgres-db psql -U postgres -d my_gin_db -c "SELECT 1;"
Problem: "GORM AutoMigrate fails"
Solutions:
- Check database permissions
- Verify database exists:
docker exec -it postgres-db psql -U postgres -c "\l"
Problem: "Validation errors not showing"
Solution: Gin's validation uses binding
tags. Make sure you're using ShouldBindJSON
:
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
Problem: Port already in use
Solution:
# Find process using port 8080
lsof -i :8080 # macOS/Linux
netstat -ano | findstr :8080 # Windows
# Kill the process or change PORT in .env
Problem: "record not found" error
Solution: GORM returns gorm.ErrRecordNotFound
. Handle it properly:
if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(404, gin.H{"error": "User not found"})
return
}
c.JSON(500, gin.H{"error": "Database error"})
return
}
Docker Commands Cheatsheet
Docker Compose
# Start services
docker-compose up -d
# Stop services
docker-compose down
# View logs
docker-compose logs -f postgres
# Restart PostgreSQL
docker-compose restart postgres
# Stop and remove volumes (⚠️ deletes data!)
docker-compose down -v
# Rebuild containers
docker-compose up -d --build
# Check service status
docker-compose ps
PostgreSQL Container
# Connect to psql
docker exec -it postgres-db psql -U postgres -d my_gin_db
# Run SQL command
docker exec -it postgres-db psql -U postgres -c "SELECT version();"
# Backup database
docker exec postgres-db pg_dump -U postgres my_gin_db > backup.sql
# Restore database
docker exec -i postgres-db psql -U postgres my_gin_db < backup.sql
# List databases
docker exec -it postgres-db psql -U postgres -c "\l"
# List tables
docker exec -it postgres-db psql -U postgres -d my_gin_db -c "\dt"
# Describe table
docker exec -it postgres-db psql -U postgres -d my_gin_db -c "\d users"
Container Management
# View running containers
docker ps
# View all containers
docker ps -a
# View container logs
docker logs postgres-db
docker logs -f postgres-db # Follow logs
# Execute command in container
docker exec -it postgres-db bash
# Inspect container
docker inspect postgres-db
# View resource usage
docker stats postgres-db
# Remove container
docker rm -f postgres-db
Useful SQL Commands (in psql)
-- List all databases
\l
-- Connect to database
\c my_gin_db
-- List all tables
\dt
-- Describe table structure
\d users
-- Show table indexes
\di
-- List all users/roles
\du
-- Show current database
SELECT current_database();
-- Show table size
SELECT pg_size_pretty(pg_total_relation_size('users'));
-- Count records
SELECT COUNT(*) FROM users;
-- Show recent activity
SELECT * FROM pg_stat_activity;
-- Quit psql
\q
Development Workflow
# Complete restart (clean slate)
docker-compose down -v
docker-compose up -d
go run main.go
# Hot reload with Air (recommended)
go install github.com/cosmtrek/air@latest
air # watches for file changes and reloads
# Check database connection
docker exec postgres-db pg_isready -U postgres
# Monitor logs while developing
docker-compose logs -f postgres &
go run main.go
# Quick backup before changes
docker exec postgres-db pg_dump -U postgres my_gin_db > backup_$(date +%Y%m%d).sql
# Database reset (development only!)
docker-compose restart postgres
go run main.go # AutoMigrate will recreate tables
Advanced Features
1. Add Pagination
Update controllers/user_controller.go
:
func GetUsers(c *gin.Context) {
// Get pagination parameters
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
var users []models.User
var total int64
// Count total records
config.DB.Model(&models.User{}).Count(&total)
// Get paginated results
offset := (page - 1) * pageSize
if err := config.DB.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to retrieve users",
})
return
}
// Convert to response format
var response []models.UserResponse
for _, user := range users {
response = append(response, user.ToResponse())
}
c.JSON(http.StatusOK, gin.H{
"data": response,
"pagination": gin.H{
"page": page,
"page_size": pageSize,
"total": total,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
},
})
}
Usage:
curl "http://localhost:8080/api/v1/users?page=1&page_size=10"
2. Add Request Logging Middleware
Create middlewares/logger.go
:
package middlewares
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
startTime := time.Now()
// Process request
c.Next()
// Log request details
latency := time.Since(startTime)
statusCode := c.Writer.Status()
clientIP := c.ClientIP()
method := c.Request.Method
path := c.Request.URL.Path
log.Printf("[%s] %s %s | Status: %d | Latency: %v | IP: %s",
time.Now().Format("2006-01-02 15:04:05"),
method,
path,
statusCode,
latency,
clientIP,
)
}
}
Add to routes:
router.Use(middlewares.RequestLogger())
3. Add Database Seeding
Create config/seeder.go
:
package config
import (
"log"
"github.com/yourusername/my-gin-api/models"
)
func SeedDatabase() {
// Check if users already exist
var count int64
DB.Model(&models.User{}).Count(&count)
if count > 0 {
log.Println("Database already seeded")
return
}
// Seed data
users := []models.User{
{Name: "John Doe", Email: "john@example.com", Age: 30},
{Name: "Jane Smith", Email: "jane@example.com", Age: 25},
{Name: "Bob Johnson", Email: "bob@example.com", Age: 35},
{Name: "Alice Williams", Email: "alice@example.com", Age: 28},
{Name: "Charlie Brown", Email: "charlie@example.com", Age: 32},
}
if err := DB.Create(&users).Error; err != nil {
log.Printf("Failed to seed database: %v", err)
return
}
log.Printf("✅ Database seeded with %d users", len(users))
}
Add to main.go:
config.ConnectDatabase()
config.DB.AutoMigrate(&models.User{})
config.SeedDatabase() // Add this line
4. Add Hot Reload with Air
Install Air:
go install github.com/cosmtrek/air@latest
Create .air.toml
:
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "postgres-data"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true
Run with Air:
air
Now your app will automatically reload when you save changes!
5. Add Input Validation Messages
Create utils/validator.go
:
package utils
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
func FormatValidationError(err error) map[string]string {
errors := make(map[string]string)
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, e := range validationErrors {
field := strings.ToLower(e.Field())
switch e.Tag() {
case "required":
errors[field] = fmt.Sprintf("%s is required", field)
case "email":
errors[field] = "Invalid email format"
case "min":
errors[field] = fmt.Sprintf("%s must be at least %s characters", field, e.Param())
case "max":
errors[field] = fmt.Sprintf("%s must be at most %s characters", field, e.Param())
case "gte":
errors[field] = fmt.Sprintf("%s must be greater than or equal to %s", field, e.Param())
case "lte":
errors[field] = fmt.Sprintf("%s must be less than or equal to %s", field, e.Param())
default:
errors[field] = fmt.Sprintf("%s is invalid", field)
}
}
}
return errors
}
Use in controllers:
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"errors": utils.FormatValidationError(err),
})
return
}
6. Add Response Wrapper
Create utils/response.go
:
package utils
import (
"github.com/gin-gonic/gin"
)
type Response struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error interface{} `json:"error,omitempty"`
}
func SuccessResponse(c *gin.Context, statusCode int, message string, data interface{}) {
c.JSON(statusCode, Response{
Success: true,
Message: message,
Data: data,
})
}
func ErrorResponse(c *gin.Context, statusCode int, message string, err interface{}) {
c.JSON(statusCode, Response{
Success: false,
Message: message,
Error: err,
})
}
Usage:
utils.SuccessResponse(c, http.StatusOK, "Users retrieved successfully", users)
utils.ErrorResponse(c, http.StatusNotFound, "User not found", nil)
Production Deployment
1. Build Docker Image for Go App
Create Dockerfile
:
# Build stage
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy binary from builder
COPY --from=builder /app/main .
COPY --from=builder /app/.env .
EXPOSE 8080
CMD ["./main"]
2. Update docker-compose.yml for Production
version: "3.8"
services:
app:
build: .
container_name: gin-app
restart: unless-stopped
ports:
- "8080:8080"
environment:
- GIN_MODE=release
- DATABASE_URL=host=postgres user=${POSTGRES_USER} password=${POSTGRES_PASSWORD} dbname=${POSTGRES_DB} port=5432 sslmode=disable
depends_on:
postgres:
condition: service_healthy
networks:
- app-network
postgres:
image: postgres:17-alpine
container_name: postgres-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
pgadmin:
image: dpage/pgadmin4:latest
container_name: pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL}
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD}
ports:
- "5050:80"
volumes:
- pgadmin-data:/var/lib/pgadmin
depends_on:
- postgres
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
postgres-data:
pgadmin-data:
3. Environment Variables for Production
Create .env.production
:
# Application
PORT=8080
GIN_MODE=release
# PostgreSQL
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=very_secure_password_here
POSTGRES_DB=production_db
# pgAdmin
PGADMIN_EMAIL=admin@production.com
PGADMIN_PASSWORD=secure_admin_password
# Database URL (using container name in production)
DATABASE_URL=host=postgres user=postgres password=very_secure_password_here dbname=production_db port=5432 sslmode=require TimeZone=UTC
4. Deploy
# Build and start all services
docker-compose -f docker-compose.yml --env-file .env.production up -d --build
# Check logs
docker-compose logs -f app
# Scale the app (if needed)
docker-compose up -d --scale app=3
Performance Optimization
1. Use Database Indexes
type User struct {
ID uint `gorm:"primaryKey"`
Email string `gorm:"type:varchar(100);uniqueIndex;not null"`
Name string `gorm:"type:varchar(100);index;not null"`
Age int `gorm:"type:int;index"`
}
2. Use Preloading for Relations
// Instead of N+1 queries
db.Preload("Orders").Find(&users)
// Nested preloading
db.Preload("Orders.Items").Find(&users)
3. Use Select to Load Specific Fields
// Only load needed fields
db.Select("id", "name", "email").Find(&users)
4. Batch Operations
// Batch insert
users := []User{{Name: "User1"}, {Name: "User2"}}
db.CreateInBatches(users, 100)
5. Use Connection Pooling Wisely
sqlDB.SetMaxIdleConns(10) // Idle connections
sqlDB.SetMaxOpenConns(100) // Max open connections
sqlDB.SetConnMaxLifetime(time.Hour) // Connection lifetime
sqlDB.SetConnMaxIdleTime(10 * time.Minute) // Idle timeout
Testing
Create controllers/user_controller_test.go
:
package controllers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/yourusername/my-gin-api/config"
"github.com/yourusername/my-gin-api/models"
)
func SetupTestDB() {
// Setup test database
config.ConnectDatabase()
config.DB.AutoMigrate(&models.User{})
}
func TestGetUsers(t *testing.T) {
SetupTestDB()
gin.SetMode(gin.TestMode)
router := gin.Default()
router.GET("/users", GetUsers)
req, _ := http.NewRequest("GET", "/users", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
}
func TestCreateUser(t *testing.T) {
SetupTestDB()
gin.SetMode(gin.TestMode)
router := gin.Default()
router.POST("/users", CreateUser)
user := models.CreateUserInput{
Name: "Test User",
Email: "test@example.com",
Age: 25,
}
jsonValue, _ := json.Marshal(user)
req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(jsonValue))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, 201, w.Code)
}
Run tests:
go test ./... -v
Additional Resources
- Gin Documentation: https://gin-gonic.com/docs/
- GORM Documentation: https://gorm.io/docs/
- PostgreSQL Documentation: https://www.postgresql.org/docs/17/
- Go by Example: https://gobyexample.com/
- Effective Go: https://go.dev/doc/effective_go
Conclusion
You now have a complete, production-ready REST API using:
✅ Gin Gonic for fast HTTP routing
✅ GORM for elegant database operations
✅ PostgreSQL 17 in Docker for data persistence
✅ pgAdmin for database management
✅ Best practices for structure, validation, and error handling
✅ Graceful shutdown and proper resource management
✅ Hot reload support for development
✅ Production deployment configuration
Next Steps
- Add authentication with JWT tokens
- Implement role-based authorization
- Add rate limiting to prevent abuse
- Set up monitoring with Prometheus/Grafana
- Implement caching with Redis
- Add API documentation with Swagger
- Set up CI/CD pipeline
- Implement logging with structured logs
Happy coding! 🚀
Questions or issues? Feel free to reach out or open an issue on GitHub!