GitHubのトレンドを眺めていたら、usekaneo/kaneo が上のほうに来ていました。「All you need. Nothing you don’t. オープンソースのプロジェクト管理」。説明にはJira代替・Linear代替・kanban・self-hostedと並んでいて、要はチケット管理をセルフホストできるやつです。
この手の「自己ホスト型プロジェクト管理」は昔から何度も見てきました。動かすこと自体は難しくないだろうと踏んでいたんですが、実際にソースから立てようとすると、製品の中身とは別のところ——SPAとAPIをローカルで喋らせる部分——でしっかり詰まりました。今回はその試行錯誤の記録です。結論から言うと、製品は素直に良いです。つまずいたのはほとんど「自分の立て方」の問題でした。
試した環境
いつも通り、ホスト(Windows)を汚さないよう、使い捨ての node:22-bookworm コンテナの中だけで動かしています。キャプチャだけはホスト側のブラウザから撮っています。
Kaneoの正式な最短経路はDocker Compose(KaneoコンテナとPostgreSQLを一緒に立てる)なんですが、本シリーズはコンテナの中でさらにComposeを回さない方針なので、pnpm dev の開発起動を選び、PostgreSQLは同じ使い捨てコンテナの中に同居させました。READMEにも pnpm install → pnpm dev の開発手順があるので、そこに乗った形です。
- 対象:
usekaneo/kaneov2.12.1(commit3894504、TypeScript、MITライセンス、★5,399) - ランタイム: Node.js v22.23.1 / pnpm 10.32.1(
packageManagerでピン留め) - DB: PostgreSQL 15.18(コンテナ内に同居)
- 構成: API=Hono(
:1337)、Web=Vite + React(:5173)の別プロセス・別オリジン
pnpm install はmonorepo全体で22秒、drizzle-kit migrate は35本のマイグレーションが2秒で流れて32テーブルができました。ここまでは驚くほどスムーズです。問題はこの後でした。

つまずき1:公開ポートは「同じ番号」で出さないといけない
まず引っかかったのがポートです。使い捨てコンテナだと、ホスト側の公開ポートは衝突を避けて適当な番号(39173 とか)を割り当てるのが自分の癖なんですが、Kaneoではこれが通りませんでした。
Web側のSPAは、APIのURLが未設定だと既定で http://localhost:1337 を叩きに行きます。この 1337 はブラウザ(=ホスト)から見た番号なので、コンテナの 1337 をホストの別番号にマッピングすると、SPAのfetch先とズレて何も返ってきません。結局、素直にポート番号を揃えて公開しました。
docker run -d --name ght-trial-... \
--memory=4g --cpus=2 --pids-limit=512 \
-p 127.0.0.1:5173:5173 \
-p 127.0.0.1:1337:1337 \
node:22-bookworm sleep infinity
フロントとAPIが別オリジンで、しかもフロントがAPIのポートを既定値として持っている構成では、ポート番号は勝手に振り替えられない。当たり前と言えば当たり前なんですが、単一サーバのアプリを動かす感覚のままだと最初に踏みます。
つまずき2:monorepoの内部パッケージは先にビルドが要る
次に、APIを pnpm dev したら起動前に落ちました。
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/work/kaneo/apps/api/node_modules/@kaneo/email/dist/index.js'
imported from /work/kaneo/apps/api/src/auth.ts
@kaneo/email という社内(ワークスペース)パッケージの dist/ が無い、というエラーです。KaneoはpnpmのワークスペースでAPI・Web・複数のpackagesを束ねていて、packages/* は dist/index.js を参照する作りになっています。ところが turbo.json の dev タスクには ^build(依存パッケージを先にビルドする)指定が無いので、packages/* を一度もビルドしていない状態でAPIの開発起動だけ叩くと転びます。
改善後は、先にワークスペースのパッケージをビルドしてからAPIを起動しました。
pnpm --filter "./packages/*" build # email / mcp / permissions、3秒ほど
これで dist/ が生えて、APIは無事 ⚡ API is running at http://localhost:1337 まで到達しました。ちなみにRedisは未設定でしたが、ログには WebSockets Initialized using: "InMemoryBroadcastAdapter" と出ていて、ちゃんとインメモリにフォールバックしてくれます。単体で動かすぶんにはRedisを立てなくてよいのは助かりました。
つまずき3:localhost と 127.0.0.1 は同じではなかった
ここからが本番です。ブラウザで http://127.0.0.1:5173 を開くとKaneoは出るものの、「Failed to fetch」でダッシュボードが真っ赤になる。一方 http://localhost:5173 を開くと、なぜか別のアプリのログイン画面が出てきました。
原因を切り分けるために、ホストからそれぞれcurlで叩いてみたら、はっきり別物を返していました。
curl http://127.0.0.1:5173/ -> <title>Kaneo ...</title> # 自分のコンテナ
curl http://localhost:5173/ -> <title>別アプリ ...</title> # 別物
Windowsだと localhost はIPv6(::1)に解決されることがあって、たまたま手元で動いていた別の開発サーバが ::1 側の同じポートを握っていた、というオチでした。私のコンテナは 127.0.0.1(IPv4)にしかポートを公開していないので、localhost(IPv6)で開くと別アプリに当たる。ローカル開発では見落としがちですが、localhost と 127.0.0.1 は同じ宛先とは限らない、というのを久しぶりに踏みました。
方針を「全部 127.0.0.1 に固定する」に切り替えました。ところが、これが次の罠を連れてきます。
つまずき4:CORSと、環境変数の優先順位
APIは、KANEO_CLIENT_URL に指定したオリジンしかCORSで許可しません(実装も process.env.KANEO_CLIENT_URL を見にいくだけのシンプルなものでした)。オリジンを 127.0.0.1:5173 に寄せたので、KANEO_CLIENT_URL もそれに合わせる必要があります。
最初はシェルで export KANEO_CLIENT_URL=http://127.0.0.1:5173 してからAPIを起動したんですが、CORSは相変わらず localhost:5173 しか許可しない。おかしいと思ってOriginを付けてヘッダを覗いたら、127.0.0.1 側にはCORSヘッダが返っていませんでした。
# Origin: http://127.0.0.1:5173 -> access-control-allow-origin ヘッダなし(拒否)
# Origin: http://localhost:5173 -> access-control-allow-origin: http://localhost:5173
犯人は dotenv-mono でした。Kaneoはルートの.envを読み込むんですが、これがシェルでexportした値を上書きしていて、.env に残っていた KANEO_CLIENT_URL=http://localhost:5173 が勝っていたわけです。exportで渡したつもりが、ファイル側の古い値に負けていた。.env のほうを 127.0.0.1 に書き換えて解決しました。
(この最中に、APIの再起動を繰り返していたら tsx watch の孤児プロセスが溜まって listen EADDRINUSE :::1337 も出ました。:: バインドはIPv4も塞ぐので、古いプロセスを全部片付けてから起動し直しています。自分で散らかしただけなんですが、一応。)
つまずき5:Viteの .env 優先順位が本丸だった
CORSを直してもまだダメでした。サインイン自体はAPIを直接叩くと 200 で通り、get-session もセッションを返すのに、SPAのルートガードがログイン画面に弾き返してくる。
決め手はブラウザのネットワークタブでした。SPAは、こちらが 127.0.0.1 に寄せたつもりでも、ずっと http://localhost:1337/api/auth/get-session を叩いていたんです。つまりフロントのAPI向き先が localhost(=IPv6の別アプリ)のままで、別物にセッションを聞きに行っていた。
apps/web/.env に VITE_API_URL=http://127.0.0.1:1337 を書いたのに効かない。理由を追って、ようやく腑に落ちました。Viteは開発モードだと .env.development を .env より優先して読むためで、apps/web/.env.development に VITE_API_URL=http://localhost:1337 が先に居座っていたんです。
# apps/web/.env VITE_API_URL=http://127.0.0.1:1337 ← 書いたのはこっち
# apps/web/.env.development VITE_API_URL=http://localhost:1337 ← dev では こっちが勝つ
Viteのenvは .env.development > .env(devモード時)という優先順位なので、モード別ファイルに古い値があると、素の .env はいくら書いても上書きされます。.env.development のほうを 127.0.0.1 に直してViteを再起動した瞬間、ネットワークの宛先が 127.0.0.1:1337 に切り替わり、管理者作成 → ワークスペース → プロジェクト → kanbanまで、そこから先は一気に通りました。正直、ここに一番時間を取られました。
動いてからは素直だった
ここまで来ると、あとは製品の良さがそのまま出てきます。最初に作ったアカウントがインスタンス管理者になり、ワークスペースを作ると、v2.12.1 と表示される落ち着いたUIのダッシュボードに入れます。

プロジェクトを作るときにキー(チケットIDの接頭辞、例では TRI)を決めさせるあたりは、Jira/Linearを触ったことがあれば馴染みのある流れです。ボードは To Do / In Progress / In Review / Done の4カラムで、Board表示とList表示、それにBacklog・Tasks・Gantt・Filter・Sortが並びます。試しにタスクを2枚追加したら、ちゃんとカラムをまたいで並びました。

見た目だけでなく、裏でちゃんと永続化されているかも気になったので、同居させたPostgresを直接覗きました。UIで作った通りにレコードが入っています。
number | title | status
--------+------------------------------+-------------
1 | Docker上でkaneoを動かす | to-do
2 | CORSとVITE_API_URLを設定する | in-progress
user 1・workspace 1・project 1・task 2。ブラウザの操作がそのままDBの行になっている、当たり前ですが確認できると安心します。ここは「動いた」を鵜呑みにせず一段掘る癖のところで、SQLで見えると納得感が違いました。
試してみての所感
製品としてのKaneoは好印象です。キーレスで(外部APIキーなしで)ローカル完結で動き、Redisが無くてもインメモリに落ちてくれて、UIも過不足なくきれい。「All you need. Nothing you don’t.」の看板に偽りなし、という感触でした。
一方で、今回つまずいた5つは、よく見るとほとんどが**「フロント(SPA)とAPIを別オリジンでローカルに立てた」自分の都合から来ています。正式なDocker Composeで一発で立てれば、ポートもオリジンもCORSも最初から噛み合っているので、たぶん一つも踏みません。裏を返すと、自己ホストでリバースプロキシを挟まず別ポートのまま運用する**なら、KANEO_CLIENT_URL(CORS)と VITE_API_URL(フロントの向き先)を最初に一致させて設計しておくのが肝、ということです。ここを最初に決めておけば、今日の遠回りはまるごと省けます。
なぜ最短のComposeを選ばなかったのか、と言われると本シリーズの制約のためなんですが、おかげで「素の構成で何が噛み合っていないと動かないか」がひと通り見えたのは収穫でした。次に自己ホスト型のSPA+API構成を立てるときは、まずオリジンとポートの地図を1枚描いてから手を動かそうと思います。もっと良い立て方があれば教えてください。