Boni KYC Lab 3 · Go backend ~7 min Step 1
Lab 3 · Go backend

Config & Postgres (pgx, goose)

the server loads config from env, connects to Postgres via a pgxpool,

runs the first migration (the citizens table + extensions), and /healthz reports DB reachability.

Concepts — twelve-factor config, connection pooling, SQL migrations as versioned files, context deadlines on DB calls.

Step 2 · Build

Build

internal/config/config.go:

internal/config/config.go
package config

import (
	"fmt"
	"os"
	"time"
)

type Config struct {
	Addr          string
	DatabaseURL   string        // postgres://app:...@localhost:5432/bonikyc?sslmode=disable (dev)
	ShutdownGrace time.Duration
	// secrets (KEK passphrase, pepper) are loaded SEPARATELY in lab 7 — not here,
	// and NEVER with a struct tag that could log them.
}

func Load() (Config, error) {
	c := Config{
		Addr:          getenv("ADDR", ":8080"),
		DatabaseURL:   os.Getenv("DATABASE_URL"),
		ShutdownGrace: 10 * time.Second,
	}
	if c.DatabaseURL == "" {
		return c, fmt.Errorf("DATABASE_URL is required") // ⟵ YOU: fail fast on missing required config
	}
	return c, nil
}

func getenv(k, def string) string { if v := os.Getenv(k); v != "" { return v }; return def }
Step 3 · Build

migrations/00001_citizens.sql

migrations/00001_citizens.sql (goose format):

migrations/00001_citizens.sql
-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;   -- gen_random_uuid()

CREATE TABLE citizens (
    -- ⟵ YOU: transcribe from docs/01-data-model.md §3 (citizens table).
    -- Type the columns yourself — it's how you internalize the encryption model.
);

-- +goose Down
DROP TABLE citizens;
Step 4 · Build

internal/store/db.go

DB wiring (put in internal/store/db.go):

internal/store/db.go
func Open(ctx context.Context, url string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(url)
	if err != nil { return nil, err }
	cfg.MaxConns = 10                 // ⟵ YOU: the mini PC isn't huge — research a sane pool size
	cfg.MaxConnLifetime = time.Hour
	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil { return nil, err }
	// ⟵ YOU: pool.Ping(ctx) with a short timeout; return error if unreachable.
	return pool, nil
}
Step 5 · Research checkpoints

Research checkpoints

goose

install, goose create, up/down, and the -- +goose StatementBegin/End markers (you'll need them for trigger functions in lab 14). How does goose track applied versions?

pgx vs database/sql

why pgx native (not the database/sql adapter) for a Postgres-only app? (Types, performance, LISTEN/NOTIFY.)

Roles

create app and migrator Postgres roles per 01-data-model §4.4. app must lack DDL rights. Write the GRANTs. Confirm app cannot DROP TABLE.

`sslmode`

fine as disable for localhost dev; what will it be once the DB is on the mini PC behind the app on the same host? (Trick question — think about the trust boundary B3.)

Step 6 · Verify

Verify

  • goose up creates citizens matching the data-model doc exactly.
  • Server refuses to start with a clear error if DATABASE_URL is unset.
  • /healthz returns {"status":"ok","db":"up"}; stop Postgres → "db":"down"

and a 503.

  • Connecting as app and running DROP TABLE citizens; is denied.