Bun 1.2 Built-in SQLite for a Zero-Dependency Local-First Backend
I ran an experiment. When I packed a REST API server written with Bun + Hono + Drizzle ORM into a Docker image, the total image size was 68MB. Implementing the same functionality with Node.js + Express + Prisma + PostgreSQL client results in an image exceeding 400MB, plus a separately running PostgreSQL container. The absence of an external DB is not merely a matter of architectural preference — it affects deployment complexity, infrastructure costs, and local development environment setup time across the board.
With Bun 1.2 embedding bun:sqlite directly into the runtime, this approach has become a practical option. Adding Hono (14kB, zero dependencies) and Drizzle ORM (zero dependencies) means you can build a type-safe REST API server without installing any external DB drivers.
Let me first clarify what "zero-dependency" means here. You still install npm packages like bun add hono drizzle-orm zod. What gets eliminated are packages that require native binding builds — such as SQLite drivers (better-sqlite3, sql.js) — along with connection configuration to an external DB server. Throughout this article, "zero-dependency" is used in this sense.
Why These Three Tools Work Well Together
bun:sqlite — What Runtime Embedding Actually Means
When you install better-sqlite3 in Node.js, node-gyp compiles C++ bindings at npm install time. Those bindings expose SQLite functions to JavaScript via N-API, and marshaling overhead between JS and C++ occurs on every query. If you've ever struggled with gyp build failures in CI environments or Docker builds, that's the reason.
bun:sqlite has no such intermediate layer. The Bun runtime itself links SQLite directly at the C level, so no driver installation or native build is needed.
// bun:sqlite can be imported directly without any separate installation
import { Database } from "bun:sqlite";
const db = new Database("app.db");
db.run("PRAGMA journal_mode=WAL");
db.run("PRAGMA busy_timeout = 5000");The API design is synchronous. Since most SQLite queries run in microseconds and are file I/O-based, the synchronous API rarely meaningfully blocks the event loop. It also offers a practical advantage: you can use it without async/await chains.
Transactions accept a callback and automatically wrap it with BEGIN/COMMIT, rolling back on exceptions:
const insertMany = db.transaction((users: Array<{ name: string; email: string }>) => {
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)");
for (const user of users) {
insert.run(user.name, user.email);
}
});
insertMany([
{ name: "Alice", email: "alice@example.com" },
{ name: "Bob", email: "bob@example.com" },
]);This is the raw transaction API of bun:sqlite. When used through Drizzle ORM, the API differs — covered in the route implementation section below.
Drizzle ORM — An ORM That Doesn't Hide SQL
Drizzle's design philosophy is "add type safety without abstracting away SQL." Reading the query code immediately reveals which SQL will be executed.
Schema definition:
// src/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
email: text("email").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(() => new Date()),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content"),
authorId: integer("author_id").references(() => users.id),
});Connecting to bun:sqlite:
// src/db.ts
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import * as schema from "./schema";
const sqlite = new Database("app.db");
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA busy_timeout = 5000");
export const db = drizzle(sqlite, { schema });Queries are written in two styles:
import { eq, desc } from "drizzle-orm";
import { db } from "./db";
import { users, posts } from "./schema";
// SQL-like API — the SELECT clause is directly visible
const recentPosts = await db
.select()
.from(posts)
.orderBy(desc(posts.id))
.limit(10);
// Relational API — fetch related data with the with clause
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: { posts: true },
});
// The type of userWithPosts is fully inferredHono — A Router That Uses Only Web Standard APIs
Hono uses only Web Standard APIs such as Request and Response, without platform-specific types like Node.js's IncomingMessage or ServerResponse. As a result, the same code runs on Bun, Deno, and Cloudflare Workers. Combined with Bun, TypeScript can be executed directly without a separate transpilation step.
// src/index.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { HTTPException } from "hono/http-exception";
import { postsRoute } from "./routes/posts";
const app = new Hono();
app.use("*", logger());
app.route("/api/posts", postsRoute);
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
console.error(err);
return c.json({ error: "Internal server error" }, 500);
});
export default {
port: Number(process.env.PORT ?? 3000),
fetch: app.fetch,
};A single line — bun src/index.ts — starts the server.
Overall Architecture Flow
The path from incoming request to outgoing response:
There are no external network calls. All I/O from request to response is completed entirely within the local file system.
Practical Setup
Project Initialization
bun init -y
bun add hono drizzle-orm @hono/zod-validator zod
bun add -d drizzle-kitbun:sqlite is not in the list. There is nothing to install.
drizzle.config.ts:
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: "./app.db",
},
} satisfies Config;Migration Workflow
drizzle-kit push can drop data if existing tables are present. For production deployment scripts, always use the generate + migrate sequence. To automatically apply migrations at app startup:
// Add to src/db.ts
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
migrate(db, { migrationsFolder: "./drizzle" });Real-World Route Implementation
// src/routes/posts.ts
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { db } from "../db";
import { posts, users } from "../schema";
import { eq, desc } from "drizzle-orm";
const postsRoute = new Hono();
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().optional(),
authorId: z.number().int().positive(),
});
postsRoute.get("/", async (c) => {
const page = Number(c.req.query("page") ?? "1");
const limit = 20;
const offset = (page - 1) * limit;
const result = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.id))
.limit(limit)
.offset(offset);
return c.json(result);
});
postsRoute.post("/", zValidator("json", createPostSchema), async (c) => {
const body = c.req.valid("json");
const result = await db.transaction(async (tx) => {
const author = await tx
.select()
.from(users)
.where(eq(users.id, body.authorId))
.get();
if (!author) {
// A missing author is a client error — return 404, not 500
throw new HTTPException(404, { message: "Author not found" });
}
const [post] = await tx.insert(posts).values(body).returning();
return post;
});
return c.json(result, 201);
});
export { postsRoute };Use tx instead of db inside a transaction. tx is a connection bound to the current transaction — using it ensures transaction isolation. Using db directly executes the query outside the current transaction scope.
Choosing a Deployment Strategy
Starting with local SQLite doesn't mean you have to stop there. As requirements grow, you can simply replace the data layer:
Because Hono is based on Web Standard APIs, moving from Bun to Cloudflare Workers requires no changes to route code — only the platform changes. Swapping the DB is handled by updating the driver configuration in a single db.ts file.
Adding S3 Backup with Litestream
Litestream streams SQLite file replication to S3 in real time. It continuously uploads WAL changes without any separate backup scripts.
# litestream.yml
dbs:
- path: /app/app.db
replicas:
- type: s3
bucket: my-backup-bucket
path: db
region: ap-northeast-2# Dockerfile
FROM oven/bun:1.2-slim
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build # if drizzle-kit generate is included
# Copy the Litestream binary alongside
COPY --from=litestream/litestream:latest /usr/local/bin/litestream /usr/local/bin/
EXPOSE 3000
# -exec flag: Litestream manages the app process as a child process.
# If the app crashes, Litestream also exits and the container restarts.
CMD ["litestream", "replicate", "-exec", "bun src/index.ts", "litestream.yml"]The -exec flag is the key. Litestream runs the app as a child process while managing replication. If the app terminates abnormally, Litestream exits alongside it, signaling the container to restart. Running Litestream as a sidecar or background process decouples the lifecycle of the app state and the replication process, complicating recovery scenarios.
When to Use It and When to Avoid It
| Suitable Cases | Reason |
|---|---|
| Internal tools, admin panels | Low concurrent write load; a single server is sufficient |
| Small-scale SaaS MVPs | Reach production quickly without infrastructure setup |
| Multi-tenant services | One SQLite file per tenant → independent isolation, backup, and migration |
| CLI tools / local agents | Maximize installation convenience with an embedded DB |
| Cases to Avoid | Reason | Alternative |
|---|---|---|
| Thousands of concurrent writes per second | Writes are serialized even in WAL mode | PostgreSQL + connection pool |
| Multiple servers accessing the same DB file | SQLite on a network drive causes locking issues | Turso, LiteFS |
| Schemas with frequent column type changes | SQLite cannot change column types via ALTER TABLE | Design the schema carefully upfront |
SQLite's single-writer constraint is both a limitation and a characteristic. Thanks to write serialization, data consistency is automatically guaranteed without optimistic locking or MVCC. For services with low write throughput, this simplicity becomes an advantage.
Getting Started Summary
# 1. Create the project
bun init -y
bun add hono drizzle-orm @hono/zod-validator zod
bun add -d drizzle-kit
# 2. Write the schema and initialize the development DB
bunx drizzle-kit push
# 3. Start the server
bun src/index.tsFor production deployment, switch to a flow that generates and commits migration files:
bunx drizzle-kit generate # generate migration files
git add drizzle/ # include in version control
# At deploy time, the migrate() function is applied automatically before the app startsReferences
- Bun 1.2 Official Release Notes
- bun:sqlite Official Documentation
- Drizzle ORM × Bun SQLite Official Guide
- Drizzle ORM × bun:sqlite Connector Documentation
- Hono Official Documentation — Getting Started with Bun
- Bun Official — Building an HTTP Server with Hono
- Bun Official — Using Drizzle ORM
- SQLite WAL Mode Official Documentation
- Litestream Official Documentation
- bun:sqlite Performance Benchmark Discussion — GitHub
- Introduction to Drizzle ORM — Why Drizzle?