Most Go services I build start from the same foundation: Postgres for storage, sqlc for the data layer, goose for migrations, and Docker so everything runs the same way on every machine. I’ve used versions of this setup in Chirpy, a small Twitter-like API, and Grapton, an RSS aggregator, and it has held up well each time.
This series builds that setup from an empty folder to a tested, containerized API, then keeps going into the part every API eventually needs: knowing who’s calling it. This first part gets Go and Postgres running together with Docker Compose. Part 2 adds the schema and type-safe queries with sqlc, Part 3 covers integration tests and a production image, and Parts 4 through 6 add password auth, authorization and session rotation on top.
What we’re building
A small API for users and their notes. The domain is deliberately boring, so the focus stays on the plumbing. By the end of the series it will:
- start with a single command on any machine that has Docker,
- talk to Postgres through generated, type-safe Go code,
- apply its own database migrations on startup,
- and have integration tests that run against a real Postgres.
Project layout
notes-api/
├── cmd/api/main.go # entry point
├── internal/database/ # generated by sqlc (Part 2)
├── sql/
│ ├── schema/ # goose migrations (Part 2)
│ └── queries/ # SQL that sqlc compiles (Part 2)
├── compose.yaml
├── Dockerfile
├── Makefile
└── sqlc.yaml
Keeping the SQL in its own top-level folder makes schema changes easy to spot in a pull request, and the generated code stays out of the way under internal/.
Create the module and add pgx, the Postgres driver we’ll use throughout:
mkdir notes-api && cd notes-api
go mod init github.com/natnael-alemayehu/notes-api
go get github.com/jackc/pgx/v5
Postgres in Docker Compose
I stopped installing Postgres directly on my machine a while ago. A Compose file gives every project its own database at a pinned version, and throwing it away takes one command.
services:
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: notes
POSTGRES_PASSWORD: notes
POSTGRES_DB: notes
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U notes -d notes"]
interval: 2s
timeout: 3s
retries: 15
api:
build: .
environment:
DATABASE_URL: postgres://notes:notes@db:5432/notes?sslmode=disable
PORT: "8080"
ports:
- "8080:8080"
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
Three details in this file save a lot of debugging later:
- Pin the major version.
postgres:17-alpinestays on Postgres 17. A floating tag likelatestcan jump to a new major version, and Postgres won’t start on a data directory created by an older one. - Use a named volume.
pgdatakeeps your data across restarts. When you want a clean slate,docker compose down -vdeletes it. - Wait for a healthy database.
depends_onon its own only waits for the container to start, not for Postgres to accept connections. The healthcheck pluscondition: service_healthycloses that gap, which gets rid of the classic connection-refused error on the first run.
One more thing that trips people up: inside the Compose network the API reaches Postgres at the hostname db, while from your laptop it’s localhost:5432. Same database, two addresses.
Connecting from Go with pgx
I use pgx directly instead of going through database/sql. It understands Postgres types natively, and sqlc can generate code for it, which we’ll rely on in Part 2. pgxpool gives us a connection pool that’s safe to share across goroutines.
package main
import (
"context"
"log"
"net/http"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("create pool: %v", err)
}
defer pool.Close()
// Fail fast if the database is unreachable.
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
log.Fatalf("connect to postgres: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
if err := pool.Ping(r.Context()); err != nil {
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
w.Write([]byte("ok"))
})
addr := ":" + os.Getenv("PORT")
log.Printf("listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
Two choices are worth calling out. pgxpool.New doesn’t actually connect, so the service pings the database at startup with a timeout. A bad DATABASE_URL then fails immediately with a clear error instead of on the first request. And /healthz checks the database too, so a load balancer stops sending traffic to an instance that has lost its connection.
A small, multi-stage Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
WORKDIR /src
# Download dependencies first so this layer stays cached.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
The first stage has the full Go toolchain. The final image contains only the compiled binary on a distroless base, running as a non-root user. Copying go.mod and go.sum before the rest of the source lets Docker cache the dependency download, so a rebuild after a code change takes seconds. CGO_ENABLED=0 produces a static binary, which the distroless static image needs. Use the same Go version as your go.mod.
Add a .dockerignore that lists .git and anything else the build doesn’t need, so it isn’t sent to Docker on every build.
Running it
docker compose up --build
# in another terminal
curl localhost:8080/healthz
You should get ok back. Now run docker compose stop db and call the endpoint again: you’ll get a 503, which is exactly what you want a load balancer to see.
Finally, a Makefile so nobody has to remember the commands:
DB_URL ?= postgres://notes:notes@localhost:5432/notes?sslmode=disable
.PHONY: up down logs psql
up:
docker compose up -d --build
down:
docker compose down
logs:
docker compose logs -f api
psql:
docker compose exec db psql -U notes -d notes
make psql opens a Postgres shell inside the database container. You’ll use it a lot in the next part.
Next up
We now have a Go service and a database that start together with one command, on any machine. Nothing is stored yet, though. In Part 2 we’ll add a schema with goose and generate a type-safe data layer with sqlc.