Go is not a complicated language. The standard library covers most of what you need for HTTP servers, and the ecosystem fills the gaps without much ceremony. What trips people up is not the language itself but the gap between a working local server and something you would actually trust in production: structured logging, graceful shutdown, database connection pooling, environment-aware configuration, a deployment pipeline that does not require manual steps.
I have built Go services for FinTech platforms, SaaS products and internal tooling across several production environments. The patterns I reach for consistently are not the ones from tutorials. They are the ones that held up under real load, survived on-call incidents and did not require archaelogy to understand six months later.
This article walks through building a REST API in Go from an empty directory to a running production deployment. It covers the decisions that matter and skips the ones that do not.
Project Structure
Go does not enforce project structure. That freedom is a liability on any project with more than one contributor. Settle on a layout before writing any application code and enforce it consistently.
The layout I use for HTTP services is straightforward:
myapi/
cmd/
api/
main.go
internal/
handler/
middleware/
store/
domain/
config/
config.go
Makefile
Dockerfile
.env.example
cmd/api/main.go is the entry point. It initialises dependencies and starts the server. internal/ contains everything that should not be imported by other packages: handlers, middleware, data access, domain types. config/ handles environment-based configuration.
The internal/ convention is enforced by the Go compiler. Packages inside internal/ cannot be imported from outside the module. Use it. It prevents the gradual erosion of package boundaries that happens when everything is exported for convenience.
Configuration
Hard-coded configuration is the first thing that breaks when you move from local to production. Read everything that varies by environment from environment variables, validated at startup.
// config/config.go
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port string
DatabaseURL string
LogLevel string
Environment string
}
func Load() (*Config, error) {
cfg := &Config{
Port: getEnv("PORT", "8080"),
DatabaseURL: mustGetEnv("DATABASE_URL"),
LogLevel: getEnv("LOG_LEVEL", "info"),
Environment: getEnv("ENVIRONMENT", "development"),
}
return cfg, nil
}
func mustGetEnv(key string) string {
val := os.Getenv(key)
if val == "" {
panic(fmt.Sprintf("required environment variable %s is not set", key))
}
return val
}
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
Panic on missing required variables at startup. A service that starts without its database URL and then fails on the first request is harder to debug than one that refuses to start at all. Fail fast and loudly.
The Entry Point
main.go should be short. Its job is to wire dependencies together, not to contain logic.
// cmd/api/main.go
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"myapi/config"
"myapi/internal/handler"
"myapi/internal/middleware"
"myapi/internal/store"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load config", "err", err)
os.Exit(1)
}
db, err := store.Connect(cfg.DatabaseURL)
if err != nil {
logger.Error("failed to connect to database", "err", err)
os.Exit(1)
}
defer db.Close()
accountStore := store.NewAccountStore(db)
accountHandler := handler.NewAccountHandler(accountStore, logger)
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/accounts/{id}", accountHandler.GetByID)
mux.HandleFunc("POST /v1/accounts", accountHandler.Create)
stack := middleware.Chain(
middleware.RequestID,
middleware.Logger(logger),
middleware.RecoverPanic(logger),
)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: stack(mux),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("server starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
logger.Info("server shutting down")
if err := srv.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "err", err)
}
}
A few things worth noting here. Server timeouts are set explicitly. A Go HTTP server has no default timeouts, which means a slow client can hold a connection open indefinitely. Set ReadTimeout, WriteTimeout and IdleTimeout on every production server.
Graceful shutdown handles in-flight requests before the process exits. Without it, a deployment or a container restart drops active connections. The 30-second timeout gives long-running requests time to complete before forcing exit.
Middleware
Middleware in Go's standard library is a function that takes an http.Handler and returns an http.Handler. The Chain helper composes them cleanly.
// internal/middleware/middleware.go
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(middlewares ...Middleware) Middleware {
return func(next http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
next = middlewares[i](next)
}
return next
}
}
// internal/middleware/logger.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
func Logger(logger *slog.Logger) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rw, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.status,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", r.Header.Get("X-Request-ID"),
)
})
}
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(status int) {
rw.status = status
rw.ResponseWriter.WriteHeader(status)
}
Structured logging with slog writes JSON to stdout. In production, your log aggregator (Datadog, CloudWatch, Loki) ingests that JSON and makes every field queryable. Unstructured logs are harder to search and cannot be reliably parsed at scale.
Handlers and Error Handling
Handlers should be thin. They decode input, call a store or service, and encode output. Business logic does not belong in handlers.
// internal/handler/account.go
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"myapi/internal/domain"
"myapi/internal/store"
)
type AccountHandler struct {
store store.AccountStore
logger *slog.Logger
}
func NewAccountHandler(s store.AccountStore, l *slog.Logger) *AccountHandler {
return &AccountHandler{store: s, logger: l}
}
func (h *AccountHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if id == "" {
writeError(w, http.StatusBadRequest, "account id is required")
return
}
account, err := h.store.GetByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "account not found")
return
}
h.logger.Error("failed to get account", "id", id, "err", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
writeJSON(w, http.StatusOK, account)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
Sentinel errors like store.ErrNotFound decouple the handler from storage implementation details. The handler does not need to know whether the store is Postgres, a cache or a mock. It checks the error type and responds accordingly.
Never return internal error details to the client. Log the full error server-side and return a generic message. Leaking stack traces or database errors to API consumers is both a security and a support problem.
Database Access
Use pgx for Postgres. It is faster than database/sql with the standard Postgres driver and exposes a cleaner API for common operations.
// internal/store/store.go
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("not found")
func Connect(databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
cfg.MaxConns = 20
cfg.MinConns = 2
return pgxpool.NewWithConfig(context.Background(), cfg)
}
// internal/store/account.go
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"myapi/internal/domain"
)
type AccountStore interface {
GetByID(ctx context.Context, id string) (*domain.Account, error)
Create(ctx context.Context, account *domain.Account) error
}
type pgAccountStore struct {
db *pgxpool.Pool
}
func NewAccountStore(db *pgxpool.Pool) AccountStore {
return &pgAccountStore{db: db}
}
func (s *pgAccountStore) GetByID(ctx context.Context, id string) (*domain.Account, error) {
var a domain.Account
err := s.db.QueryRow(ctx,
`SELECT id, owner_id, currency, balance, created_at FROM accounts WHERE id = $1`,
id,
).Scan(&a.ID, &a.OwnerID, &a.Currency, &a.Balance, &a.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return &a, nil
}
Define AccountStore as an interface. The handler depends on the interface, not the concrete Postgres implementation. Tests can inject a mock store without a database, and swapping storage backends later does not require changing handlers.
Connection pooling via pgxpool is non-negotiable in production. A new TCP connection to Postgres costs 20 to 50 milliseconds. Without pooling, under any meaningful load, your database connection overhead will dominate your response times.
The Dockerfile
Keep the image small. Multi-stage builds compile in one image and copy the binary into a minimal runtime image.
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o api ./cmd/api
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
distroless/static is a minimal base image with no shell, no package manager and no unnecessary libraries. The attack surface is smaller and the image is typically under 20MB. CGO_ENABLED=0 produces a statically linked binary with no C dependencies, which is what makes the distroless base viable.
The -ldflags="-s -w" flag strips debug information and the symbol table from the binary, reducing its size by roughly 30 percent.
Deployment
The Makefile ties local and CI workflows together.
.PHONY: build run test docker-build docker-push deploy
IMAGE := ghcr.io/yourorg/myapi
TAG := $(shell git rev-parse --short HEAD)
build:
go build -o bin/api ./cmd/api
test:
go test ./... -race -count=1
docker-build:
docker build -t $(IMAGE):$(TAG) .
docker-push:
docker push $(IMAGE):$(TAG)
deploy:
kubectl set image deployment/myapi api=$(IMAGE):$(TAG)
Tag images with the short git SHA. It gives you an unambiguous link between what is running in production and the commit it was built from. Mutable tags like latest make rollbacks ambiguous and incident investigation harder.
A minimal GitHub Actions pipeline that builds, tests and pushes on every merge to main:
name: deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Test
run: go test ./... -race -count=1
- name: Log in to registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build and push
run: |
TAG=$(git rev-parse --short HEAD)
docker build -t ghcr.io/${{ github.repository }}:$TAG .
docker push ghcr.io/${{ github.repository }}:$TAG
- name: Deploy
run: |
TAG=$(git rev-parse --short HEAD)
kubectl set image deployment/myapi api=ghcr.io/${{ github.repository }}:$TAG
env:
KUBECONFIG: ${{ secrets.KUBECONFIG }}
Tests run before the build. If they fail, nothing ships. The deploy step updates the Kubernetes deployment with the new image, which triggers a rolling update with zero downtime if your deployment is configured with more than one replica.
Conclusion
A production Go API is not significantly more complex than a local one. The gap is in the details: timeouts that are set, errors that are logged correctly, configuration that does not live in the binary, a deployment pipeline that does not require a human to run steps in order.
The patterns in this article are not novel. They are the result of running Go services under real load and learning which shortcuts cause incidents. Graceful shutdown, structured logging, interface-based storage, connection pooling, distroless images and git-SHA tagging are all standard practice for a reason.
Start with these foundations and you will spend your time building features rather than debugging infrastructure. The decisions that feel like overhead at the start are the ones that make production incidents tractable when they happen, and they will happen.