A weekend architecture experiment: a production-shaped Go API server with Postgres, sqlc, Kamal deployment, and an embedded SvelteKit frontend for a small AI-assisted read-it-later app.
The app lets you save links to articles and blog posts, capture why you saved them, and ask OpenAI to generate a brief triage card with a summary, estimated read time, recommended reading mode, best sections to read, and read/skip guidance.
- Save article/blog post URLs with a personal “why I saved this” note.
- Fetch article metadata and page text from the Go backend.
- Store items in Postgres using sqlc-generated queries.
- Run embedded migrations on app startup.
- Generate AI triage output with OpenAI:
- brief summary
- estimated read time
- recommended mode: read fully, skim, reference, or skip
- best sections to read
- “You should read this if” bullets
- “You should skip this if” bullets
- Triage saved items as read soon, skim later, reference, skipped, or archived.
- Run SvelteKit with Vite HMR in development.
- Build SvelteKit to static files and embed them into a single Go binary for production.
- Deploy with Docker + Kamal + a Postgres accessory.
cmd/server Go HTTP server entrypoint
internal/app domain model, article fetching, OpenAI analysis
internal/db sqlc schema, queries, generated Go code
internal/storage Postgres store + embedded migrations
internal/httpserver API routes and SvelteKit asset/proxy serving
internal/storage/migrations Postgres migrations embedded into the binary
ui/svelte SvelteKit frontend
web/build generated static frontend assets embedded by Go
config/deploy.yml Kamal deployment config
compose.yml local/container Postgres + app stack
Routes:
/app— SvelteKit frontend./api/items— JSON API consumed by SvelteKit./healthz— deployment health check./— redirects to/app.
Create a local .env file and add your OpenAI key:
cp .env.example .envThen edit .env:
OPENAI_API_KEY=sk-your-key-here
OPENAI_MODEL=gpt-5.4-mini
ADDR=:8080
DATABASE_URL=postgres://read_later:password@127.0.0.1:55432/read_later?sslmode=disable
POSTGRES_PASSWORD=password.env is ignored by git.
make devOpen http://localhost:8080.
Dev mode starts:
- Postgres via Docker Compose on
localhost:5432 - Go server on
localhost:8080 - SvelteKit/Vite dev server on
localhost:5173 - Go proxying
/app/*to Vite for HMR
make compose-upThis builds the production container and starts Postgres locally.
make build
./serverThe production binary serves the embedded SvelteKit build from web/build and runs DB migrations on startup.
After changing internal/db/schema.sql or internal/db/queries/*.sql, regenerate query code:
make sqlcThe Kamal config is in config/deploy.yml. It deploys:
- one Go web container
- one Postgres accessory
- GHCR image:
ghcr.io/hanifcarroll/read-later-lab
Required secrets:
KAMAL_REGISTRY_PASSWORD
POSTGRES_PASSWORD
DATABASE_URL
OPENAI_API_KEY
For production, DATABASE_URL should point at the Kamal accessory, for example:
postgres://read_later:<password>@read-later-lab-postgres:5432/read_later?sslmode=disable
The Tailscale overlay in config/deploy.tailscale.yml disables the public Kamal proxy and publishes the app only on VPS localhost:
kamal deploy -d tailscaleThen expose that private localhost port to your tailnet from the VPS:
tailscale serve --bg 8080Access it from any device in your tailnet at the HTTPS URL shown by:
tailscale serve statusThe VPS hostname is configured as hetzner-vps when joining Tailscale.
kamal app exec 'ps -o pid,rss,comm -C server'
kamal app exec 'cat /sys/fs/cgroup/memory.current'
kamal accessory exec postgres 'ps aux'
ssh root@<vps-host> 'docker stats --no-stream'Useful checks:
make test
make build