Opinionated Go API starter on Fiber v3: modular vertical slices, Uber FX wiring, session auth, Postgres/GORM, generated OpenAPI served with Scalar.
Not a framework — a template you are meant to delete from. Everything below is what the code actually does today.
- Go 1.25
- Fiber v3 · FX v1.24 · GORM v1.31 (pgx) · validator v10
- Auth: opaque session token in an HttpOnly cookie (no JWT)
- Docs:
/docs(Scalar), generated byswagintodocs/, committed, CI-checked
- Quick start
- Configuration
- Architecture
- Request lifecycle
- Conventions
- API
- Database & migrations
- Development
- Testing
- Deployment
- Known limits
Prerequisites: Go 1.25+, Docker (or a local PostgreSQL), Task, swag, Air for hot reload.
git clone https://github.com/l1ttps/fiberest && cd fiberest
task init # copies example.env -> .env, downloads deps
docker compose up -d postgres
task dev # generates docs, then runs with hot reload on :3278Create the first admin, then log in:
curl -X POST localhost:3278/auth/init \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"change-me-please"}'
curl -c cookies.txt -X POST localhost:3278/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"change-me-please"}'
# -> 200 {"message":"Login successful","expiresAt":"..."} + Set-Cookie: session_id=.../auth/init only works while no ADMIN user exists. After that it returns 400.
Environment only — no config files beyond .env. Resolution order per key: process env → .env → default. .env never overrides an existing process variable.
| Key | Default | Values / notes |
|---|---|---|
PORT |
3278 |
1–65535 |
APP_ENV |
development |
development · production · test |
APP_NAME |
Fiberest |
used in logs and docs title |
DB_HOST |
localhost |
|
DB_PORT |
5432 |
|
DB_USER |
postgres |
|
DB_PASSWORD |
— | empty by default |
DB_NAME |
fiberest |
|
DB_SSLMODE |
disable |
disable · allow · prefer · require · verify-ca · verify-full |
DB_MAX_IDLE_CONNS |
10 |
|
DB_MAX_OPEN_CONNS |
100 |
|
DB_MAX_LIFETIME |
1h |
Go duration |
DB_LOG_LEVEL |
silent |
silent · error · warn · info |
DB_AUTO_MIGRATE |
true unless production |
true · false |
CORS_ALLOW_ORIGINS |
* in dev, empty otherwise |
comma-separated |
CORS_ALLOW_CREDENTIALS |
false |
forced to false when origins contain * |
LOG_LEVEL |
info |
debug · info · warn · error |
LOG_FORMAT |
json in production, text otherwise |
json · text |
SESSION_DURATION |
168h |
lifetime of "remember me" sessions |
BCRYPT_COST |
10 |
4–31 |
SESSION_COOKIE_SECURE |
true in production |
false only for plain-http local testing |
Invalid values fail startup with a validation error listing the offending key — there is no silent fallback.
To add a setting, add one tagged field; nothing else changes:
Port int `env:"PORT" default:"3278" validate:"required,min=1,max=65535"`cmd/server/main.go fx.New(configs, logger, database, modules, server).Run()
docs/ generated OpenAPI (committed; CI fails on drift)
internal/
common/
types/ GetManyRequest (pagination + defaults), PageMeta
validators/ Bind + validate helpers, field-level error messages
configs/ env loader (reflection over `env`/`default` tags)
database/ GORM connection, pool, THE ONLY AutoMigrate
logger/ slog handler (json/text, level from config)
middlewares/ RequestID, Recovery, SecurityHeaders, CORS, Limiter, AuthGuard, RoleGuard
models/ User, Account, Session, BaseModel (UUID pk)
modules/
combine_module.go aggregates feature modules
auth/ controller, service, repository, dto
users/ controller, service, repository, dto
server/ fiber app assembly, health probes, 404, lifecycle
pkg/
http_error/ the only error type + the only error writer
Each feature module owns its controller, service, repository and DTOs. Sharing between modules goes through an exported interface, not through each other's repositories.
var Module = fx.Options(
fx.Provide(NewRepository),
fx.Provide(NewService),
fx.Provide(NewController),
fx.Invoke(RegisterRoutes),
)Then register it — both steps are required, nothing is auto-discovered:
// internal/modules/combine_module.go
var Module = fx.Options(auth.Module, users.Module /* + your module */)Three rules that are not obvious:
- FX matches types exactly, not structurally.
middlewares.AuthGuardneedsmiddlewares.AuthService;auth.Modulere-exports its service under that name, so protected modules take it as a parameter of their route function:func UserRoutes(app *fiber.App, c *Controller, authSvc middlewares.AuthService) { ... }
- Route registration happens in
fx.Invoke. All invokes complete before any lifecycle hook runs, which is whyservercan register the 404 catch-all inOnStartand be sure every module route exists. If you register a route insideOnStart, register it before that catch-all or it will never be reached. - The
*fiber.Appis a mutable registry. Modules attach to it; there is no route DSL. Order offx.Invokeis the order modules appear inmain.go.
internal/server/fx_test.go validates the whole graph without a database:
go test ./internal/server -run TestFxGraphNote: fx.ValidateApp resolves constructors but does not run invoke bodies, so that test proves wiring, not routes. Route behaviour is covered by internal/server/routes_test.go, which mounts the real route functions on a bare app.
Middleware, in registration order:
| # | Middleware | Scope |
|---|---|---|
| 1 | RequestID |
all requests; sets/echoes X-Request-ID, stores it in locals |
| 2 | Recovery |
all requests; panic → generic 500, stack to logs |
| 3 | SecurityHeaders |
all requests; helmet, HSTS only in production over https |
| 4 | CORS |
all requests; config-driven |
| — | /, /health-check, /health, /health/ready, /docs/* |
registered before the limiter so probes and docs are never throttled |
| 5 | global limiter | 20 req/s per IP (in-memory, per process) |
| 6 | AuthGuard |
per route group, not global — see Conventions |
| 7 | RoleGuard |
admin-only groups |
| 8 | 404 catch-all | registered in OnStart, after every module route |
/health returns {status, env}; /health/ready pings the database with a 2s timeout and returns 503 with a generic body on failure.
There is no global auth middleware and no public-path allow-list. Public routes are simply registered without the guard:
func UserRoutes(app *fiber.App, c *Controller, authSvc middlewares.AuthService) {
users := app.Group("/users")
users.Patch("/me", middlewares.AuthGuard(authSvc), c.patchMe)
admin := users.Group("/",
middlewares.AuthGuard(authSvc),
middlewares.RoleGuard(models.RoleAdmin))
admin.Get("/", c.getManyUsers)
}Consequence: an unknown path returns 404, not 401.
Read identity only through the typed helpers — never c.Locals("user_id"):
user, ok := middlewares.User(ctx)
userID, ok := middlewares.UserID(ctx)
session, ok := middlewares.Session(ctx)pkg/http_error is the only package that writes an error body, via fiber.Config.ErrorHandler:
return http_error.BadRequest("Invalid date format")
return http_error.NotFound(err.Error()) // safe: service sentinel
return http_error.Internal(err) // cause logged with request id, client sees "Internal server error"Response shape (details only appears for validation failures):
{ "status": 404, "code": "NOT_FOUND", "message": "user not found", "requestId": "…" }Rules:
- Never pass a database or driver error message into a 4xx/5xx message. Wrap with
Internal(err). - Map service sentinels once per module with a local
mapError(err)helper. pkgmust not importinternal.
var req dto.CreateUserRequest
if err := validators.ParseAndValidate(ctx, &req); err != nil {
return validators.Invalid(err) // 400 + field details
}| Input | Helper | Notes |
|---|---|---|
| JSON body | validators.ParseAndValidate |
|
| Query | validators.GetQuery |
applies SetDefaults() before validating (pagination fields must stay optional) |
| Path params | validators.GetParam |
param:"id" tags, backed by Fiber's Bind().URI |
Path IDs are UUIDs — tag them validate:"required,uuid" so a malformed id is a 400 instead of a Postgres cast error.
One shape, one calculation:
types.PageMeta // limit, page, pageCount, hasNextPage, total
types.NewPageMeta(limit, page, total) // embedded in every paginated DTO
types.GetManyRequest // limit/page/search + SetDefaults()Return DTOs, never GORM models. Helpers such as dto.NewUserResponse(user) exist so a model field cannot leak by accident. Session tokens are json:"-" everywhere.
Infrastructure: GET /, GET /health, GET /health/ready, GET /health-check, GET /docs.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /auth/init |
public | creates the first admin; only while no admin exists |
| POST | /auth/login |
public | 5/min per IP; sets session_id cookie |
| POST | /auth/logout |
session | deletes the session, clears the cookie |
| GET | /auth/session |
session | current session, token excluded |
| GET | /auth/sessions |
session | your sessions, paginated |
| DELETE | /auth/sessions/:id |
session | revoke one of your sessions |
| POST | /auth/change-password |
session | revokes every other session of the user |
Sessions: 24h by default, SESSION_DURATION when the login body sets "remember": true, auto-extended once per day. Cookie is HttpOnly, SameSite=Lax, Secure per SESSION_COOKIE_SECURE. The token never appears in a response body.
| Method | Path | Role |
|---|---|---|
| PATCH | /users/me |
any authenticated user |
| GET | /users |
ADMIN |
| POST | /users |
ADMIN |
| GET · PUT · DELETE | /users/:id |
ADMIN |
| POST | /users/:id/ban · /users/un-ban/:id |
ADMIN |
| POST | /users/set-password/:id |
ADMIN |
Paginated endpoints return:
{ "data": [], "limit": 10, "page": 1, "pageCount": 0, "hasNextPage": false, "total": 0 }?limit and ?page are optional (limit 1–100, default 10/page 1). Unknown query or path values are rejected, not ignored.
One connection pool, one schema owner.
// internal/database/database.module.go
fx.Provide(NewDatabaseService)
fx.Invoke(runMigrations) // AutoMigrate(User, Account, Session) when DB_AUTO_MIGRATE
fx.Invoke(registerShutdown) // pool closed on OnStopDB_AUTO_MIGRATEdefaults to false in production. There, migrate out-of-band with a versioned tool.- Feature modules must not call
AutoMigrate. Add models todatabase.Moduleinstead. - Repositories receive
*database.DatabaseServiceand calldb.GetDB().WithContext(ctx).
Known ceiling: there is no transaction propagation — a service cannot run two repositories in one transaction. CreateWithAccount handles its own transaction locally. If you need cross-repository atomicity, provide *gorm.DB instead of DatabaseService and thread the tx through the context.
| Command | What it does |
|---|---|
task init |
create .env from example.env, go mod download && go mod tidy |
task dev |
Air hot reload; runs task docs before every build |
task run |
build docs, then go run cmd/server/main.go |
task build |
build docs, then go build -o bin/server |
task docs |
swag init -g cmd/server/main.go -o docs |
task test |
go test ./... -race |
task test:cover |
coverage profile + per-function report |
task lint |
golangci-lint, falls back to go vet |
task docker:build · docker:up · docker:down |
image / compose stack |
docs/ is committed and shipped inside the image, so annotations and generated files must stay in sync. task dev regenerates on every build; CI runs swag init and fails on git diff --exit-code docs/. Forgetting task docs after editing an annotation is the most common source of CI red.
Annotate handlers with the usual swag comments (@Summary, @Param, @Success, @Router), then task docs. Scalar UI: http://localhost:3278/docs.
go test ./... -count=1 # what CI runs (plus -race on Linux)
go test ./internal/server -v # DI graph + route table + guard behaviourWhat the suite actually asserts:
| Area | Test |
|---|---|
| DI graph resolves, providers unique | internal/server/fx_test.go (database replaced, no Postgres) |
| route table, 401 on protected routes, 404 on unknown, login unguarded | internal/server/routes_test.go |
| internal errors never reach the client | pkg/http_error/http_error_test.go |
pagination defaults (GET /users without ?limit) and UUID path validation |
internal/common/validators/validator_test.go |
| config precedence, conditional defaults, invalid values rejected | internal/configs/config_test.go |
| pool settings, DSN, log-level mapping | internal/database/database.service_test.go |
| panic recovery, security headers, request id | internal/middlewares/*_test.go |
Not covered yet: a full login → cookie → protected route round trip against a real database. That is the most valuable test to add next.
docker compose up --build -dCompose runs the app with production defaults (APP_ENV=production: JSON logs, Secure cookies, no wildcard CORS, no implicit auto-migrate). Two overrides are deliberate and documented in docker-compose.yml:
DB_AUTO_MIGRATE=true— first boot convenience; turn it off once you have a migration pipeline.SESSION_COOKIE_SECURE=true— requires HTTPS in front of the app. Set it tofalseonly if you terminate TLS somewhere browsers do not see.
Production checklist:
-
APP_ENV=production -
DB_AUTO_MIGRATE=falseand a versioned migration tool -
CORS_ALLOW_ORIGINSset to real origins - TLS terminating in front; HSTS is only emitted over https
-
SESSION_COOKIE_SECURE=true -
DB_SSLMODEnotdisablefor a remote database -
LOG_FORMAT=json, shipped somewhere
The image is a two-stage build (CGO_ENABLED=0, static binary, non-root appuser). /health and /health/ready are the probe endpoints.
Deliberate shortcuts, each with the trigger for fixing it:
- Rate limiter is in-memory → limits multiply per replica; move to a shared store when you run more than one.
- No transaction propagation → see Database.
AutoMigrateis not versioned → no rollback, no reviewable schema diff. Replace withgolang-migratebefore the schema matters./auth/inithas a theoretical race → two concurrent calls can both pass the "no admin yet" check. Needs a partial unique index if that matters.- One
Repositoryper module → no aggregates, no domain events, no unit-of-work. Fine until a module needs invariants across two repositories. authandusersshare concepts → both defineAccountaccess (FindAccount/CreateAccount). Merge when a third module needs it.