Building a REST API with Go and Deploying It to Production from Scratch

Software Engineer Dublin

Software Engineer Dublin

Engineer Built To Scale

Beyond The Code

What They Say

Let's Build

Message Received

Privacy Policy

Terms Of Use

Cookie Policy

Disclaimer

Latest Updates

Selected Work

Showcase

What We Offer

Industries We Serve

Beyond The Screen

Built Together

View View
Nben Malla
Nben Malla

Software Engineer

A software engineer who builds systems that scale, modernizes platforms that matter and ships code that holds up long after the project ends.

Based in Dublin, Ireland, with a presence across Bristol, Groningen and Kathmandu, the work spans FinTech platforms, SaaS products and open source contributions, collaborating with engineering teams across Nepal, Ireland, the Netherlands, New Zealand and the United States.

From leading legacy modernization for global banking clients to architecting microservices in Go, Java and Python, the focus has always been the same. Understand the problem deeply, build it right and make sure the people depending on it never have to think about it failing.

  • Read Article Read Article

    Tutorials 4 mins

    Building a REST API with Go and Deploying It to Production from Scratch

    Nben M. 10 Aug, 2025 4 mins

    Building a REST API with Go and Deploying It to Production from Scratch

    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:

    markdown
    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.

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

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

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

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

    go
    // 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)
    }
    go
    // 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.

    Dockerfile ·
    markdown
    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.

    Makefile ·
    markdown
    .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:

    yaml
    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.