Code

Schema and migrations — the users table, embedding, config

First, the new dependencies:

go get github.com/jackc/pgx/v5 github.com/pressly/goose/v3 github.com/joho/godotenv

Three files as tabs, in dependency order: 00001_users.sqldb/embed.goconfig.go (the schema, then embedding it, then teaching config where the database lives). The last is a modify of your lesson-1 config.

1. db/migrations/00001_users.sql — the first migration. The -- +goose markers tell goose where each direction begins:

-- +goose Up
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
    id            UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    email         TEXT        NOT NULL UNIQUE,
    password_hash TEXT        NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE users;

Up builds it, Down reverses it. gen_random_uuid() (from pgcrypto) gives every row a UUID primary key — no guessable sequential IDs, which matters once documents belong to users. The email is UNIQUE; password_hash is filled in lesson 4.

2. db/embed.go — bake the migrations into the binary:

//go:embed migrations/*.sql
var FS embed.FS

This is what makes deployment self-migrating: the .sql files travel inside the compiled binary, and startup runs any that haven't been applied.

3. internal/config/config.go (modify) — config learns where the database is. The change: a DatabaseURL field, godotenv to read a local .env, and a production guard.

DatabaseURL: getenv("DATABASE_URL", "postgres://postgres@localhost:5432/signflow?sslmode=disable"),

The dev default connects to a local Postgres with no password (trust auth), so go run works with zero config. Everything from lesson 1 — Port, Env, BaseURL, IsProd — is carried forward unchanged; the diff is just the new field and the .env loading.

.env in dev. godotenv.Load() reads a .env file if present (and shrugs if not), so you can keep DATABASE_URL in a gitignored .env instead of exporting it every shell. In prod, Railway sets the real environment.