Fiber #

Fiber is a web framework for Go inspired by Express.js. Its API feels familiar to Node.js developers — middleware, routing with app.Get("/path", handler), and c.JSON() for responses — yet its performance matches Gin because it’s built on fasthttp (not the standard net/http). For teams moving from Node.js to Go, Fiber offers far friendlier ergonomics than Gin or the standard net/http.

This article covers a complete Docker Compose setup for Fiber local development, from multi-stage Dockerfiles, hot reload with Air, to database integration and best practices. After reading it, you’ll have a template you can use directly for new Fiber projects.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Go 1.22+ (optional, for host-side development)
  • Git

A standard Fiber project structure:

my-fiber-app/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── handler/
│   │   └── user.go
│   ├── service/
│   │   └── user.go
│   ├── repository/
│   │   └── user.go
│   └── model/
│       └── user.go
├── go.mod
├── go.sum
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .air.toml
├── .env
└── .dockerignore

Fundamental Differences: Fiber vs Gin #

Before getting technical, understand why Fiber differs from Gin. This affects many Dockerfile and Compose decisions.

AspectGinFiber
HTTP engineStandard net/httpfasthttp (custom)
API styleExpress-like, idiomatic GoExpress-like, closer to JS
Middlewaregin.HandlerFuncfiber.Handler (plain functions)
Context*gin.Context*fiber.Ctx (method-rich)
Body bindingc.ShouldBindJSONc.BodyParser
Static filesr.Static("/uploads", "./uploads")app.Static("/uploads", "./uploads")
PerformanceVery fastSlightly faster in synthetic benchmarks
net/http middleware compatibilityYesNo (needs an adapter)

Because Fiber uses fasthttp (not net/http), standard net/http middleware can’t be used directly. You need the adapter github.com/gofiber/adaptor to use middleware like gorilla/handlers or prometheus/client_golang. For most greenfield applications, this isn’t a problem — Fiber’s own middleware is complete enough.

Multi-Stage Dockerfile #

The multi-stage pattern is the same as Go in general: a builder stage for compilation, a slim runtime stage.

# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Install OS tools needed during the build
RUN apk add --no-cache git ca-certificates

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build a static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server

# Second stage: a slim runtime image
FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app

# Non-root user
RUN addgroup -S app && adduser -S app -G app
USER app:app

COPY --from=builder /out/server /app/server

EXPOSE 3000

ENTRYPOINT ["/app/server"]

The final image is < 20 MB. Great for deploying to Cloud Run, Fly.io, or Kubernetes.

A Development Dockerfile #

For development with hot reload, use Air.

# Dockerfile.dev
FROM golang:1.22-alpine

WORKDIR /app

RUN apk add --no-cache git curl bash

# Install Air
RUN go install github.com/air-verse/air@latest

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

EXPOSE 3000

CMD ["air", "-c", ".air.toml"]

The Air configuration (.air.toml):

root = "."
tmp_dir = "tmp"

[build]
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ./cmd/server"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  include_dir = ["cmd", "internal"]
  include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml"]
  exclude_regex = ["_test.go"]
  exclude_unchanged = true

[log]
  time = true

[misc]
  clean_on_exit = true

docker-compose.yml #

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: fiber-app:dev
    container_name: fiber-api
    ports:
      - "3000:3000"
    volumes:
      - ./cmd:/app/cmd
      - ./internal:/app/internal
      - ./go.mod:/app/go.mod
      - ./go.sum:/app/go.sum
      - go-build:/app/tmp
    environment:
      - APP_ENV=development
      - SERVER_PORT=3000
      - DATABASE_URL=postgres://app:pass@db:5432/fiberapp?sslmode=disable
      - REDIS_URL=redis://cache:6379/0
      - JWT_SECRET=local-dev-secret-change-me
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    command: air -c .air.toml

  db:
    image: postgres:16-alpine
    container_name: fiber-db
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=fiberapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d fiberapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: fiber-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

volumes:
  db-data:
  cache-data:
  go-build:

An Example Fiber Application #

cmd/server/main.go:

package main

import (
    "context"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/gofiber/fiber/v2"
    "github.com/gofiber/fiber/v2/middleware/cors"
    "github.com/gofiber/fiber/v2/middleware/logger"
    "github.com/gofiber/fiber/v2/middleware/recover"
    "github.com/gofiber/fiber/v2/middleware/requestid"
    "github.com/redis/go-redis/v9"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "myapp/internal/handler"
    "myapp/internal/model"
    "myapp/internal/repository"
    "myapp/internal/service"
)

func main() {
    // Database with retry
    var db *gorm.DB
    var err error
    dsn := os.Getenv("DATABASE_URL")
    for i := 0; i < 30; i++ {
        db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
        if err == nil {
            break
        }
        log.Printf("waiting for db: %v", err)
        time.Sleep(2 * time.Second)
    }
    if err != nil {
        log.Fatal("db connection failed: ", err)
    }
    if err := db.AutoMigrate(&model.User{}); err != nil {
        log.Fatal("migrate failed: ", err)
    }

    // Redis
    rdb := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_URL"),
    })

    // Layers
    userRepo := repository.NewUserRepository(db)
    userSvc := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userSvc)

    // Fiber app
    app := fiber.New(fiber.Config{
        AppName:               "my-fiber-app",
        DisableStartupMessage: false,
        ErrorHandler: func(c *fiber.Ctx, err error) error {
            code := fiber.StatusInternalServerError
            if e, ok := err.(*fiber.Error); ok {
                code = e.Code
            }
            return c.Status(code).JSON(fiber.Map{"error": err.Error()})
        },
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    })

    // Middleware
    app.Use(requestid.New())
    app.Use(logger.New(logger.Config{
        Format:     "${time} ${status} ${method} ${path} ${latency} ${locals:requestid}\n",
        TimeFormat: "2006-01-02T15:04:05Z07:00",
    }))
    app.Use(recover.New())
    app.Use(cors.New(cors.Config{
        AllowOrigins: "*",
        AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
        AllowHeaders: "Content-Type,Authorization,X-Request-ID",
    }))

    // Health
    app.Get("/health", func(c *fiber.Ctx) error {
        return c.JSON(fiber.Map{
            "status": "ok",
            "time":   time.Now().Format(time.RFC3339),
        })
    })

    // Routes
    api := app.Group("/api/v1")
    api.Post("/users", userHandler.Create)
    api.Get("/users/:id", userHandler.Get)

    // Start the server with graceful shutdown
    go func() {
        if err := app.Listen(":3000"); err != nil {
            log.Printf("server stopped: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := app.ShutdownWithContext(ctx); err != nil {
        log.Fatal(err)
    }
}

Handler (internal/handler/user.go):

package handler

import (
    "errors"
    "strconv"

    "github.com/gofiber/fiber/v2"

    "myapp/internal/service"
)

type UserHandler struct {
    svc *service.UserService
}

func NewUserHandler(svc *service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

type createUserRequest struct {
    Email    string `json:"email"`
    Name     string `json:"name"`
    Password string `json:"password"`
}

type errorResponse struct {
    Error string `json:"error"`
}

func (h *UserHandler) Create(c *fiber.Ctx) error {
    var req createUserRequest
    if err := c.BodyParser(&req); err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(errorResponse{Error: err.Error()})
    }
    if req.Email == "" || req.Name == "" || len(req.Password) < 8 {
        return c.Status(fiber.StatusBadRequest).JSON(errorResponse{
            Error: "email, name required and password min 8 chars",
        })
    }
    u, err := h.svc.Register(c.UserContext(), req.Email, req.Name, req.Password)
    if err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(errorResponse{Error: err.Error()})
    }
    return c.Status(fiber.StatusCreated).JSON(u)
}

func (h *UserHandler) Get(c *fiber.Ctx) error {
    id, err := strconv.ParseUint(c.Params("id"), 10, 64)
    if err != nil {
        return c.Status(fiber.StatusBadRequest).JSON(errorResponse{Error: "invalid id"})
    }
    u, err := h.svc.Get(c.UserContext(), id)
    if err != nil {
        return c.Status(fiber.StatusInternalServerError).JSON(errorResponse{Error: err.Error()})
    }
    if u == nil {
        return c.Status(fiber.StatusNotFound).JSON(errorResponse{Error: "user not found"})
    }
    return c.JSON(u)
}

Service (internal/service/user.go):

package service

import (
    "context"
    "errors"
    "strings"

    "golang.org/x/crypto/bcrypt"

    "myapp/internal/model"
    "myapp/internal/repository"
)

type UserService struct {
    repo *repository.UserRepository
}

func NewUserService(repo *repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Register(ctx context.Context, email, name, password string) (*model.User, error) {
    email = strings.ToLower(strings.TrimSpace(email))
    existing, _ := s.repo.FindByEmail(ctx, email)
    if existing != nil {
        return nil, errors.New("email already registered")
    }
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }
    u := &model.User{
        Email:    email,
        Name:     strings.TrimSpace(name),
        Password: string(hashed),
    }
    if err := s.repo.Create(ctx, u); err != nil {
        return nil, err
    }
    return u, nil
}

func (s *UserService) Get(ctx context.Context, id uint64) (*model.User, error) {
    return s.repo.FindByID(ctx, id)
}

Repository (internal/repository/user.go):

package repository

import (
    "context"
    "errors"

    "gorm.io/gorm"

    "myapp/internal/model"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
    return r.db.WithContext(ctx).Create(u).Error
}

func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).First(&u, id).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &u, err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*model.User, error) {
    var u model.User
    err := r.db.WithContext(ctx).Where("email = ?", email).First(&u).Error
    if errors.Is(err, gorm.ErrRecordNotFound) {
        return nil, nil
    }
    return &u, err
}

Model (internal/model/user.go):

package model

import "time"

type User struct {
    ID        uint64    `gorm:"primaryKey" json:"id"`
    Email     string    `gorm:"size:255;not null;uniqueIndex" json:"email"`
    Name      string    `gorm:"size:255;not null" json:"name"`
    Password  string    `gorm:"size:255;not null" json:"-"`
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

func (User) TableName() string {
    return "users"
}

Fiber’s Built-in Middleware #

Fiber has many official, ready-to-use middlewares:

MiddlewareFunction
recoverCatch panics, return 500
loggerLog every request
requestidGenerate/extract request IDs
corsHandle CORS
compressGzip/deflate responses
limiterRate limiting
jwtJWT validation
basicauthHTTP Basic Auth
cacheHTTP cache headers
etagETag headers
monitorMetrics endpoint (needs an ORM)
csrfCSRF tokens
helmetSecurity headers
healthcheckLiveness/readiness endpoints

A safe middleware combination for APIs:

app.Use(requestid.New())
app.Use(recover.New())
app.Use(logger.New())
app.Use(cors.New())
app.Use(helmet.New())
app.Use(compress.New())
app.Use(etag.New())

helmet sets security headers (X-Frame-Options, X-Content-Type-Options, etc.). compress reduces response size. etag enables HTTP caching.

Validation with go-playground/validator #

Like Gin, Fiber also uses go-playground/validator. But the tag isn’t binding, it’s validate.

import "github.com/go-playground/validator/v10"

var validate = validator.New()

type createUserRequest struct {
    Email    string   `json:"email" validate:"required,email"`
    Name     string   `json:"name" validate:"required,min=2,max=100"`
    Password string   `json:"password" validate:"required,min=8,max=72"`
    Roles    []string `json:"roles" validate:"required,min=1,dive,oneof=admin user guest"`
    Age      int      `json:"age" validate:"gte=18,lte=120"`
}

func (h *UserHandler) Create(c *fiber.Ctx) error {
    var req createUserRequest
    if err := c.BodyParser(&req); err != nil {
        return c.Status(400).JSON(fiber.Map{"error": err.Error()})
    }
    if err := validate.Struct(req); err != nil {
        return c.Status(400).JSON(fiber.Map{"error": err.Error()})
    }
    // continue
}

Build and Run #

# Build the development image
docker compose build

# Run all services
docker compose up -d

# View logs
docker compose logs -f api

# Shell into the container
docker compose exec api sh

# Stop
docker compose down

# Full reset (remove volumes)
docker compose down -v

Access:

  • API: http://localhost:3000
  • Health check: http://localhost:3000/health
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379

Test:

# Register
curl -X POST http://localhost:3000/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Siti","password":"password123"}'

# Get a user
curl http://localhost:3000/api/v1/users/1

WebSockets with Fiber #

Fiber has built-in WebSocket support via the github.com/gofiber/contrib/websocket middleware.

import "github.com/gofiber/contrib/websocket"

app.Get("/ws", websocket.New(func(c *websocket.Conn) {
    defer c.Close()
    for {
        mt, msg, err := c.ReadMessage()
        if err != nil {
            return
        }
        log.Printf("recv: %s", msg)
        if err := c.WriteMessage(mt, msg); err != nil {
            return
        }
    }
}))

For real-time chat or notifications, WebSockets in Fiber are very easy to set up. Alternatively, use Server-Sent Events (SSE) for one-way communication.

File Uploads and Static Files #

// Single file upload
app.Post("/upload", func(c *fiber.Ctx) error {
    file, err := c.FormFile("file")
    if err != nil {
        return c.Status(400).JSON(fiber.Map{"error": err.Error()})
    }
    if file.Size > 5*1024*1024 {
        return c.Status(413).JSON(fiber.Map{"error": "file too large"})
    }
    filename := fmt.Sprintf("./uploads/%d_%s", time.Now().UnixNano(), file.Filename)
    if err := c.SaveFile(file, filename); err != nil {
        return c.Status(500).JSON(fiber.Map{"error": err.Error()})
    }
    return c.JSON(fiber.Map{"filename": filename, "size": file.Size})
})

// Serve static files
app.Static("/uploads", "./uploads")
app.Static("/", "./public")

Testing with Testcontainers #

Like Gin, Fiber pairs well with Testcontainers for integration tests.

package handler_test

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

    "github.com/gofiber/fiber/v2"
    "github.com/stretchr/testify/assert"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "myapp/internal/handler"
    "myapp/internal/model"
    "myapp/internal/repository"
    "myapp/internal/service"
    "context"
)

func setupApp(t *testing.T) (*fiber.App, func()) {
    ctx := context.Background()
    pgC, err := postgres.RunContainer(ctx,
        postgres.WithDatabase("test"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
    )
    if err != nil {
        t.Fatal(err)
    }
    dsn, _ := pgC.ConnectionString(ctx, "sslmode=disable")
    db, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    db.AutoMigrate(&model.User{})

    repo := repository.NewUserRepository(db)
    svc := service.NewUserService(repo)
    h := handler.NewUserHandler(svc)

    app := fiber.New()
    app.Post("/users", h.Create)
    app.Get("/users/:id", h.Get)

    cleanup := func() {
        pgC.Terminate(ctx)
    }
    return app, cleanup
}

func TestCreateUser(t *testing.T) {
    app, cleanup := setupApp(t)
    defer cleanup()

    body := map[string]string{
        "email":    "[email protected]",
        "name":     "Test",
        "password": "password123",
    }
    b, _ := json.Marshal(body)
    req := httptest.NewRequest("POST", "/users", bytes.NewReader(b))
    req.Header.Set("Content-Type", "application/json")

    resp, err := app.Test(req, -1)
    assert.NoError(t, err)
    assert.Equal(t, 201, resp.StatusCode)

    bodyBytes, _ := io.ReadAll(resp.Body)
    assert.Contains(t, string(bodyBytes), "[email protected]")
}

app.Test() runs handlers in-memory without listening on a port — very fast for tests.

When Fiber Fits, and When It Doesn’t #

Use Fiber if:
  ✓ Developers are familiar with Express.js
  ✓ You need built-in WebSocket support
  ✓ You like more declarative APIs
  ✓ High-throughput microservices

Avoid Fiber if:
  ✗ You need standard net/http middleware
  ✗ Ecosystem libraries (e.g. the OpenTelemetry SDK) require net/http
  ✗ The team is strong in idiomatic Go and prefers net/http
  ✗ gRPC services

Because Fiber uses fasthttp, some Go libraries aren’t compatible. Examples: prometheus/client_golang (for metrics), otelhttp (for tracing), and some observability middleware. To use those libraries, you need an adapter or a custom implementation.

Best Practices #

Use Air for Hot Reload #

Without hot reload, Go feels slow for iteration. Air watches .go files and rebuilds automatically. Install via go install in Dockerfile.dev.

Cache Go Modules #

Always copy go.mod + go.sum first, then run go mod download. The dependency layer rarely changes, so caches hit more often.

Bind-Mount Source Code #

In development, mount source code into the container. The go-build volume caches build output so container restarts are faster.

Healthchecks and Retry Loops #

PostgreSQL and Redis must have healthchecks. The Fiber service must not start before db and cache are healthy. Add a retry loop in the Go code for the initial connection.

Separate Config from Code #

Use os.Getenv() for all configuration. Provide a .env.example in the repo. Don’t hardcode URLs, ports, or secrets.

Be Aware of fasthttp #

Fiber uses fasthttp — it’s not compatible with standard net/http libraries without an adapter. For metrics, tracing, and ecosystem middleware, check whether a Fiber adapter exists first.

Graceful Shutdown #

Add a signal handler so the server can shut down cleanly when the container restarts. Without it, active connections can be cut off.

Troubleshooting #

Air Doesn’t Detect Changes #

Make sure the bind mount includes the cmd/ and internal/ directories. On Mac/Windows with Docker Desktop, it can sometimes take a few seconds. Raise delay in .air.toml to 2000-3000 ms if rebuilds happen too often.

Database Connection Refused #

Use depends_on: condition: service_healthy in Compose, and add a retry loop in the Go code. depends_on alone isn’t enough because Docker only waits for containers to start, not for services to be ready.

net/http Middleware Doesn’t Work #

Fiber uses fasthttp, so standard net/http middleware can’t be used directly. Look for the Fiber version in github.com/gofiber/contrib or write a custom one.

Port 3000 Already in Use #

lsof -i :3000
# or
netstat -ano | findstr :3000

Stop the process or change the mapping in Compose ("3001:3000").

Memory Leaks #

Make sure every request allocating large resources is closed properly. For database connections, use a connection pool (sql.DB for database/sql, or pool configuration in GORM).

Summary #

  • Fiber is ideal for Node.js developers moving to Go — Express-like API, familiar ergonomics, performance on par with Gin.
  • Fiber uses fasthttp, not net/http. This makes standard Go ecosystem middleware not directly compatible. For observability and metrics, you need an adapter or a custom implementation.
  • Multi-stage Dockerfiles for production: a builder stage with the Go toolchain, a slim runtime stage with alpine. The final image is < 20 MB.
  • Dockerfile.dev for development: full toolchain + Air for hot reload.
  • Hot reload with Air: watches .go files, rebuilds, restarts. Configured via .air.toml.
  • Go layer caching: copy the manifest first, then the source code. Super-fast incremental builds.
  • Built-in middleware: recover, logger, requestid, cors, compress, etag, helmet, jwt, limiter. Just app.Use().
  • Built-in WebSockets via github.com/gofiber/contrib/websocket — very easy for real-time apps.
  • File uploads via c.FormFile() and c.SaveFile(). Static files via app.Static().
  • Validation with go-playground/validator via the validate tag. The validator library is separate, not built in.
  • Healthchecks for dependent services: Postgres pg_isready, Redis redis-cli ping. Use depends_on: condition: service_healthy in Compose.
  • Testcontainers for integration tests: spins up a Postgres container, runs tests, cleans up automatically.
  • Use Fiber if you’re familiar with Express.js, need WebSockets, or like declarative APIs. Avoid it if the net/http ecosystem is a hard requirement.
  • Best practices: separate dev & prod Dockerfiles, cache dependencies, bind-mount source, healthchecks, retry loops, graceful shutdown, fasthttp compatibility awareness.
  • Alternatives: Gin for net/http compatibility, chi for a lightweight router, standard net/http for minimal services.

← Previous: Gin   Next: Chi →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact