A Go REST API can stay small and readable as it grows if you pick libraries that stay close to the standard library. This guide builds one with chi and sqlx, step by step, with the patterns you need before it goes to production.
Go's standard net/http is solid, but chi adds route groups and middleware, and sqlx makes database code shorter without becoming an ORM. This guide shows production-grade patterns: structured logging with log/slog, graceful shutdown, config from environment variables, migrations, testing, auth middleware and deployment on a Domain India VPS.
Why chi + sqlx
Go's web ecosystem offers dozens of frameworks. For maintainable, idiomatic Go:
- chi — a small router with route groups and middleware. Handlers are plain
net/httphandlers, so there is no framework lock-in. Since Go 1.22 the standardServeMuxalso matches methods and path parameters; chi still adds sub-routers, route groups and a middleware set. - sqlx — extends
database/sqlwith struct scanning, named queries. Not an ORM — just quality-of-life. Pairs well with migrations viamigrateorgoose.
Alternative combinations work too (echo + pgx, gin + GORM), but chi + sqlx stays close to the standard library, so any Go developer can read it.
Project layout
myapi/
├── cmd/
│ └── server/
│ └── main.go # entry point
├── internal/
│ ├── config/config.go # env var parsing
│ ├── db/ # sqlx setup
│ ├── handler/ # HTTP handlers
│ ├── middleware/ # auth, logging, recovery
│ ├── model/ # domain structs
│ └── repo/ # data access
├── migrations/ # golang-migrate SQL files
├── go.mod
└── .env.exampleStep 1 — Dependencies
go mod init github.com/yourcompany/myapi
go get github.com/go-chi/chi/v5
go get github.com/jmoiron/sqlx
go get github.com/jackc/pgx/v5 # PostgreSQL driver (used via database/sql)
go get github.com/caarlos0/env/v11 # env parsingStructured logging uses log/slog from the standard library (Go 1.21 and later), so there is nothing to install for it. lib/pq still works but is in maintenance mode; pgx is the actively developed PostgreSQL driver.
Step 2 — Config with env vars
internal/config/config.go:
package config
import (
"github.com/caarlos0/env/v11"
)
type Config struct {
Env string `env:"ENV" envDefault:"development"`
Port string `env:"PORT" envDefault:"8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
JWTSecret string `env:"JWT_SECRET,required"`
LogLevel string `env:"LOG_LEVEL" envDefault:"info"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
}Step 3 — main.go with graceful shutdown
cmd/server/main.go:
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/yourcompany/myapi/internal/config"
"github.com/yourcompany/myapi/internal/handler"
)
func main() {
cfg, err := config.Load()
if err != nil {
slog.Error("config", "err", err); os.Exit(1)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
db, err := sqlx.Connect("pgx", cfg.DatabaseURL)
if err != nil {
slog.Error("db connect", "err", err); os.Exit(1)
}
defer db.Close()
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Use(slogMiddleware(logger))
r.Get("/health", handler.Health)
r.Route("/v1", func(r chi.Router) {
userHandler := handler.NewUserHandler(db)
r.Post("/users", userHandler.Create)
r.Get("/users/{id}", userHandler.Get)
})
srv := &http.Server{
Addr: "127.0.0.1:" + cfg.Port, // only nginx on the same server can reach it
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
}
// Start
go func() {
slog.Info("starting", "port", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("listen", "err", err); os.Exit(1)
}
}()
// Wait for interrupt
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown", "err", err)
}
}Step 4 — Handler with sqlx
internal/handler/user.go:
package handler
import (
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jmoiron/sqlx"
)
type User struct {
ID string `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Name string `db:"name" json:"name"`
}
type UserHandler struct {
db *sqlx.DB
}
func NewUserHandler(db *sqlx.DB) *UserHandler {
return &UserHandler{db: db}
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var u User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err := h.db.QueryRowxContext(r.Context(),
`INSERT INTO users (email, name)
VALUES ($1, $2) RETURNING id`,
u.Email, u.Name,
).Scan(&u.ID)
if err != nil {
// Log the detail; never send database errors to the client
slog.Error("create user", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(u)
}
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var u User
if err := h.db.GetContext(r.Context(), &u,
`SELECT id, email, name FROM users WHERE id = $1`, id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "not found", http.StatusNotFound)
return
}
slog.Error("get user", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(u)
}Step 5 — Migrations with golang-migrate
Add the library if you want to run migrations from code (go get github.com/golang-migrate/migrate/v4), or install the CLI:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestCreate migration:
migrate create -ext sql -dir migrations -seq create_usersmigrations/000001_create_users.up.sql:
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text UNIQUE NOT NULL,
name text NOT NULL,
created_at timestamptz DEFAULT now()
);migrations/000001_create_users.down.sql:
DROP TABLE users;Run:
migrate -path migrations -database "$DATABASE_URL" upStep 6 — Testing
func TestHealth(t *testing.T) {
r := chi.NewRouter()
r.Get("/health", handler.Health)
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
}For tests that touch the database, use testcontainers-go or dockertest to start PostgreSQL in a container, apply migrations, run the tests and tear it down.
Step 7 — Production middleware
This request logger is the slogMiddleware used in main.go; keep it in the main package or move it to internal/middleware.
func slogMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
defer func() {
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration_ms", time.Since(start).Milliseconds(),
"request_id", middleware.GetReqID(r.Context()),
)
}()
next.ServeHTTP(ww, r)
})
}
}Step 8 — Auth middleware
In internal/middleware, imported as mw so it doesn't clash with chi's middleware package. verifyJWT stands for your own verification with a maintained library such as github.com/golang-jwt/jwt/v5; always pin the expected signing algorithm.
type ctxKey string
const userIDKey ctxKey = "userID"
func JWTAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := r.Header.Get("Authorization")
if !strings.HasPrefix(tok, "Bearer ") {
http.Error(w, "unauth", 401); return
}
claims, err := verifyJWT(tok[7:], secret)
if err != nil {
http.Error(w, "invalid token", 401); return
}
ctx := context.WithValue(r.Context(), userIDKey, claims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// In routes:
r.Route("/v1", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(mw.JWTAuth(cfg.JWTSecret))
r.Get("/me", userHandler.Me)
})
// Public routes here:
r.Post("/login", authHandler.Login)
})Deploy to a Domain India VPS
See our Running Go Applications on a VPS guide for the systemd and nginx setup. Key addition: run migrations in the systemd unit's ExecStartPre= (with DATABASE_URL in the unit's EnvironmentFile):
EnvironmentFile=/opt/myapi/.env
ExecStartPre=/usr/local/bin/migrate -path /opt/myapi/migrations -database ${DATABASE_URL} up
ExecStart=/opt/myapi/apiThis suits a single server. If you run several instances, run migrations once from your deploy script instead, so they don't race.
Running this on Domain India:
- VPS: self-managed KVM with full root access, from ₹553 a month excluding GST. You install Go (or upload a binary), PostgreSQL and nginx yourself. Backups and snapshots are not included, so schedule your own
pg_dumpand copy it off the server. - App Platform: deploys from GitHub (Deploy Now) or with a deploy token, and PostgreSQL and free SSL are included on every plan. Only Node.js is detected automatically, so a Go API needs your own Dockerfile. There is no SSH, so run migrations from the app at startup.
- Shared hosting: cannot run your own long-running Go binary.
Common pitfalls
GetContext and SelectContext, which close for you, or defer rows.Close() right after checking the error.r.Context() to every database call.middleware.Recoverer so a panic becomes a logged 500 response.127.0.0.1 and put nginx in front.go.mod sets the minimum Go version; use the same toolchain in CI and production.FAQ
Should I use chi, gin or echo?
chi uses standard net/http handlers and is minimal, which makes it easy to maintain. gin and echo include more built-in features but use their own handler signatures. For teams that want idiomatic Go, chi or the standard library router is a good default.
Should I use sqlx, GORM or pgx directly?
sqlx suits most APIs: plain SQL with struct scanning. GORM is a full ORM, convenient but prone to hidden N+1 queries. Using pgx's own interface gives the best performance and PostgreSQL features such as LISTEN/NOTIFY and COPY.
How many requests per second can a Go API handle?
It depends on your endpoints, queries and server size, so load-test your own app with a tool such as k6 or hey. Go itself is rarely the bottleneck; the database usually is, so index your queries and size the connection pool.
Do I need a Dockerfile for Go?
Not on a VPS: copy the single binary to the server and run it with systemd. On the Domain India App Platform, yes, because only Node.js is detected automatically there. A Dockerfile also keeps CI builds consistent.
Should I use Node.js or Go for my backend?
Go usually uses less memory and handles CPU-heavy concurrency well. Node.js with TypeScript has a larger ecosystem and is quick for simple APIs. Pick the one your team knows best.
Ready to deploy? Follow the Go on a VPS guide, compare VPS plans, or read more in Docker and containers if you plan to use the App Platform.
A self-managed Domain India VPS gives you full root access for Go, PostgreSQL and nginx.
Explore VPS plans