オープンソースCRM「Twenty」をRailwayでセルフホストする
オープンソースCRM「Twenty」について、これまでローカル開発環境の構築、アーキテクチャのコードリーディング、Salesforceとの比較を書いてきました。今回はチームで実際に触るために、クラウドにセルフホストします。
ホスティング先はPaaSのRailwayを選びました。結論を先に書くと、月25〜40ドル見込みでチーム利用できる環境が半日で動きました。ただしすんなりではなく、「初回デプロイが中断すると再起動では絶対に直らない状態に陥る」というTwenty側の仕様を踏んだので、そこを厚めに書きます。
ホスティング先の選定
Twentyの公式セルフホスト手段は、現在Docker Compose一本です。以前あったRenderワンクリックやKubernetesのガイドは公式ドキュメントから消えていて、self-hostセクションにはDocker Compose・Setup・Upgrade・Troubleshootingの4ページしかありません。つまり選ぶのは「composeで動く4コンテナ(server / worker / PostgreSQL / Redis)をどこで動かすか」だけです。

当初はGCP(GCE + Cloud SQL)を考えていましたが、PoC段階ではRailwayにしました。Railwayはコンテナ・DB・ドメインをまとめて面倒を見てくれるPaaSで、決め手は2つあります。
1つ目はDBの保護がPoCに十分だったことです。以前のRailwayのDBは「ただのコンテナ+ボリューム」でしたが、現在はスケジュールバックアップに加えてPostgresのPITR(任意時点復元)まで提供されています。マイグレーション事故からの復旧手段があるなら、Cloud SQLに求めていたものの大部分は満たせます。
2つ目は料金です。RailwayはHobby($5)とPro($20/シート)のどちらも基本料と同額の利用クレジットが含まれる構造なので、Twenty一式の利用量(月$25前後)だと総額はほぼ同じになります。東京から使うのに日本に一番近いSingaporeリージョンはPro限定ですが、実質タダでリージョンを選べる計算になるため、迷わずProにしました。

なおRailwayにはTwentyのコミュニティテンプレートもありますが、作られたばかりで実績が少なかったため、公式イメージtwentycrm/twentyでサービスを自分で組みました。構成を理解する意味でも、この選択は正解だったと思います。
構成はrailway.tsに全部書ける
RailwayはCLIからrailway configでIaC(Infrastructure as Code)が使えます。TypeScriptで書いた定義をrailway config planで差分確認し、railway config applyで適用する、Terraformライクな体験です。
今回の構成は最終的にこうなりました。
import { defineRailway, project, service, postgres, redis, volume, image } from "railway/iac";
export default defineRailway(() => {
const appSecret = process.env.TWENTY_APP_SECRET; // シークレットはコミットしない
const SG = "asia-southeast1"; // Singapore(Pro限定)
const TAG = "v2.20.0"; // アップグレードはタグを上げてapply
const db = postgres("postgres", { region: SG });
const cache = redis("redis", { region: SG });
// BullMQの要件でnoevictionが必須。テンプレ既定のコマンドに追記
cache.deploy = {
...cache.deploy,
startCommand:
'/bin/sh -c "... redis-server --requirepass $REDIS_PASSWORD ... --maxmemory-policy noeviction"',
};
const storage = volume("twenty-storage", { region: "asia-southeast1-eqsg3a", sizeMB: 50000 });
const commonEnv = {
PG_DATABASE_URL: db.env.DATABASE_URL,
REDIS_URL: cache.env.REDIS_URL,
APP_SECRET: appSecret,
ENCRYPTION_KEY: appSecret,
STORAGE_TYPE: "local",
SERVER_URL: process.env.TWENTY_SERVER_URL ?? "http://localhost:3000",
};
const server = service("twenty-server", {
source: image(`twentycrm/twenty:${TAG}`),
replicas: { [SG]: 1 },
env: { ...commonEnv, NODE_PORT: "3000", RAILWAY_RUN_UID: "0" },
volumeMounts: { "/app/packages/twenty-server/.local-storage": storage },
});
const worker = service("twenty-worker", {
source: image(`twentycrm/twenty:${TAG}`),
start: "yarn worker:prod",
replicas: { [SG]: 1 },
env: { ...commonEnv, DISABLE_DB_MIGRATIONS: "true", DISABLE_CRON_JOBS_REGISTRATION: "true" },
});
return project("twenty-poc", { resources: [db, cache, storage, server, worker] });
});
公式docker-compose.ymlの構成(workerの起動コマンド、DISABLE_DB_MIGRATIONS、Redisのnoeviction)をそのまま移植した形です。postgres()やredis()のオプション、DBサービスの起動コマンド上書きの方法はドキュメントに見当たらなかったので、SDK(npmのrailwayパッケージ)の型定義を直接読んで確かめました。
適用後はrailway domainで公開ドメインを発行し、SERVER_URLに設定すれば完成です。ここまでは素直でした。問題はここからです。
ハマりどころ1: DBサービスはリージョン指定を忘れるとus-west2に落ちる
最初のapplyで、replicas: { [SG]: 1 }を指定したserver/workerはSingaporeに乗ったのに、リージョン未指定だったpostgres()とredis()はデフォルトのus-west2(米国西海岸)にデプロイされました。アプリとDBが太平洋を挟んで会話する構成です。
{ region: SG }を足して適用し直せば「Move database to asia-southeast1」という差分が出て移動できます。ただしこの移動を初回マイグレーションの実行中にやってしまったのが、次のハマりの引き金になりました。
ハマりどころ2: 初回デプロイの中断は、再起動では絶対に直らない
今回一番の学びです。Twentyのserverは初回起動時にDBのフル初期化(マイグレーション182本)を実行しますが、これが完走する前にコンテナが殺されると、以後何度再起動しても直らない片肺状態になります。
原因はイメージのentrypointにあります。
has_schema=$(psql -tAc "SELECT EXISTS (... schema_name = 'core')" ${PG_DATABASE_URL})
if [ "$has_schema" = "f" ]; then
echo "Database appears to be empty, running migrations."
yarn database:init:prod
fi
if ! yarn command:prod upgrade; then
echo "Warning: Upgrade completed with errors. ... Check logs for details."
fi
フル初期化(database:init:prod)が走るのは「coreスキーマが存在しないとき」だけです。初期化が途中で殺されてスキーマが中途半端に残ると、次の起動では「スキーマあり」と判定されて初期化がスキップされます。後続のupgradeコマンドはエラーを握りつぶして起動を続けるので、アプリは一見正常に立ち上がり、ワークスペース作成などの操作時に「カラムが存在しない」系のエラーで初めて破綻が見えます。
私の場合、マイグレーション実行中にDBのリージョン移動を適用して接続を切断したのが最初の引き金で、さらにRailwayのhealthcheck(猶予5分)がマイグレーション完走前のコンテナをkillし続けたことで片肺状態が固定されました。マイグレーションの記録テーブルcore._typeorm_migrationsを直接見ると、182本中90本で止まったままでした。
復旧はDROP SCHEMA core CASCADEでスキーマを消し、entrypointに「空のDB」と認識させてフル初期化をやり直す一択です。クリーンな状態からの初期化は1分足らずで完走しました。
教訓はシンプルで、初回デプロイが完了するまで、環境変数の変更・リージョン移動・再デプロイを一切投げないことです。合わせて、healthcheckの猶予も初回は長めに取る必要があります。
ハマりどころ3: Railway内蔵のhealthcheckが通らない
healthcheckPath: "/healthz"を設定すると、猶予を5分から15分に伸ばしてもhealthcheckが一度も通らず、デプロイがFAILED判定になり続けました。アプリ自体は健全で、healthcheckを外すと公開ドメイン経由の/healthzは起動90秒後に200を返します。
TwentyはNODE_PORT=3000でlistenしますが、Railwayの内部プローブがこのポートをうまく解決できていないようでした。PoCでは内蔵healthcheckを無効化し(クラッシュ時の再起動ポリシーは別に効いています)、外形監視で代替する判断にしました。
調査で困ったのは、Twentyが起動時にRailwayのログ制限(500 logs/sec)を超えるログを吐き、肝心のエラー行がドロップされることです。「ログを読む」より「psqlでDBの記録を直接見る」「healthcheckを外してデプロイを生かし、外から叩く」ほうが早く原因に辿り着けました。
ハマりどころ4: ボリュームに書けずワークスペース作成が失敗する
デプロイが安定して初回サインアップまで進んだあと、ワークスペース作成で「エラーが発生しました」とだけ出て失敗しました。サーバーログを見ると原因は明快でした。
Error: EACCES: permission denied, mkdir '/app/packages/twenty-server/.local-storage/<workspace-id>'
Railwayのボリュームはroot所有でマウントされる一方、Twentyのコンテナは非rootユーザーで動くため、添付ファイル置き場に書き込めません。docker composeの名前付きボリュームはイメージ側の所有権を引き継ぐので、ローカルでは顕在化しないRailway特有の罠です。
対処はRailway公式が案内しているとおり、環境変数RAILWAY_RUN_UID=0を設定してコンテナをrootで実行することです。適用後はワークスペース作成が通りました。
ちなみに招待メールはEMAIL_DRIVER未設定だと実送信されず、ログに本文が書かれるだけです。PoCの間は設定画面の招待リンクをコピーして共有すれば足ります。
所感
ハマりどころ込みで、構築にかかったのは3〜4時間でした。踏んだ罠はどれも「Twentyのentrypoint仕様」と「Railwayのプラットフォーム特性」の掛け合わせで起きるもので、この組み合わせの前例情報はほぼ見つからなかったので、この記事がその穴を埋められればと思います。
RailwayでのTwenty運用は、PoC〜小規模チーム用途なら十分実用的だと感じます。月額はPro基本料$20($20クレジット込み)+超過分で$25〜40の見込み、PostgresはバックアップとPITRで保護でき、アップグレードもIaCのタグを上げてapplyするだけです。
本採用になった場合の移行も心配していません。Twentyは素のPostgreSQLで動くので、pg_dumpで吸い出してGCP等に載せ替えられます。撤退や移行の出口が軽いのは、SaaSにはないセルフホストOSSの良さだと改めて感じました。
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。