Natnael Alemayehu
← All writing

Setting up Go and Postgres with Docker Compose

05 Oct 20267 min read#go#docker#postgres

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

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

compose.yaml
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-alpine stays on Postgres 17. A floating tag like latest can jump to a new major version, and Postgres won’t start on a data directory created by an older one.
  • Use a named volume. pgdata keeps your data across restarts. When you want a clean slate, docker compose down -v deletes it.
  • Wait for a healthy database. depends_on on its own only waits for the container to start, not for Postgres to accept connections. The healthcheck plus condition: service_healthy closes 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.

cmd/api/main.go
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

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

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

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