Client Area

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

ByDomain India Team·DomainIndia Engineering
4 min readPublished 20 Apr 2026Updated 23 Jun 2026152 views

In this article

  • 1Why chi + sqlx
  • 2Project layout
  • 3Step 1 — Dependencies
  • 4Step 2 — Config with env vars
  • 5Step 3 — main.go with graceful shutdown

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

TL;DR
Go's standard net/http is solid, but chi adds routing elegance and sqlx makes database code idiomatic. This guide shows production-grade patterns: structured logging, graceful shutdown, config, migrations, testing, and deployment on DomainIndia VPS.

Why chi + sqlx

Go's web ecosystem offers dozens of frameworks. For maintainable, idiomatic Go:

  • chi — minimal router, middleware-friendly, ~600 LOC. Compatible with net/http. Better than gin for long-term maintenance.
  • 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, fiber + gorm), but chi+sqlx stays close to the standard library, meaning junior Go developers 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.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/lib/pq                          # PostgreSQL driver
go get github.com/golang-migrate/migrate/v4       # migrations
go get github.com/caarlos0/env/v10                # env parsing
go get log/slog                                   # stdlib (Go 1.21+)

Step 2 — Config with env vars

internal/config/config.go:

go
package config

import (
    "github.com/caarlos0/env/v10"
)

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/jmoiron/sqlx"
    _ "github.com/lib/pq"

    "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("postgres", 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:         ":" + cfg.Port,
        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 (
    "encoding/json"
    "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 {
        http.Error(w, err.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 {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(u)
}

Step 5 — Migrations with golang-migrate

Install 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 DB-touching tests, use dockertest to spin up PostgreSQL in a container, apply migrations, run tests, teardown.

Step 7 — Production 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

go
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(middleware.JWTAuth(cfg.JWTSecret))
        r.Get("/me", userHandler.Me)
    })
    // Public routes here:
    r.Post("/login", authHandler.Login)
})

Deploy to DomainIndia VPS

See our Running Go Applications on DomainIndia VPS for the systemd + nginx setup. Key addition: run migrations in the systemd unit's ExecStartPre=:

ini
ExecStartPre=/usr/local/bin/migrate -path /opt/myapi/migrations -database $DATABASE_URL up
ExecStart=/opt/myapi/api

Common pitfalls

FAQ

Q chi or gin or echo?

chi — standard library-compatible, minimal, easiest to maintain. gin has more batteries but non-standard handlers. echo similar to gin. chi wins for Go-idiomatic teams.

Q sqlx or GORM or pgx?

sqlx for most. GORM if you like ORMs (but comes with ORM downsides — N+1, magic). pgx for pure performance and Postgres-specific features (listen/notify, COPY).

Q How many requests per second can Go handle?

A well-tuned Go API on a 2 GB DomainIndia VPS comfortably hits 5,000+ req/sec for simple endpoints, 500–2,000 for DB-backed. Bottleneck is usually Postgres, not Go.

Q Do I need Dockerfile for Go?

Not for a single-binary deploy. Just copy the binary to VPS + systemd. Docker is optional but helps for CI/CD uniformity. See Docker & Containers.

Q TypeScript/Node.js vs Go for backend?

Go: better concurrency, lower RAM, faster. Node.js: larger ecosystem, faster to ship for simple APIs, TypeScript if you value strict types. Pick based on team skills.

Deploy Go APIs with confidence on a DomainIndia VPS. Explore VPS plans

Was this article helpful?

Your feedback helps us improve our documentation

Still need help? Submit a support ticket