Client Area

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

ByDomain India Team·DomainIndia Engineering
4 min read24 Apr 20263 views
# 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.

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

Still need help?

Our support team can assist you directly.

Submit Ticket