Building a High-Performance REST API in Go with Fiber: Routing, Middleware, and Docker Deployment
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 →