Backend development

Building a High-Performance REST API in Go with Fiber: Routing, Middleware, and Docker Deployment

Ruslan Ismailov Published 14 min read
B

1. Introduction — Why Fiber in 2026

A Go Fiber REST API is today one of the most popular stacks for building high-load services. Fiber is built on top of fasthttp — the fastest HTTP engine for Go — giving it an edge over net/http in scenarios with thousands of concurrent connections. According to the latest TechEmpower benchmarks, Fiber consistently ranks in the top 5 frameworks for JSON serialization and plaintext request metrics.

Why developers choose Fiber framework 2026:

  • Syntax familiar to Express.js developers — minimal learning curve.
  • Built-in middleware: logging, CORS, rate limiter, recovery.
  • Active ecosystem: adapters for Redis, WebSocket, SSE, gRPC.
  • Zero allocations on the hot path thanks to fasthttp.

2. Project Architecture

Before writing any code, it's important to define the folder structure. We use a clean architecture lite approach — without excessive abstractions, but with a clear separation of layers:

myapi/
├── cmd/
│   └── server/
│       └── main.go          # entry point
├── internal/
│   ├── config/              # config from ENV
│   ├── handler/             # HTTP handlers
│   ├── middleware/          # custom middleware
│   ├── repository/          # database layer
│   ├── service/             # business logic
│   └── model/               # data structures
├── pkg/
│   ├── database/            # pgx and Redis initialization
│   └── validator/           # validation helpers
├── migrations/              # SQL migrations
├── Dockerfile
├── docker-compose.yml
└── .env.example

Layers interact strictly top-down: handler → service → repository. Middleware is injected into the router before the handlers.

3. Defining Routes and Groups: API Versioning

Proper versioning is the foundation of long-term maintainability. We use /api/v1 and /api/v2 prefixes via Fiber groups:

// internal/handler/routes.go
package handler

import (
    "github.com/gofiber/fiber/v2"
    "myapi/internal/middleware"
)

func RegisterRoutes(app *fiber.App, h *UserHandler) {
    api := app.Group("/api")

    v1 := api.Group("/v1", middleware.Logger())
    {
        users := v1.Group("/users")
        users.Get("/",       h.ListUsers)
        users.Post("/",      h.CreateUser)
        users.Get("/:id",    h.GetUser)
        users.Put("/:id",    h.UpdateUser)
        users.Delete("/:id", h.DeleteUser)
    }

    // Protected v1 routes
    protected := v1.Group("/admin", middleware.JWTAuth())
    protected.Get("/stats", h.GetStats)
}

Groups allow you to apply middleware selectively: JWTAuth is attached only to /admin, leaving public endpoints unaffected.

4. Implementing Middleware: Logging, JWT, Rate Limiting

4.1 Request Logging

// internal/middleware/logger.go
package middleware

import (
    "github.com/gofiber/fiber/v2"
    fiberLogger "github.com/gofiber/fiber/v2/middleware/logger"
)

func Logger() fiber.Handler {
    return fiberLogger.New(fiberLogger.Config{
        Format: "[${time}] ${status} - ${latency} ${method} ${path}\n",
    })
}

4.2 JWT Authentication

// internal/middleware/jwt.go
package middleware

import (
    "github.com/gofiber/fiber/v2"
    jwtware "github.com/gofiber/contrib/jwt"
    "os"
)

func JWTAuth() fiber.Handler {
    return jwtware.New(jwtware.Config{
        SigningKey: jwtware.SigningKey{
            Key: []byte(os.Getenv("JWT_SECRET")),
        },
        ErrorHandler: func(c *fiber.Ctx, err error) error {
            return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
                "error": "invalid or expired token",
            })
        },
    })
}

4.3 Rate Limiting

// internal/middleware/ratelimit.go
package middleware

import (
    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/limiter"
    "time"
)

func RateLimiter() fiber.Handler {
    return limiter.New(limiter.Config{
        Max:        100,          // 100 requests
        Expiration: time.Minute,  // per minute per IP
        KeyGenerator: func(c *fiber.Ctx) string {
            return c.IP()
        },
        LimitReached: func(c *fiber.Ctx) error {
            return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
                "error": "rate limit exceeded",
            })
        },
    })
}

5. Connecting PostgreSQL via pgx and Redis for Caching

5.1 PostgreSQL Initialization (pgx)

// pkg/database/postgres.go
package database

import (
    "context"
    "fmt"
    "os"

    "github.com/jackc/pgx/v5/pgxpool"
)

func NewPostgresPool(ctx context.Context) (*pgxpool.Pool, error) {
    dsn := fmt.Sprintf(
        "postgres://%s:%s@%s:%s/%s?sslmode=disable",
        os.Getenv("POSTGRES_USER"),
        os.Getenv("POSTGRES_PASSWORD"),
        os.Getenv("POSTGRES_HOST"),
        os.Getenv("POSTGRES_PORT"),
        os.Getenv("POSTGRES_DB"),
    )
    pool, err := pgxpool.New(ctx, dsn)
    if err != nil {
        return nil, fmt.Errorf("pgxpool.New: %w", err)
    }
    if err = pool.Ping(ctx); err != nil {
        return nil, fmt.Errorf("postgres ping: %w", err)
    }
    return pool, nil
}

5.2 Response Caching with Redis

// pkg/database/redis.go
package database

import (
    "context"
    "fmt"
    "os"
    "time"

    "github.com/redis/go-redis/v9"
)

func NewRedisClient() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:     fmt.Sprintf("%s:%s", os.Getenv("REDIS_HOST"), os.Getenv("REDIS_PORT")),
        Password: os.Getenv("REDIS_PASSWORD"),
        DB:       0,
    })
}

// CacheResponse caches GET responses for a given TTL
func CacheResponse(rdb *redis.Client, ttl time.Duration) fiber.Handler {
    return func(c *fiber.Ctx) error {
        if c.Method() != "GET" {
            return c.Next()
        }
        key := "cache:" + c.OriginalURL()
        cached, err := rdb.Get(context.Background(), key).Bytes()
        if err == nil {
            c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8)
            return c.Status(fiber.StatusOK).Send(cached)
        }
        if err := c.Next(); err != nil {
            return err
        }
        rdb.Set(context.Background(), key, c.Response().Body(), ttl)
        return nil
    }
}

The cache middleware reads the response from Redis using the key cache:{URL}. On a cache miss, it executes the handler and stores the response body with the specified TTL — a classic cache-aside pattern for Go API PostgreSQL Redis.

6. Input Validation and Error Handling

For validation we use the go-playground/validator library. Let's create a unified helper:

// pkg/validator/validator.go
package validator

import (
    "fmt"
    "github.com/go-playground/validator/v10"
)

var validate = validator.New()

type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

func Validate(s any) []ValidationError {
    var errors []ValidationError
    err := validate.Struct(s)
    if err == nil {
        return nil
    }
    for _, e := range err.(validator.ValidationErrors) {
        errors = append(errors, ValidationError{
            Field:   e.Field(),
            Message: fmt.Sprintf("failed on tag '%s'", e.Tag()),
        })
    }
    return errors
}

Example usage in the CreateUser handler:

// internal/handler/user.go
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
    var req model.CreateUserRequest
    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
            "error": "cannot parse body",
        })
    }
    if errs := validator.Validate(req); errs != nil {
        return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{
            "errors": errs,
        })
    }
    user, err := h.svc.CreateUser(c.Context(), req)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
            "error": err.Error(),
        })
    }
    return c.Status(fiber.StatusCreated).JSON(user)
}

7. Writing Tests for Handlers

Fiber supports testing via the app.Test() method, which eliminates the need for a real network connection:

// internal/handler/user_test.go
package handler_test

import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/gofiber/fiber/v2"
    "github.com/stretchr/testify/assert"
    "myapi/internal/handler"
    "myapi/internal/service/mocks"
)

func TestCreateUser_Success(t *testing.T) {
    mockSvc := new(mocks.UserService)
    mockSvc.On("CreateUser", mock.Anything, mock.AnythingOfType("model.CreateUserRequest")).
        Return(&model.User{ID: 1, Email: "test@example.com"}, nil)

    h := handler.NewUserHandler(mockSvc)
    app := fiber.New()
    app.Post("/users", h.CreateUser)

    body, _ := json.Marshal(map[string]string{
        "email":    "test@example.com",
        "password": "secret123",
    })
    req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body))
    req.Header.Set("Content-Type", "application/json")

    resp, err := app.Test(req, -1)
    assert.NoError(t, err)
    assert.Equal(t, http.StatusCreated, resp.StatusCode)
    mockSvc.AssertExpectations(t)
}

We mock the service layer using testify/mock, isolating the handler from the database. This speeds up test execution and makes them deterministic.

8. Containerization: Multi-Stage Dockerfile and Docker Compose

8.1 Dockerfile

# Dockerfile

# ---- Build stage ----
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 server ./cmd/server

# ---- Run stage ----
FROM gcr.io/distroless/static-debian12
WORKDIR /app

COPY --from=builder /app/server .
COPY --from=builder /app/migrations ./migrations

EXPOSE 3000
USER nonroot:nonroot
ENTRYPOINT ["/app/server"]

Multi-stage builds reduce the final image size from ~300 MB to ~10–15 MB. The -s -w flags strip debug information, further shrinking the binary.

8.2 docker-compose.yml

# docker-compose.yml
version: "3.9"

services:
  api:
    build: .
    container_name: myapi
    ports:
      - "3000:3000"
    env_file: .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    container_name: myapi_postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    container_name: myapi_redis
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

The condition: service_healthy directive ensures the API container only starts after PostgreSQL and Redis pass their healthchecks.

9. CI/CD Basics: Automated Build and Image Publishing

Let's set up a simple GitHub Actions pipeline that runs tests, builds the image, and pushes it to Docker Hub on every push to main:

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.23"
      - name: Run tests
        run: go test ./... -race -coverprofile=coverage.out
      - name: Upload coverage
        uses: codecov/codecov-action@v4

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/myapi:latest
            ${{ secrets.DOCKER_USERNAME }}/myapi:${{ github.sha }}

The build-and-push job only runs if all tests pass, preventing a broken image from being published. Images are tagged with the commit SHA to allow rollbacks.

10. Conclusion

We've built a complete high-performance Go API using Fiber: from versioned routing to Redis caching, Docker containerization, and an automated CI/CD pipeline. Key takeaways:

  • Fiber delivers minimal latency thanks to fasthttp and its zero-alloc approach.
  • Route groups and targeted middleware application keep the codebase scalable.
  • pgx + Redis is a proven combination for Go REST APIs with response-level caching.
  • A multi-stage Dockerfile reduces image size by an order of magnitude, improving security and deployment speed.
  • GitHub Actions automates testing and publishing without manual intervention.

The next steps are adding distributed tracing (OpenTelemetry), horizontal scaling via Kubernetes, and migrations via golang-migrate. But the architecture described here can already handle tens of thousands of requests per second on modest hardware.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →