JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
Next

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

  1. Overview
  2. Prerequisites
  3. Project Setup
  4. Docker Compose Configuration
  5. Starting the Services
  6. Go Project Structure
  7. GORM Database Setup
  8. Building REST API with Gin
  9. Complete Implementation
  10. Testing the API
  11. Best Practices
  12. Troubleshooting
  13. 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:

  1. Click "Add New Server"
  2. General Tab: Name = Local PostgreSQL
  3. Connection Tab:
    • Host: postgres (container name, not localhost!)
    • Port: 5432
    • Database: my_gin_db
    • Username: postgres
    • Password: (from .env)
  4. Check "Save password"
  5. 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:

  1. Verify PostgreSQL container is running:

    docker-compose ps
  2. Check connection string in .env:

    DATABASE_URL=host=localhost user=postgres password=... dbname=my_gin_db port=5432 sslmode=disable
  3. Test PostgreSQL connection:

    docker exec -it postgres-db psql -U postgres -d my_gin_db -c "SELECT 1;"

Problem: "GORM AutoMigrate fails"

Solutions:

  1. Check database permissions
  2. 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


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

  1. Add authentication with JWT tokens
  2. Implement role-based authorization
  3. Add rate limiting to prevent abuse
  4. Set up monitoring with Prometheus/Grafana
  5. Implement caching with Redis
  6. Add API documentation with Swagger
  7. Set up CI/CD pipeline
  8. Implement logging with structured logs

Happy coding! 🚀


Questions or issues? Feel free to reach out or open an issue on GitHub!