Build a REST API in Go with Fiber v3 on Ubuntu

Series Production Go Web Service with Fiber Part 1/6 All parts

Go’s standard library can write an HTTP server, but “everything needed” and “everything convenient” are two different things. Once you start handling JSON bodies, route parameters, middleware chains, and consistent error responses, net/http alone means writing a fair amount of boilerplate by hand or reaching for a router library on top of it. Fiber is one of the more popular choices for that job in the Go ecosystem, and this tutorial uses it to build a small, real REST API from scratch.

This is the first part of a series that builds one application, a bookmarks API called bookmarkd, from an empty directory all the way to a deployed, tested, observable service. By the end of this series you will have a Go API backed by PostgreSQL, protected with JWT authentication, exposing metrics and structured logs, covered by tests, and running behind Nginx in Docker. This part covers just the first piece: a working HTTP API with an in-memory store, so the routing and request handling are solid before a real database enters the picture in the next part.

This tutorial is for developers who are comfortable with Go’s basics (functions, structs, interfaces) but new to building HTTP services with it. You do not need any prior Fiber experience. By the end, you will have a bookmarkd binary that lists, creates, fetches, and deletes bookmarks over HTTP, running as a systemd service on Ubuntu.

Conceptual Overview

Fiber is an HTTP web framework for Go built on top of fasthttp, a low-level HTTP engine that is faster than Go’s standard net/http package because it avoids some of its allocations and abstractions. Fiber wraps fasthttp with an API that will feel familiar if you have used Express.js in Node.js: you register routes with a method and path, and each route gets a handler function.

Fiber recently released version 3, which changed enough of the API that most tutorials and Stack Overflow answers you find online are still written for v2. The biggest change is that fiber.Ctx, the object passed into every handler, is now an interface instead of a struct pointer, which means handler signatures look like func(c fiber.Ctx) error rather than func(c *fiber.Ctx) error. Request binding also moved from a single BodyParser method to a Bind() builder with separate Body(), Query(), and URI() methods depending on where the data comes from. If you have existing Fiber v2 code, the Fiber CLI ships a fiber migrate command that automates most of this conversion, but everything in this series is written directly against v3.

A route group lets you attach a common path prefix and shared middleware to a set of routes, which is how this tutorial organizes everything under /v1. An error handler is a single function Fiber calls whenever a handler returns a non-nil error, which is what keeps error responses consistent instead of scattering c.Status().JSON() calls with slightly different shapes across every handler. Finally, a store in this project is just an interface describing how bookmarks are read and written; today it is backed by a plain Go map protected with a mutex, and in the next part the exact same interface will be implemented against PostgreSQL without touching a single handler.

Prerequisites

Before starting, make sure you have:

  • One Ubuntu 22.04 or 24.04 server or virtual machine. This guide uses a host named api-01 at 10.20.0.51.
  • sudo access on that host.
  • Basic familiarity with the Go language: structs, interfaces, and go.mod.
  • curl installed for testing endpoints (sudo apt install -y curl).
  • No prior Fiber experience needed.

Step 1: Install Go 1.25

Fiber v3 requires Go 1.25 or newer. Ubuntu’s own package repositories usually lag behind the latest Go release, so install it from the official tarball instead:

curl -fsSL https://go.dev/dl/go1.25.0.linux-amd64.tar.gz -o /tmp/go1.25.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf /tmp/go1.25.0.linux-amd64.tar.gz

Add Go to your PATH if it is not already there:

echo 'export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin' >> ~/.bashrc
source ~/.bashrc

Confirm the install:

go version
go version go1.25.0 linux/amd64

Step 2: Initialize the Project

Create the project directory and initialize a Go module. This series uses github.com/facsiaginsa/bookmarkd as the module path throughout; substitute your own if you plan to push this to your own repository.

mkdir -p ~/bookmarkd/cmd/api ~/bookmarkd/internal/handler ~/bookmarkd/internal/store
cd ~/bookmarkd
go mod init github.com/facsiaginsa/bookmarkd

This layout separates three concerns that will matter more as the series progresses: cmd/api holds the entry point that wires everything together, internal/handler holds the HTTP layer, and internal/store holds the data layer. The internal prefix is a Go convention that prevents other modules from importing these packages, which is appropriate here since none of this is meant to be a reusable library.

Now add Fiber:

go get github.com/gofiber/fiber/v3

Step 3: Define the Bookmark Type and Store Interface

Create internal/store/store.go:

package store

import (
	"context"
	"errors"
	"time"
)

var ErrNotFound = errors.New("bookmark not found")

type Bookmark struct {
	ID        string    `json:"id"`
	Title     string    `json:"title"`
	URL       string    `json:"url"`
	CreatedAt time.Time `json:"created_at"`
}

type Store interface {
	List(ctx context.Context) ([]Bookmark, error)
	Get(ctx context.Context, id string) (Bookmark, error)
	Create(ctx context.Context, b Bookmark) (Bookmark, error)
	Delete(ctx context.Context, id string) error
}

Every method takes a context.Context as its first argument, even though the in-memory implementation below ignores it. This is deliberate: once the store talks to PostgreSQL in Part 2, that same context will carry request deadlines and cancellation down to the database driver, and changing the interface later would mean touching every handler that calls it.

Now the in-memory implementation, internal/store/memory.go:

package store

import (
	"context"
	"sync"
	"time"

	"github.com/google/uuid"
)

type MemoryStore struct {
	mu        sync.RWMutex
	bookmarks map[string]Bookmark
}

func NewMemoryStore() *MemoryStore {
	return &MemoryStore{
		bookmarks: make(map[string]Bookmark),
	}
}

func (s *MemoryStore) List(ctx context.Context) ([]Bookmark, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	out := make([]Bookmark, 0, len(s.bookmarks))
	for _, b := range s.bookmarks {
		out = append(out, b)
	}
	return out, nil
}

func (s *MemoryStore) Get(ctx context.Context, id string) (Bookmark, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	b, ok := s.bookmarks[id]
	if !ok {
		return Bookmark{}, ErrNotFound
	}
	return b, nil
}

func (s *MemoryStore) Create(ctx context.Context, b Bookmark) (Bookmark, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	b.ID = uuid.NewString()
	b.CreatedAt = time.Now().UTC()
	s.bookmarks[b.ID] = b
	return b, nil
}

func (s *MemoryStore) Delete(ctx context.Context, id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()

	if _, ok := s.bookmarks[id]; !ok {
		return ErrNotFound
	}
	delete(s.bookmarks, id)
	return nil
}

Add the UUID dependency:

go get github.com/google/uuid

The sync.RWMutex is necessary because Fiber, like any HTTP server, handles requests concurrently on multiple goroutines. Without it, two simultaneous writes to the map could corrupt its internal state or panic the whole process.

Step 4: Write the HTTP Handlers

Create internal/handler/bookmark.go:

package handler

import (
	"errors"

	"github.com/gofiber/fiber/v3"

	"github.com/facsiaginsa/bookmarkd/internal/store"
)

type BookmarkHandler struct {
	Store store.Store
}

type createBookmarkRequest struct {
	Title string `json:"title"`
	URL   string `json:"url"`
}

func (h *BookmarkHandler) List(c fiber.Ctx) error {
	bookmarks, err := h.Store.List(c.Context())
	if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "failed to list bookmarks")
	}
	return c.JSON(bookmarks)
}

func (h *BookmarkHandler) Get(c fiber.Ctx) error {
	id := c.Params("id")

	b, err := h.Store.Get(c.Context(), id)
	if errors.Is(err, store.ErrNotFound) {
		return fiber.NewError(fiber.StatusNotFound, "bookmark not found")
	}
	if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "failed to fetch bookmark")
	}
	return c.JSON(b)
}

func (h *BookmarkHandler) Create(c fiber.Ctx) error {
	var in createBookmarkRequest
	if err := c.Bind().Body(&in); err != nil {
		return fiber.NewError(fiber.StatusBadRequest, "invalid request body")
	}

	if in.Title == "" || in.URL == "" {
		return fiber.NewError(fiber.StatusBadRequest, "title and url are required")
	}

	created, err := h.Store.Create(c.Context(), store.Bookmark{
		Title: in.Title,
		URL:   in.URL,
	})
	if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "failed to create bookmark")
	}

	return c.Status(fiber.StatusCreated).JSON(created)
}

func (h *BookmarkHandler) Delete(c fiber.Ctx) error {
	id := c.Params("id")

	if err := h.Store.Delete(c.Context(), id); errors.Is(err, store.ErrNotFound) {
		return fiber.NewError(fiber.StatusNotFound, "bookmark not found")
	} else if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "failed to delete bookmark")
	}

	return c.SendStatus(fiber.StatusNoContent)
}

Two things worth calling out. First, c.Bind().Body(&in) is Fiber v3’s replacement for the v2 method c.BodyParser(&in); it reads the Content-Type header and decodes JSON, form data, or XML accordingly. Second, every handler returns a *fiber.Error (via fiber.NewError) instead of writing the response itself and returning nil. That consistency is what makes a single central error handler possible in the next step, instead of every handler deciding its own error format.

Step 5: Wire Up the Server and a Central Error Handler

Create cmd/api/main.go:

package main

import (
	"log"

	"github.com/gofiber/fiber/v3"

	"github.com/facsiaginsa/bookmarkd/internal/handler"
	"github.com/facsiaginsa/bookmarkd/internal/store"
)

func main() {
	app := fiber.New(fiber.Config{
		ErrorHandler: func(c fiber.Ctx, err error) error {
			code := fiber.StatusInternalServerError
			message := "internal server error"

			var fe *fiber.Error
			if e, ok := err.(*fiber.Error); ok {
				fe = e
				code = fe.Code
				message = fe.Message
			}

			return c.Status(code).JSON(fiber.Map{
				"error": message,
			})
		},
	})

	mem := store.NewMemoryStore()
	bookmarkHandler := &handler.BookmarkHandler{Store: mem}

	v1 := app.Group("/v1")
	bookmarks := v1.Group("/bookmarks")
	bookmarks.Get("/", bookmarkHandler.List)
	bookmarks.Get("/:id", bookmarkHandler.Get)
	bookmarks.Post("/", bookmarkHandler.Create)
	bookmarks.Delete("/:id", bookmarkHandler.Delete)

	log.Fatal(app.Listen(":8080"))
}

The ErrorHandler function is what turns every fiber.NewError(...) call from the handlers into an actual JSON response. Without registering it, Fiber’s built-in default handler would still return JSON, but this version guarantees the exact shape ({"error": "..."}) stays the same everywhere, which matters once client code starts depending on it.

Run the API:

go run ./cmd/api
 ┌───────────────────────────────────────────────────┐ 
 │                   Fiber v3.3.0                     │ 
 │               http://127.0.0.1:8080                │ 
 │       (bound on host 0.0.0.0 and port 8080)         │ 
 └───────────────────────────────────────────────────┘ 

Step 6: Test Every Endpoint with curl

In a second terminal on api-01, create a bookmark:

curl -s -X POST http://localhost:8080/v1/bookmarks \
  -H "Content-Type: application/json" \
  -d '{"title": "Fiber Docs", "url": "https://docs.gofiber.io"}'
{"id":"b1e6b3f0-2f2d-4b1a-9e7a-6b6b3f0a2f2d","title":"Fiber Docs","url":"https://docs.gofiber.io","created_at":"2026-08-10T09:12:03Z"}

List all bookmarks:

curl -s http://localhost:8080/v1/bookmarks
[{"id":"b1e6b3f0-2f2d-4b1a-9e7a-6b6b3f0a2f2d","title":"Fiber Docs","url":"https://docs.gofiber.io","created_at":"2026-08-10T09:12:03Z"}]

Fetch it by ID (substitute the ID from the create response):

curl -s http://localhost:8080/v1/bookmarks/b1e6b3f0-2f2d-4b1a-9e7a-6b6b3f0a2f2d

Fetch a nonexistent ID to confirm the error handler works:

curl -s -i http://localhost:8080/v1/bookmarks/does-not-exist
HTTP/1.1 404 Not Found
Content-Type: application/json

{"error":"bookmark not found"}

And delete it:

curl -s -i -X DELETE http://localhost:8080/v1/bookmarks/b1e6b3f0-2f2d-4b1a-9e7a-6b6b3f0a2f2d
HTTP/1.1 204 No Content

Step 7: Build a Binary and Run It as a systemd Service

Development runs with go run are fine for iterating, but a real deployment should run a compiled binary supervised by systemd so it restarts automatically if it crashes and starts on boot. Build the binary:

cd ~/bookmarkd
go build -o bookmarkd ./cmd/api
sudo mv bookmarkd /usr/local/bin/bookmarkd

Create a dedicated system user so the service does not run as root:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin bookmarkd

Create the unit file at /etc/systemd/system/bookmarkd.service:

sudo tee /etc/systemd/system/bookmarkd.service > /dev/null <<'EOF'
[Unit]
Description=bookmarkd REST API
After=network.target

[Service]
Type=simple
User=bookmarkd
ExecStart=/usr/local/bin/bookmarkd
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target
EOF

Enable and start it:

sudo systemctl daemon-reload
sudo systemctl enable --now bookmarkd
sudo systemctl status bookmarkd

If you have already worked through Manage Background Services and Timers with systemd on Ubuntu, this unit will look familiar; the same restart and dependency ordering rules apply here as to any other Go binary running as a background service.

Common Mistakes and Troubleshooting

Compile error mentioning *fiber.Ctx versus fiber.Ctx. This almost always means a code snippet copied from a Fiber v2 tutorial. In v3, handler signatures take fiber.Ctx (an interface), not a pointer to it. Check every func(c *fiber.Ctx) error and remove the asterisk.

c.BodyParser is undefined. Same root cause: that method was renamed in v3. Use c.Bind().Body(&in) instead.

“address already in use” when starting the server. Something is already bound to port 8080, often a previous go run process that did not exit cleanly. Find and stop it with sudo lsof -i :8080 followed by kill <pid>, or change the port in app.Listen.

POST requests return 400 with a valid-looking JSON body. Check that the request actually sets Content-Type: application/json. Fiber’s binder uses that header to decide how to parse the body, and without it, c.Bind().Body() will not know to treat the payload as JSON.

systemd service fails immediately with a permission error. The bookmarkd system user cannot read files it does not own. If you later add a config file or .env file, make sure its permissions allow the bookmarkd user to read it, or run sudo chown bookmarkd:bookmarkd on the file.

Best Practices

  • Keep handlers thin. Handlers in this tutorial only translate between HTTP and the store interface; business logic that grows more complex than this should live in its own package rather than spreading across handler functions.
  • Return errors, do not write responses directly from deep inside your logic. Returning a *fiber.Error and letting the central ErrorHandler format it keeps every error response consistent, which becomes important once client applications start parsing them.
  • Depend on interfaces, not concrete types. BookmarkHandler depends on store.Store, not *store.MemoryStore, which is exactly what makes swapping in PostgreSQL in the next part a change to one line in main.go rather than a rewrite of the handlers.
  • Never trust client input without validation. The Create handler already rejects an empty title or URL; as this API grows, validate early and return a 400 rather than letting bad data reach the store layer.
  • Run the compiled binary in production, not go run. go run compiles on every invocation and leaves a child process that complicates signal handling; a systemd-managed binary is simpler to reason about and restart.

Conclusion

You now have a working REST API in Go, built on Fiber v3, with four endpoints backed by a thread-safe in-memory store and a systemd unit keeping it running. The store is deliberately behind an interface, the handlers are deliberately thin, and error handling is deliberately centralized, all so that the next part of this series can swap the storage layer without touching anything covered here.

In Part 2, that in-memory map gets replaced with a real PostgreSQL database using pgx and sqlc, with the exact same store.Store interface implemented against it, so every endpoint you tested with curl today keeps working exactly as before.

All tutorials →

Latest Tutorials

Support this site