$ cat ./blog/bun-hono-minimal-starter.md
Bun + Hono で始める個人開発の最小構成 Cloudflare Workers 以外の場面で個人開発 API を最小構成で組むなら Bun + Hono。TypeScript 設定もテストランナーも追加ライブラリも無しに、bun init から bun test まで通せる構成を、Fly.io / Railway / Cloudflare Workers の 3 方向にそのまま出せる形で解説します。
author r43
date 2026-04-23 (2026年4月23日)
reading 3 min
commit 5ea8865 Cloudflare Workers 前提に従わない場面で、個人開発 API をどこまで薄く組めるか。Node + Express は今どき重すぎ、tsx + Hono は中途半端。この間を埋めるのが Bun + Hono です。本記事では、TS 設定もテストランナーも追加ライブラリも入れずに bun run だけで動く最小 API を組み、Fly.io / Railway / Cloudflare Workers の 3 方向に出せる状態までをまとめます。
なぜ Bun + Hono なのか
TypeScript がネイティブに動く — tsc も tsx も要らない
テストランナー同梱 — bun test が describe / expect を Jest 互換 API で提供
HTTP サーバ同梱 — Bun.serve({ fetch }) だけでサーバが立つ
Hono は app.fetch を返すランタイム中立な設計 — 同じコードが Bun / Workers / Deno / Node で動く
bun build --compile で 単一バイナリ化 も可能
依存は実質 hono + zod + @hono/zod-validator の 3 つ。package.json が 20 行を超えない構成になります。
到達点と前提
作るもの: 最小の投稿 API (POST /posts / GET /posts/:id)。永続化は本記事の範囲外(メモリ内 Map に保持)で、本題は「薄いランタイム構成」です。
動作確認バージョン (2026-04 時点最新):
Bun v1.3 / Hono v4.12 / Zod v4.3 / @hono/zod-validator v0.7
セットアップ
コマンド 2 発で終わります。
bun init -y # package.json / tsconfig.json / .gitignore を生成
bun add hono @hono/zod-validator zod
bun init はインタラクティブ版もありますが、既存プロジェクトに最小限の土台だけ欲しければ -y で十分です。
src/app.ts — ランタイム中立な Hono アプリ
// src/app.ts
import { Hono } from "hono" ;
import { HTTPException } from "hono/http-exception" ;
import { zValidator } from "@hono/zod-validator" ;
import { z } from "zod" ;
type Post = { id : string ; title : string ; body : string ; createdAt : number };
const store = new Map < string , Post >();
const postInput = z. object ({
title: z. string (). min ( 1 ). max ( 200 ),
body: z. string (). min ( 1 ). max ( 50_000 ),
});
const app = new Hono ();
app. onError (( err , c ) => {
if (err instanceof HTTPException ) return err. getResponse ();
console. error (err);
return c. json ({ code: "internal" , message: "unexpected error" }, 500 );
});
app. post ( "/posts" , zValidator ( "json" , postInput), ( c ) => {
const input = c.req. valid ( "json" );
const id = crypto. randomUUID ();
const post : Post = { id, ... input, createdAt: Date. now () };
store. set (id, post);
return c. json (post, 201 );
});
app. get ( "/posts/:id" , ( c ) => {
const post = store. get (c.req. param ( "id" ));
if ( ! post) throw new HTTPException ( 404 , { message: "post not found" });
return c. json (post);
});
export default app; // ← Bun / Workers / Deno すべてこの 1 行で対応
export default app が Bun でも Workers でも同じエントリ になるのが Hono の強みです。Bun は default export に fetch メソッドがあれば、それを Bun.serve に自動適用して起動します。
起動とテスト
開発は bun --watch src/app.ts、テストは bun test。Hono には app.request() という疑似 HTTP テスト用メソッド があり、supertest を足さずにそのままテストが書けます。
// src/app.test.ts
import { describe, test, expect } from "bun:test" ;
import app from "./app" ;
describe ( "posts api" , () => {
test ( "POST /posts creates a post" , async () => {
const res = await app. request ( "/posts" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({ title: "hi" , body: "hello world" }),
});
expect (res.status). toBe ( 201 );
const post = await res. json ();
expect (post.title). toBe ( "hi" );
});
test ( "GET /posts/:id returns 404 when missing" , async () => {
const res = await app. request ( "/posts/not-exist" );
expect (res.status). toBe ( 404 );
});
});
実際の HTTP サーバを立てずに fetch ハンドラを直接呼ぶので、テスト実行が速く CI コストも下がります。
デプロイ選択肢 — 同じコードで 3 方向
出力先 必要な設定ファイル 手順 Fly.io / Railway / 任意 VPS Dockerfile (Bun ベース)bun install --production 後 bun src/app.ts を起動コマンドにCloudflare Workers wrangler.jsoncmain: "src/app.ts" を指定して wrangler deploy。Bun 特有 API を使っていなければそのまま動く単一バイナリ 不要 bun build --compile --outfile server src/app.ts
本記事のコード(メモリ Map 保存)は 3 つとも動きます。永続化を D1 / Postgres / R2 に差し替える段になっても、Hono アプリ本体は変更不要なのがこの構成の芯です。
落とし穴
Node 互換は完全ではない — fs.promises の一部、一部 crypto API、OS 依存コマンドが未実装のケースがあります。本番投入前に bun run で通し実行する
bun --watch が TS の変更を全部見ているとは限らない — tsconfig.json の include を適切にしないと、サブディレクトリが watch 外になる
Bun.serve({ fetch }) 直接利用 vs export default app — どちらも動きますが、ランタイム可搬性を重視するなら後者を基本にする
本番 Docker で bun install のみだと devDeps も入る — bun install --production --frozen-lockfile を明示する
まとめ
Bun + Hono は、TS 設定・テストランナー・追加ライブラリなしで bun init から bun test までを 1 行で通せる、個人開発の 下限構成 です。同じコードを Cloudflare Workers にも VPS にも単一バイナリにも出せる柔軟性が、このスタックの本当の価値です。
次回は Better Auth + D1 による認証実装と CVE-2025-61928 対策 を書く予定です。
参考
$ tree ./repo/
記事で登場したコードをひとつの最小リポジトリとしてまとめました。ファイルツリーから切り替えて、全体の構造をそのまま確認できます。
┌─ bun-hono-minimal-starter ────────── files 08 ────────────────────── ● ● ● ─┐ $ cat ./src/app.ts ts
node_modules/
.DS_Store
# Bun — bun.lock (text) はコミット対象。バイナリ版の旧 bun.lockb が残っていれば無視する
bun.lockb
.bun/
# build
dist/
server
*.tsbuildinfo
# env / secrets
.env
.env.*
!.env.example
.dev.vars
# editor
.vscode/
.idea/
# cloudflare
.wrangler/ # ── build stage: 依存インストールのみキャッシュに乗せる ──
FROM oven/bun:1.3 AS deps
WORKDIR /app
# Bun 1.2+ のデフォルトは bun.lock (text)。旧形式 bun.lockb もあれば拾う
COPY package.json bun.lock* bun.lockb* ./
RUN bun install --production --frozen-lockfile
# ── runtime stage ──
FROM oven/bun:1.3-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json tsconfig.json ./
COPY src ./src
# Hono の app は `default export` で fetch を提供するので、
# Bun.serve がそのまま起動する。PORT は Fly / Railway / Render が自動で渡す。
ENV PORT=8080
EXPOSE 8080
USER bun
CMD [ "bun" , "src/app.ts" ] # bun-hono-minimal-starter
Bun + Hono で組む、ランタイム中立な最小 API テンプレート。
[ 記事本文 ]( https://r43lab.com/blog/bun-hono-minimal-starter ) と対になるコピペ用リポジトリです。
## Quick start
```bash
bun install # 依存インストール
bun --watch src/app.ts # ローカルで起動 (ホットリロード)
bun test # テスト実行
```
## デプロイ
このリポジトリは 3 つのランタイムに同じソースのまま出せます。
- **Fly.io / Railway / 任意 VPS** → `Dockerfile` を使ってコンテナ起動
- **Cloudflare Workers** → `wrangler.jsonc` を使って `wrangler deploy`
- **単一バイナリ** → `bun build --compile --outfile server src/app.ts`
```bash
# Fly.io
fly launch --dockerfile Dockerfile
# Railway
railway up
# Cloudflare Workers
npx wrangler deploy
# 単一バイナリ
bun build --compile --outfile server src/app.ts
./server
```
## 依存バージョンについて
`package.json` は **記事執筆時点 (2026-04) のスナップショット** です。Bun / Hono は更新頻度が高いので、clone 後に最新化を推奨します。
```bash
bun outdated
bun update
bun test
```
## Stack
Bun v1.3 · Hono v4.12 · Zod v4.3 · `@hono/zod-validator` v0.7 {
"name" : "bun-hono-minimal-starter" ,
"private" : true ,
"type" : "module" ,
"module" : "src/app.ts" ,
"scripts" : {
"dev" : "bun --watch src/app.ts" ,
"start" : "bun src/app.ts" ,
"test" : "bun test" ,
"build:bin" : "bun build --compile --outfile server src/app.ts" ,
"deploy:cf" : "wrangler deploy"
},
"dependencies" : {
"@hono/zod-validator" : "^0.7.6" ,
"hono" : "^4.12.14" ,
"zod" : "^4.3.6"
},
"devDependencies" : {
"@types/bun" : "^1.3.0" ,
"typescript" : "^6.0.3" ,
"wrangler" : "^4.84.0"
}
} import { describe, test, expect } from "bun:test" ;
import app from "./app" ;
describe ( "posts api" , () => {
test ( "POST /posts creates a post" , async () => {
const res = await app. request ( "/posts" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({ title: "hi" , body: "hello world" }),
});
expect (res.status). toBe ( 201 );
const post = await res. json ();
expect (post.title). toBe ( "hi" );
expect ( typeof post.id). toBe ( "string" );
});
test ( "POST /posts validates body" , async () => {
const res = await app. request ( "/posts" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({ title: "" , body: "" }),
});
expect (res.status). toBe ( 400 );
});
test ( "GET /posts/:id returns 404 when missing" , async () => {
const res = await app. request ( "/posts/does-not-exist" );
expect (res.status). toBe ( 404 );
});
test ( "round-trip: POST then GET returns the same row" , async () => {
const created = await app. request ( "/posts" , {
method: "POST" ,
headers: { "content-type" : "application/json" },
body: JSON . stringify ({ title: "round" , body: "trip" }),
});
const { id } = await created. json ();
const fetched = await app. request ( `/posts/${ id }` );
expect (fetched.status). toBe ( 200 );
const post = await fetched. json ();
expect (post.title). toBe ( "round" );
expect (post.body). toBe ( "trip" );
});
}); import { Hono } from "hono" ;
import { HTTPException } from "hono/http-exception" ;
import { zValidator } from "@hono/zod-validator" ;
import { z } from "zod" ;
type Post = { id : string ; title : string ; body : string ; createdAt : number };
// デモ用のメモリストア。
// 本番で永続化するなら D1 / Postgres / R2 などに差し替える。
const store = new Map < string , Post >();
const postInput = z. object ({
title: z. string (). min ( 1 ). max ( 200 ),
body: z. string (). min ( 1 ). max ( 50_000 ),
});
const app = new Hono ();
app. get ( "/" , ( c ) => c. text ( "bun-hono-minimal-starter ok \n " ));
app. onError (( err , c ) => {
if (err instanceof HTTPException ) return err. getResponse ();
console. error (err);
return c. json ({ code: "internal" , message: "unexpected error" }, 500 );
});
app. post ( "/posts" , zValidator ( "json" , postInput), ( c ) => {
const input = c.req. valid ( "json" );
const id = crypto. randomUUID ();
const post : Post = { id, ... input, createdAt: Date. now () };
store. set (id, post);
return c. json (post, 201 );
});
app. get ( "/posts/:id" , ( c ) => {
const post = store. get (c.req. param ( "id" ));
if ( ! post) throw new HTTPException ( 404 , { message: "post not found" });
return c. json (post);
});
// `export default app` が Bun / Workers / Deno 共通のエントリ。
// Bun はここから `fetch` を拾って Bun.serve を自動で起動する。
export default app; {
"compilerOptions" : {
"target" : "ES2022" ,
"module" : "ESNext" ,
"moduleResolution" : "Bundler" ,
"lib" : [ "ES2022" , "DOM" ],
"types" : [ "bun" ],
"strict" : true ,
"noEmit" : true ,
"skipLibCheck" : true ,
"resolveJsonModule" : true ,
"isolatedModules" : true ,
"verbatimModuleSyntax" : true ,
"allowImportingTsExtensions" : true
},
"include" : [ "src/**/*" ]
} {
"$schema" : "node_modules/wrangler/config-schema.json" ,
"name" : "bun-hono-minimal-starter" ,
"main" : "src/app.ts" ,
"compatibility_date" : "2026-04-01" ,
// Hono アプリ本体は Workers でもそのまま動く。
// メモリ内 Map で持っている state は Worker の isolate ごとに揮発する点に注意
// (本番で永続化するなら D1 / KV / Durable Objects に差し替える)
"observability" : {
"enabled" : true
}
} slug bun-hono-minimal-starter│ files 8 click a file in the tree to switch