Rewriting Atlas Docs' Backend in Go
Atlas Docs is live at atlas.binary.ovh and, feature-wise, it works. It's a self-hosted, offline-first note-taking app — React + Tiptap on the frontend, IndexedDB as the source of truth, syncing to a small Python/FastAPI + SQLite backend. The problem is the backend feels sluggish. UI actions that only toggle a panel can still feel like they're waiting on something. I want the whole stack to feel instant.
I could profile the Python version and patch hotspots. Instead I'm treating this as a deliberate learning project: rewrite the backend in Go, from scratch, idiomatically — not a line-for-line transliteration. I'll document decisions, mistakes, and code as I go.
Why Go
A few concrete reasons, not vibes:
- Single static binary. Deploy is a file, not a venv + uvicorn process tree.
- Lower baseline memory on an 8GB VPS, where every resident megabyte matters.
- Explicit style as a complement to years of Python. Explicit error returns instead of exceptions. Explicit concurrency instead of asyncio's implicit event loop. Go forces you to name the things Python often hides.
The existing Python backend is small: 1,694 lines across 12 modules — JWT auth with scrypt, documents/folders CRUD, a sync endpoint (last-write-wins on client timestamps, with throttled version snapshots), comments, share links (public read-only, optionally password-protected), and "publish to vault" which pushes a doc to a separate public wiki at vault.binary.ovh.
Also present, and explicitly out of scope for pass one: an AI layer (a personal Grok bridge), a Telegram bot, and a live-collab websocket relay. Each is its own concurrency lesson — goroutines and channels for a poll loop, websockets for collab — and I don't want them rushed in next to core CRUD. They get later phases.
Stack choices
These are the interesting decisions. The goal is to learn Go, not "FastAPI but with curly braces."
| Piece | Choice | Why |
|---|---|---|
| HTTP | net/http + chi |
Handlers are plain http.Handler. Gin/Echo invent their own Context and bury the stdlib. |
| SQLite | modernc.org/sqlite (pure Go) | No cgo. Cross-compile and a tiny scratch Docker image become trivial — half the point of Go here. |
| Queries | sqlc | Hand-written .sql → typed Go structs and functions. No runtime reflection. Same discipline as SQLAlchemy 2.0's typed Mapped[...], one step further. |
| Migrations | goose | Plain up/down .sql, same mental model as Alembic. |
| Logging | stdlib log/slog |
Structured JSON. Enough. |
I almost reached for Gin out of habit. Then I noticed I'd be learning a framework's Context type instead of Go's actual request path. Chi stays out of the way.
Same story with ORMs. GORM would ship faster on day one and teach me less. sqlc keeps SQL visible and the types honest.
Phase 1: done
What's shipped so far is deliberately boring infrastructure:
- chi router
- slog JSON logging with a request-id and duration on every line
- graceful shutdown on SIGINT/SIGTERM — drains in-flight requests before exit (not automatic in Go the way uvicorn makes it feel)
GET /api/health→{"ok": true}, matching the Python health check exactly
Tests hit the real router through net/http/httptest, table-driven, under go test -race. Lint is clean under golangci-lint.
Lint immediately earned its keep: it caught an unchecked error from a JSON encode. In Python that class of failure tends to surface as an exception. In Go it compiles fine and fails quietly unless you check the return. That's exactly the discipline I'm here to practice.
// the kind of thing golangci-lint refuses to let slide
if err := json.NewEncoder(w).Encode(resp); err != nil {
// handle it — don't ignore it
}
What's next
Phases, in order:
- Models/structs + validation
- SQLite + sqlc data layer
- JWT auth + password hashing
- Core CRUD — documents, sync, shares, publish — each with tests
- Config, logging, error-handling polish
- Multi-stage Docker build to a tiny image; side-by-side deploy on its own subdomain
- Head-to-head benchmarks — latency, memory, image size — once there's enough surface area for the comparison to mean something
Important: the Go service will not replace the live Python backend on day one. Cutover is a deliberate later decision, not a rushed swap. The Python app stays production until the Go one is proven, side by side.
I'm not rewriting this because Python "can't" be fast enough for a personal docs app. I'm rewriting it because the backend is small enough to rebuild carefully, Go is a skill I want under real constraints, and "build in public" only works if the project is small enough to finish and honest enough to show the boring middle.
Next post: models, validation, and the first sqlc queries.