Bringing a Go + Gin App Up to Production Quality: From Configuration and Structure to CI
Introduction
In this series so far, we’ve been building a web API for posting blog articles using Go × Gin, progressing step by step from the basics of an MVC structure to implementing a testing strategy.
- In Part 1, we designed the basic structure of the app and launched a minimal API using Gin.
- In Part 2, we introduced practical methods for growing reliable code, from service/handler-level tests to integration tests.
This Part 3 is the final installment of the series.
From here, we’ll go beyond “the tests pass” and focus on the aspects needed to raise the completeness of the application to production quality:
- Configuration management that can switch between development and production
- Logging design and error handling that improve maintainability
- Task management that supports project reproducibility (Makefile / Taskfile)
- CI setup (GitHub Actions) in preparation for real-world operation
- Docker configuration that brings the local development environment closer to production
Each of these is essential for evolving from an “it runs” application to one that “can be operated” and “can be safely grown.”
In this part, we especially focus on what you can do even with an in-memory setup, so you can take the first step toward production-aware design without using a DB.
Let’s now bring your Go + Gin app up to production quality together.
The code created this time is available in the repository below.
Switching Configuration per Environment: Best Practices for Configuration Management
To support multiple environments such as development, production, and test, switching configuration per environment is essential. In production, it’s common for things like log level, port number, and external service URLs to differ from development.
In Go applications as well, externalizing configuration and leveraging environment variables are the basics.
Introducing the config package
First, create a config package to centrally manage configuration for the entire application.
package config
import (
"log"
"os"
"github.com/joho/godotenv"
)
type Config struct {
Env string
Port string
LogLevel string
}
var AppConfig *Config
func Load() {
_ = godotenv.Load()
AppConfig = &Config{
Env: getEnv("APP_ENV", "development"),
Port: getEnv("PORT", "8080"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
}
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
Example .env file
Create a .env file in the root directory and write the following:
APP_ENV=development
PORT=8080
LOG_LEVEL=debug
This separates environment variable management from the code, and by switching .env files per environment, you can achieve a flexible configuration.
Applying it in main.go
package main
import (
+ "fmt"
+ "go-gin-blog-api/config"
"go-gin-blog-api/handler"
"go-gin-blog-api/repository"
"go-gin-blog-api/service"
+ "log"
"github.com/gin-gonic/gin"
)
func main() {
+ config.Load()
r := gin.Default()
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
})
})
postRepo := repository.NewPostRepository()
postService := service.NewPostService(postRepo)
postHandler := handler.NewPostHandler(postService)
postHandler.RegisterRoutes(r)
- r.Run(":8080") // starts on port 8080
+ addr := fmt.Sprintf(":%s", config.AppConfig.Port)
+ log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
+ r.Run(addr)
}
When you start the server, logs will be output to the console.
2025/06/03 08:15:26 Server running on :8080 (development)
Improving Logging Design and Error Handling
In production operations, logging and error handling are indispensable for understanding “what is happening.” In this section, we’ll introduce the minimum logging mechanisms you should know and a simple policy for error handling.
Purposes of log output
- Request tracing and troubleshooting
- Performance analysis (processing time, frequency, etc.)
- Security auditing (unauthorized access, failed logins, etc.)
These are directly tied not only to development but also to maintainability in production.
Leveraging Gin’s logging features
Gin provides logging as middleware. Below is an example using the default log output.
r := gin.New()
r.Use(gin.Logger()) // Request logs
r.Use(gin.Recovery()) // Catches panic and returns 500
Logs are written to standard output (stdout), which makes them highly compatible with Docker and cloud log collection mechanisms.
Organizing log levels and output format
If you want to handle logs more seriously, consider introducing a library that supports structured log output. For example, uber-go/zap is popular for its speed, structured output, and ease of use.
Steps to introduce a zap logger into a Gin app
package logger
import (
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var Log *zap.Logger
func Init(env string) error {
var err error
if env == "production" {
Log, err = zap.NewProduction()
} else {
cfg := zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
Log, err = cfg.Build()
}
return err
}
var Log *zap.Logger
- A logger instance that can be used globally.
- Other packages can use it as
logger.Log.Info(...), etc.
cfg := zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
Log, err = cfg.Build()
- In development, this sets colors for levels like “INFO” and “WARN.”
Applying it in main.go
package main
import (
"fmt"
"go-gin-blog-api/config"
"go-gin-blog-api/handler"
+ "go-gin-blog-api/logger"
"go-gin-blog-api/repository"
"go-gin-blog-api/service"
"log"
"github.com/gin-gonic/gin"
+ "go.uber.org/zap"
)
func main() {
cfg := config.Load()
+ if err := logger.Init(cfg.Env); err != nil {
+ log.Fatalf("failed to init logger: %v", err)
+ }
+ defer logger.Log.Sync()
- r := gin.Default()
+ r := gin.New()
+ r.Use(gin.Recovery())
+ r.Use(GinZapMiddleware())
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
})
})
postRepo := repository.NewPostRepository()
postService := service.NewPostService(postRepo)
postHandler := handler.NewPostHandler(postService)
postHandler.RegisterRoutes(r)
addr := fmt.Sprintf(":%s", config.AppConfig.Port)
log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
r.Run(addr)
}
+func GinZapMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ path := c.Request.URL.Path
+ method := c.Request.Method
+
+ c.Next()
+
+ status := c.Writer.Status()
+ logger.Log.Info("request completed",
+ zap.String("method", method),
+ zap.String("path", path),
+ zap.Int("status", status),
+ )
+ }
}+
Explanation of the code
if err := logger.Init(cfg.Env); err != nil {
log.Fatalf("failed to init logger: %v", err)
}
- This is where zap is initialized.
- Depending on the environment, it switches the log output format between development (human-friendly) and production (JSON structured).
defer logger.Log.Sync()
- An important process that flushes log output and prevents logs from being left unwritten.
func GinZapMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
method := c.Request.Method
c.Next() // Execute handler
status := c.Writer.Status()
logger.Log.Info("request completed",
zap.String("method", method),
zap.String("path", path),
zap.Int("status", status),
)
}
}
- This middleware outputs a log when a request is completed. It helps improve request tracing and observability.
When you start the server and access it with curl or similar, logs will appear.
You can check the timestamp, URL path, method, and HTTP status.
Cleaning Up Around Server Startup: Separating the Responsibilities of the Entry Point
One common issue in Go apps is that main.go tends to grow too large.
When configuration loading, logger initialization, routing, DI, and server startup are all crammed into main(), the following problems arise:
- Insufficient separation of concerns, making the code hard to read
- Unit testing becomes difficult
- Mixed responsibilities make the code fragile to change
In this section, we’ll introduce an approach to properly separate the responsibilities of main.go and modularize server initialization.
List of responsibilities to separate
| Responsibility | Example module to delegate to |
|---|---|
| Loading configuration | config.Load() |
| Initializing logger | logger.Init() |
| Initializing server (DI, etc.) | internal/server.New() |
| Registering routes | handler.RegisterRoutes() |
| Starting the application | main() |
Introducing the internal/server package
Create server.go under internal/server/ and prepare a function that initializes the entire application.
package server
import (
"fmt"
"go-gin-blog-api/config"
"go-gin-blog-api/handler"
"go-gin-blog-api/logger"
"go-gin-blog-api/repository"
"go-gin-blog-api/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func New() (*gin.Engine, error) {
if err := logger.Init(config.AppConfig.Env); err != nil {
return nil, fmt.Errorf("failed to init logger: %w", err)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(GinZapMiddleware())
postRepo := repository.NewPostRepository()
postService := service.NewPostService(postRepo)
postHandler := handler.NewPostHandler(postService)
postHandler.RegisterRoutes(r)
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
return r, nil
}
func GinZapMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
method := c.Request.Method
c.Next()
status := c.Writer.Status()
logger.Log.Info("request completed",
zap.String("method", method),
zap.String("path", path),
zap.Int("status", status),
)
}
}
main.go now looks like this:
package main
import (
"fmt"
"go-gin-blog-api/config"
"go-gin-blog-api/internal/server"
"go-gin-blog-api/logger"
"log"
)
func main() {
config.Load()
r, err := server.New()
if err != nil {
log.Fatalf("failed to init logger: %v", err)
}
defer logger.Log.Sync()
addr := fmt.Sprintf(":%s", config.AppConfig.Port)
log.Printf("Server running on %s (%s)", addr, config.AppConfig.Env)
r.Run(addr)
}
Now the responsibility of main.go is limited to “starting the app,” and you gain the following benefits:
- Safe termination when initialization fails
server.New()can be reused in tests or CLI tools- With a clearer responsibility for main, maintainability improves dramatically
Supporting graceful shutdown of the server
When you build a web server in Go and start it with just r.Run(), the process may terminate immediately when it receives SIGINT (Ctrl+C) or SIGTERM (process termination), which can cut off in-flight requests.
- In production, app reloads and pod restarts happen routinely.
- In such cases, you want to stop the server only after allowing in-flight requests to complete as much as possible.
- This is what “graceful shutdown” achieves.
Modify main.go as follows.
package main
import (
"context"
"fmt"
"go-gin-blog-api/config"
"go-gin-blog-api/internal/server"
"go-gin-blog-api/logger"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go.uber.org/zap"
)
func main() {
config.Load()
if err := logger.Init(config.AppConfig.Env); err != nil {
log.Fatalf("failed to initialize logger: %v", err)
}
defer logger.Log.Sync()
r, err := server.New()
if err != nil {
log.Fatalf("failed to create server: %v", err)
}
srv := &http.Server{
Addr: fmt.Sprintf(":%s", config.AppConfig.Port),
Handler: r,
}
go func() {
logger.Log.Info("starting server",
zap.String("addr", srv.Addr),
zap.String("env", config.AppConfig.Env),
)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Log.Fatal("server error", zap.Error(err))
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Log.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Log.Fatal("forced shutdown", zap.Error(err))
}
logger.Log.Info("server exited gracefully")
}
To confirm that graceful shutdown is working correctly, add a slow API that responds late.
The response will be returned 3 seconds after the request is sent.
r.GET("/slow", func(c *gin.Context) {
time.Sleep(3 * time.Second)
c.JSON(200, gin.H{"message": "done"})
})
If you actually send a request and then stop the server, you can see from the logs that the server stops after returning the response.
Task Management with Makefile
When you develop with production operation in mind, it’s inefficient to “manually repeat” routine tasks such as development, build, test, and run.
The role of a Makefile is to automate such tasks so that anyone can perform the same operations, any number of times, in a consistent way.
- In Go projects, it’s especially useful for:
- Simplifying
go runandgo build - Running
go test ./...and linters - Starting development with
.envloading - Acting as a wrapper for
docker-compose - Defining reproducible commands for CI/CD
run:
go run main.go
build:
go build -o app main.go
test:
go test ./...
lint:
go vet ./...
dev:
APP_ENV=development go run main.go
make run: production runmake dev: development run with environment variablesmake test: run all testsmake lint: static analysis (golangci-lint)
As a project grows, even “small tasks” tend to become person-dependent and complicated.
By introducing a Makefile, you ensure reproducibility in development, testing, and deployment,
which in turn improves the productivity and reliability of the entire team.
Aim for a state where anyone can run tests and start the local environment using the same steps.
First Step Toward CI/CD: Automating Tests with GitHub Actions
Creating .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Install dependencies
run: go mod download
- name: Run vet
run: go vet ./...
- name: Run tests
run: go test -v ./...
go vet ./...: Go’s standard static analysisgo test -v ./...: run tests for all packages-voption: outputs test logs to CI logs, making debugging easier
Checking runs and logs on failure
From GitHub’s “Actions” tab, you can easily check run results and logs.
If tests fail, a pull request will get a “red cross,” which is a big advantage in preventing broken code from being merged.
Security and Dependency Management
In production, one thing you absolutely want to avoid is unknowingly running an app that contains vulnerabilities. In this section, we’ll explain security-conscious dependency management and some basic measures.
Basic security checklist
The following items are the minimum security measures you should check.
| Item | Measure |
|---|---|
| Prevent logging of unnecessary information | Don’t log passwords, auth tokens, etc. |
| HTTP header settings | Add security headers (e.g. Content-Security-Policy, X-Content-Type-Options) |
| Handling panic | Use recover so that the server doesn’t stop even if a panic occurs |
| Input validation | Strictly validate parameters received by APIs using binding or validator |
| CORS settings | Allow only the minimum necessary domains (e.g. c.Use(cors.New(corsConfig))) |
Go Modules and monitoring dependencies
Go also has several useful tools for security checks.
govulncheck
Go’s official vulnerability scanner. With Go 1.18 or later, you can use it out of the box.
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
Below is an example of the output.
For example, in the first one, there seems to be a vulnerability issue related to os on Windows.
=== Symbol Results ===
Vulnerability #1: GO-2025-3750
Inconsistent handling of O_CREATE|O_EXCL on Unix and Windows in os in
syscall
More info: https://pkg.go.dev/vuln/GO-2025-3750
Standard library
Found in: os@go1.23
Fixed in: os@go1.23.10
Platforms: windows
Example traces found:
#1: config/config.go:4:2: config.init calls os.init, which calls os.Getwd
#2: config/config.go:4:2: config.init calls os.init, which calls os.NewFile
#3: config/config.go:18:19: config.Load calls godotenv.Load, which eventually calls os.Open
#4: logger/logger.go:17:23: logger.Init calls zap.Config.Build, which eventually calls os.OpenFile
#5: main.go:40:31: gin.main calls http.Server.ListenAndServe, which eventually calls os.ReadFile
#6: main.go:40:31: gin.main calls http.Server.ListenAndServe, which eventually calls os.Remove
#7: handler/post_handler.go:56:28: handler.PostHandler.UpdatePost calls gin.Context.ShouldBindJSON, which eventually calls os.Stat
#8: config/config.go:4:2: config.init calls os.init, which eventually calls syscall.Open
Vulnerability #2: GO-2025-3749
Usage of ExtKeyUsageAny disables policy validation in crypto/x509
More info: https://pkg.go.dev/vuln/GO-2025-3749
Standard library
Found in: crypto/x509@go1.23
Fixed in: crypto/x509@go1.23.10
Example traces found:
#1: config/config.go:18:19: config.Load calls godotenv.Load, which eventually calls x509.Certificate.Verify
Dependabot
If you’re using a GitHub repository, enabling dependabot will detect problematic packages for you.
Below is an example of the output.
There is a Critical (high urgency) alert for the crypto package.
Whenever you update packages, be sure to run tests.
Updates can break existing code.
Code changes are often required, so it’s recommended to update frequently and in small increments.
Laying the Groundwork for Production with Dockerization
When aiming for a production-quality application, “eliminating environment differences” is extremely important. While a Go app can run as a standalone binary, containerizing it with Docker provides the following benefits.
Benefits of Dockerization
- You can reproduce the same runtime environment in production, staging, and local
- Easy to integrate into CI/CD deployment pipelines
- Prevents issues caused by missing dependencies or Go version differences
Minimal Dockerfile configuration
Below is a minimal Dockerfile for a Go app.
# Build stage
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o blog-api main.go
# Runtime stage (lightweight image)
FROM gcr.io/distroless/base-debian11
WORKDIR /app
COPY /app/blog-api .
COPY .env .env
EXPOSE 8080
ENTRYPOINT ["/app/blog-api"]
How to build and run
docker build -t go-gin-blog-api .
docker run -p 8080:8080 --env-file .env go-gin-blog-api
If it starts successfully, you can verify access with curl as before.
Conclusion
Up to this point, we’ve been developing a blog API using Go × Gin.
We started in Part 1 with basic design using an MVC structure, then in Part 2 we implemented a robust testing strategy using mocks and integration tests.
In Part 3, we prepared mechanisms that can withstand operation, such as configuration, logging, graceful shutdown, CI introduction, and Dockerization, all with production quality in mind.
What this series aimed to convey is that “getting an app to run and being able to keep growing it are two different things.”
Writing code that runs is important, but in addition to that, things like:
- Design that is easy for a team to maintain
- Ease of switching configuration and environments
- Observability that allows quick response when problems occur
- A testing setup that is resilient to change
- CI introduction that enables safe releases
—this kind of “solid groundwork” is what strengthens you as a developer.
From Here On
This series is one milestone, but technical improvements start from here.
- Full-fledged database design
- Introducing authentication and authorization
- Integration with a frontend (for example, Next.js)
- Migrating to production deployment environments (Fly.io, Render, GCP, etc.)
- Monitoring (Prometheus, Grafana) and alert design
There are many such topics.
We plan to cover these on this site as opportunities arise.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Complete Guide to Web Accessibility: From Automated Testing with Lighthouse / axe and Defining WCAG Criteria to Keyboard Operation and Screen Reader Support
2023/11/21Introduction to Automating Development Work: A Complete Guide to ETL (Python), Bots (Slack/Discord), CI/CD (GitHub Actions), and Monitoring (Sentry/Datadog)
2024/02/12Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
2024/03/07CI/CD Strategies to Accelerate and Automate Your Development Flow: Leveraging Caching, Parallel Execution, and AI Reviews
2024/03/12Strengthening Dependency Security: Best Practices for Vulnerability Scanning, Automatic Updates, and OSS License Management
2024/01/29Getting Started with Building Web Servers Using Go × Echo: Learn Simple, High‑Performance API Development in the Shortest Time
2023/12/03Go × Gin Advanced: Practical Techniques and Scalable API Design
2023/11/29Go × Gin Basics: A Thorough Guide to Building a High-speed API Server
2023/11/23




