# 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](https://domainindia.com/support/kb/running-go-applications-vps-systemd-reverse-proxy) 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.