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.
| Aspect | Gin | Fiber |
|---|---|---|
| HTTP engine | Standard net/http | fasthttp (custom) |
| API style | Express-like, idiomatic Go | Express-like, closer to JS |
| Middleware | gin.HandlerFunc | fiber.Handler (plain functions) |
| Context | *gin.Context | *fiber.Ctx (method-rich) |
| Body binding | c.ShouldBindJSON | c.BodyParser |
| Static files | r.Static("/uploads", "./uploads") | app.Static("/uploads", "./uploads") |
| Performance | Very fast | Slightly faster in synthetic benchmarks |
net/http middleware compatibility | Yes | No (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:
| Middleware | Function |
|---|---|
recover | Catch panics, return 500 |
logger | Log every request |
requestid | Generate/extract request IDs |
cors | Handle CORS |
compress | Gzip/deflate responses |
limiter | Rate limiting |
jwt | JWT validation |
basicauth | HTTP Basic Auth |
cache | HTTP cache headers |
etag | ETag headers |
monitor | Metrics endpoint (needs an ORM) |
csrf | CSRF tokens |
helmet | Security headers |
healthcheck | Liveness/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, notnet/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
.gofiles, 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()andc.SaveFile(). Static files viaapp.Static().- Validation with
go-playground/validatorvia thevalidatetag. The validator library is separate, not built in.- Healthchecks for dependent services: Postgres
pg_isready, Redisredis-cli ping. Usedepends_on: condition: service_healthyin 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/httpecosystem 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/httpcompatibility, chi for a lightweight router, standard net/http for minimal services.