Go Development

Building Production REST APIs in Go with chi and sqlx on Domain India VPS

By Domain India Team · DomainIndia EngineeringPublished 5 min read
Knowledge base article
Contents (13 sections)

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.

Key takeaways

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/http handlers, so there is no framework lock-in. Since Go 1.22 the standard ServeMux also matches methods and path parameters; chi still adds sub-routers, route groups and a middleware set.
  • sqlx — extends database/sql with struct scanning, named queries. Not an ORM — just quality-of-life. Pairs well with migrations via migrate or goose.

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

code
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.example

Step 1 — Dependencies

bash
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 parsing

Structured 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:

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:

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:

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:

bash
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

Create migration:

bash
migrate create -ext sql -dir migrations -seq create_users

migrations/000001_create_users.up.sql:

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:

sql
DROP TABLE users;

Run:

bash
migrate -path migrations -database "$DATABASE_URL" up

Step 6 — Testing

go
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.

go
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.

go
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):

ini
EnvironmentFile=/opt/myapi/.env
ExecStartPre=/usr/local/bin/migrate -path /opt/myapi/migrations -database ${DATABASE_URL} up
ExecStart=/opt/myapi/api

This 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_dump and 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

Forgetting rows.Close()
Connections leak. Prefer GetContext and SelectContext, which close for you, or defer rows.Close() right after checking the error.
Ignoring errors from Query or Scan
You get nil-pointer crashes and silent bad data. Check every error.
No context cancellation
Slow queries keep running after the client disconnects. Pass r.Context() to every database call.
Panics reaching clients
Add middleware.Recoverer so a panic becomes a logged 500 response.
Binding to 0.0.0.0 without a firewall
The internal port is exposed. Bind to 127.0.0.1 and put nginx in front.
Building with the wrong Go version
go.mod sets the minimum Go version; use the same toolchain in CI and production.
Sending database errors to clients
They leak table names and queries. Log the error and return a generic message.

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.

Deploy your Go API

A self-managed Domain India VPS gives you full root access for Go, PostgreSQL and nginx.

Explore VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app
Production Go REST APIs with chi and sqlx on DomainIndia