Why SQLite — not Postgres
Postgres is the default database for a SaaS starter. It’s also the default reason a starter is hard to deploy: you need a server, a port, credentials, a connection pooler, and a backup strategy that isn’t pg_dump on a cron. Laju Go uses SQLite, and for a starter that targets a single-instance VPS, the tradeoff is almost always in SQLite’s favor.
One file, no server
Section titled “One file, no server”SQLite is a library, not a server process. The database is a single file on disk. There is no postgres daemon to install, no port to open, no DATABASE_URL to configure, no connection pool to tune against a remote host. Laju Go opens it with one line:
db, err := sql.Open("sqlite3", cfg.DBPath)The DSN is file:data.db?_foreign_keys=on — a path, not a network address. This means the database lives and dies with the app binary. Deploy is scp laju-go data.db server:. Backup is cp data.db data.db.bak. There is no separate failure mode for “the database is up but the app can’t reach it.”
WAL mode for concurrency
Section titled “WAL mode for concurrency”The old knock on SQLite was write concurrency — a single writer lock. WAL (Write-Ahead Logging) mode fixes this. Readers and writers don’t block each other; writes append to a WAL file and checkpoint asynchronously. Laju Go enables it explicitly, along with a set of PRAGMAs tuned for a single-instance NVMe VPS:
func initDatabase(dbPath string) (*sql.DB, error) { db, err := sql.Open("sqlite3", dbPath) // ...
db.SetMaxOpenConns(15) db.SetMaxIdleConns(10) db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxIdleTime(30 * time.Second)
db.Exec("PRAGMA foreign_keys = ON") db.Exec("PRAGMA journal_mode = WAL") db.Exec("PRAGMA synchronous = NORMAL") db.Exec("PRAGMA cache_size = -16000") // 16 MB cache db.Exec("PRAGMA mmap_size = 268435456") // 256 MB memory-mapped I/O db.Exec("PRAGMA temp_store = MEMORY") db.Exec("PRAGMA busy_timeout = 5000") // wait 5s for locks db.Exec("PRAGMA wal_autocheckpoint = 1000")
return db, nil}synchronous = NORMAL trades strict durability (FULL) for a ~2x write speedup while remaining safe against power loss in WAL mode. mmap_size = 256MB lets SQLite memory-map the file so reads bypass the page cache syscall path. busy_timeout = 5000 means writers wait for a lock instead of failing immediately.
Tens of thousands of requests per second
Section titled “Tens of thousands of requests per second”On a modern NVMe drive with WAL mode, SQLite sustains tens of thousands of writes per second and far more reads — more than enough for a SaaS starter’s first 10,000 users. The bottleneck for a typical SaaS is not the database’s raw throughput; it’s the application’s query patterns and indexing. SQLite with the right PRAGMAs handles the load that a single-instance app can generate.
Backup is a file copy
Section titled “Backup is a file copy”Backing up Postgres is a production concern: pg_dump, WAL archiving, PITR, a replica. Backing up SQLite is:
sqlite3 data.db ".backup data.db.bak"# or, if the app is stopped:cp data.db data.db.bakThe .backup command uses the online backup API — it works while the app is running and produces a consistent snapshot. For a starter, this is a one-line cron job. There is no replication slot to fill up, no WAL archive to fill the disk.
When you outgrow it
Section titled “When you outgrow it”SQLite is single-instance. The moment you need horizontal scale — two app servers hitting the same database — you need Postgres. Laju Go’s architecture is built for that day: the query layer is generated by sqlc from plain SQL, so swapping SQLite for Postgres is a matter of changing the driver, the schema types, and regenerating. The three-tier rule (Handler → Service → Query) means no business logic touches the database driver directly.
But most SaaS starters never reach that day. Most never reach the point where a single VPS can’t handle the load. SQLite gets you to revenue without a database server, and the migration path is open when you need it.
The tradeoff
Section titled “The tradeoff”SQLite is the wrong choice if you need multi-writer concurrency across nodes, if you need Postgres-specific features (JSONB operators, full-text search via tsvector, logical replication), or if you’re deploying on a network filesystem where file locks are unreliable. It is the right choice for a starter that wants to ship on one box, back up with cp, and not run a database server until the business pays for one.