Go Development

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

By Domain India Team · DomainIndia EngineeringPublished · Updated 4 min read
Knowledge base article
Contents (13 sections)
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

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/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

Forgetting rows.Close()
connection leaks. Use sqlx.GetContext / SelectContext which close for you.
Using database/sql's Query and ignoring err
get nil pointer crashes. Always check err on query + scan.
No context cancellation
slow queries don't stop when client disconnects. Always pass r.Context() to DB calls.
Panics leak into clients
include middleware.Recoverer to convert panics to 500 responses with logging.
Binding to 0.0.0.0 without firewall
exposes internal port. Always front with nginx; bind Go app to 127.0.0.1:8080.
Building with wrong Go version
go.mod pins minimum. CI/CD and prod must match.

FAQ

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.

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

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.

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.

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