Bringing a Go + Gin App Up to Production Quality: From Configuration and Structure to CI

  • github
    github
  • gin
    gin
  • golang
    golang
Published on 2023/12/06

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.

https://shinagawa-web.com/en/blogs/go-gin-mvc-basics

https://shinagawa-web.com/en/blogs/go-gin-mvc-testing

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.

https://github.com/shinagawa-web/go-gin-blog-api

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.

config/config.go
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

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.

https://github.com/uber-go/zap

Steps to introduce a zap logger into a Gin app

logger/logger.go
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

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.

Image from Gyazo

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.

internal/server/server.go
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:

main.go
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.

main.go
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.

internal/server/server.go
	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.

Image from Gyazo

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 run and go build
  • Running go test ./... and linters
  • Starting development with .env loading
  • 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 run
  • make dev: development run with environment variables
  • make test: run all tests
  • make 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 analysis
  • go test -v ./...: run tests for all packages
  • -v option: 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.

Image from Gyazo

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.

Image from Gyazo

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.

Dockerfile
# 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 --from=builder /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.

Image from Gyazo

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.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form