やってみた 2026年8月1日

自己ホスト型プロジェクト管理「Kaneo」を使い捨てコンテナで動かしてみたら、SPAとAPIの間で何度もつまずいた話

XECIN TypeScriptセルフホストCORSDocker

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 installpnpm dev の開発手順があるので、そこに乗った形です。

  • 対象: usekaneo/kaneo v2.12.1(commit 3894504、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テーブルができました。ここまでは驚くほどスムーズです。問題はこの後でした。

Kaneoの初回セットアップ画面。ダークテーマで、中央に「Set up your Kaneo instance」の見出しと「This account becomes the instance administrator with full access.」の説明、Full Name / Email / Password の入力欄と「Create Account」ボタンが並ぶ。最初に立てた人がインスタンス管理者になる方式

つまずき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.jsondev タスクには ^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:localhost127.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)で開くと別アプリに当たる。ローカル開発では見落としがちですが、localhost127.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/.envVITE_API_URL=http://127.0.0.1:1337 を書いたのに効かない。理由を追って、ようやく腑に落ちました。Viteは開発モードだと .env.development.env より優先して読むためで、apps/web/.env.developmentVITE_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のダッシュボードに入れます。

Kaneoのワークスペース画面。左サイドバーに Overview(Projects / Members / Invitations)と Projects のセクション、左下にバージョン v2.12.1。中央は「No projects yet」「Get started by creating your first project.」の空状態と「Create project」ボタン

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

KaneoのkanbanボードTrial Board。To Do(1) / In Progress(1) / In Review(0) の3カラムが見え、To Doに「TRI-1 Docker上でkaneoを動かす」、In Progressに「TRI-2 CORSとVITE_API_URLを設定する」のカードが載っている。右上にBoard/List切替、上部にBacklog/Tasks/Gantt

見た目だけでなく、裏でちゃんと永続化されているかも気になったので、同居させた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枚描いてから手を動かそうと思います。もっと良い立て方があれば教えてください。