Docker / VPSでセルフホストする
docker composeで1台のサーバーにTrayPageを立てる手順。ローカルで試す、本番VPS、外部のPostgres・S3、バックアップ、更新。
1台のサーバー(VPS、社内サーバー、手元のPC)で、docker compose up だけでTrayPageを動かします。既定ではすべてを1台に閉じ込めるので、外部サービスに依存せず、閉じたネットワークでも動きます。
ブラウザ / AIエージェント
│
▼
Caddy(TLS終端・2つのホスト名)
│ アプリ用 / 閲覧用(ページ本文だけを配信)
▼
app(ghcr.io/8d-inc/tray-page)
│
├── Postgres(同梱。外部のマネージドPostgresに置き換え可)
└── SeaweedFS(S3互換。外部のS3 / R2に置き換え可)| サービス | 役割 | データ(volume) |
|---|---|---|
caddy | 入口。https:// のoriginなら、Let's Encryptで証明書を自動取得・更新する | caddy-data(証明書)、caddy-config |
app | TrayPage本体(Web画面・REST API・MCP・共有ページ)。起動時にDBのマイグレーションを自動で適用する | なし |
postgres | DB(profile postgres) | postgres-data |
seaweedfs | ページ本文のS3互換ストレージ(profile s3)。起動時にbucketを作る | seaweedfs-data |
mailpit | ローカルで試すときのメールの受け口(profile mailpit) | なし |
どのサービスを起動するかは、.env の COMPOSE_PROFILES で決めます。設定はすべて .env に書きます。一式は、TrayPageのリポジトリの deploy/docker/(compose.yaml・Caddyfile・.env.example)にあります。
必要なもの
- Docker Engine 24 以降と Docker Compose v2.20 以降(
docker compose versionで確認) - 本番で使う場合: ホスト名(ドメイン)2つと、外から80番・443番に届くサーバー
ローカルで試す
TLSなしで動かします。
cd deploy/docker
cp .env.example .env
sed -i.bak "s/^BETTER_AUTH_SECRET=.*/BETTER_AUTH_SECRET=$(openssl rand -hex 32)/" .env && rm .env.bak
docker compose up -d-
http://localhost:3000 を開き、メールアドレスを入力してログインリンクを送ります。
-
MCPクライアントを接続します。MCPのURLは
http://localhost:3000/api/mcpです。claude mcp add --transport http --scope user traypage http://localhost:3000/api/mcp -
MCPでページを作成して公開し、共有URL(
http://localhost:3000/share/...)を開きます。ページ本文は閲覧用ホストhttp://view.localhost:3000から、隔離された状態で表示されます。
*.localhost は、ブラウザが自動で自分のPCに解決します。ポートを変えるときは、APP_ORIGIN・VIEWER_ORIGIN・HTTP_PORT の3つを同じ番号にそろえてください。
本番VPSで動かす
-
ホスト名を2つ決め、DNSのA / AAAAレコードをサーバーのIPに向けます(例:
traypage.example.comとview.traypage.example.com)。閲覧用ホストは、利用者やAIが書いた任意のHTMLを配信するので、アプリ用とは別のホスト名が必須です。可能なら別のドメイン(例:traypage-content.example.net)にすると、cookieの共有範囲からも切り離せます。 -
ファイアウォールで80番・443番(TCP)を開けます。80番は、証明書の取得とhttpsへのリダイレクトに使います。
-
.envを設定します。COMPOSE_PROFILES=postgres,s3 TRAYPAGE_IMAGE=ghcr.io/8d-inc/tray-page:v1.2.3 APP_ORIGIN=https://traypage.example.com VIEWER_ORIGIN=https://view.traypage.example.com HTTP_PORT=80 HTTPS_PORT=443 BETTER_AUTH_SECRET=<openssl rand -hex 32> POSTGRES_PASSWORD=<openssl rand -hex 24> S3_ACCESS_KEY_ID=traypage S3_SECRET_ACCESS_KEY=<openssl rand -hex 24> SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=... SMTP_PASSWORD=... EMAIL_FROM=TrayPage <noreply@example.com> -
docker compose up -dを実行します。初回は、Caddyが証明書を取得するまで数十秒かかります(docker compose logs -f caddyで確認)。
POSTGRES_PASSWORD と S3_* は、初回起動の前に決めてください。DBのvolumeを作った後に POSTGRES_PASSWORD を変えても、DB側のパスワードは変わりません。
現在のバージョンでは、ログインリンクを受け取れる人は誰でもアカウントを作れます。社内だけで使う場合は、ファイアウォールやVPNでアクセス元を絞ってください(セキュリティ上の注意)。
メール
ログインのリンクと招待にメールを使います。次の順で使われます。
| 設定 | 送り方 |
|---|---|
RESEND_API_KEY | ResendのAPI |
SMTP_HOST(SMTP_PORT・SMTP_SECURE・SMTP_USER・SMTP_PASSWORD) | SMTP。SMTP_SECURE=true で465番(最初からTLS)、それ以外はSTARTTLS |
送信元は EMAIL_FROM です。本番では、COMPOSE_PROFILES から mailpit を外してください。
外部のPostgresとS3を使う
PostgresとS3互換ストレージは、.env を変えるだけで外部のサービスに置き換えられます。該当するprofileを COMPOSE_PROFILES から外すと、同梱のコンテナは起動しません。
Postgres(RDS・Cloud SQL・Neon・PlanetScaleなど)
COMPOSE_PROFILES=s3
DATABASE_URL=postgres://traypage:password@db.example.com:5432/traypage?sslmode=require
DATABASE_POOL_MAX=10DB(空でよい)とユーザーを先に作っておきます。テーブルはアプリが起動時に作ります。複数台で同時に起動しても、1台だけが適用します。
S3互換ストレージ(AWS S3・Cloudflare R2など)
bucketを先に作っておきます。非公開のままでかまいません(アプリが署名付きのリクエストで読み書きします)。
# AWS S3
COMPOSE_PROFILES=postgres
S3_ENDPOINT=https://s3.ap-northeast-1.amazonaws.com
S3_REGION=ap-northeast-1
S3_BUCKET=traypage-pages
S3_ACCESS_KEY_ID=AKIA...
S3_SECRET_ACCESS_KEY=...# Cloudflare R2(S3 API)
COMPOSE_PROFILES=postgres
S3_ENDPOINT=https://<account_id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_BUCKET=traypage-pages
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...既定はpath-style(endpoint/bucket/key)です。virtual-hosted styleが必要なサービスでは S3_ADDRESSING_STYLE=virtual にします。両方を外部にする場合は COMPOSE_PROFILES=(空)にします。
更新
docker compose pull
docker compose up -dDBのマイグレーションは、新しいイメージの起動時に自動で適用されます。更新の前にバックアップを取ってください。
イメージのタグ
| タグ | 中身 |
|---|---|
vX.Y.Z | リリース。本番ではこれで固定する |
vX.Y | そのマイナーバージョンの最新のリリース |
latest | 最新のリリース(プレリリースを除く) |
edge | develop ブランチの最新。動作確認用 |
sha-<commit> | 特定のコミット |
linux/amd64 と linux/arm64 に対応しています。
バックアップと復元
バックアップするものは3つです。
| 対象 | 方法 |
|---|---|
| DB(Postgres) | pg_dump |
ページ本文(seaweedfs-data volume、または外部のS3 / R2) | volumeをtarで固める。外部のS3 / R2は、そのサービスのバージョニングやレプリケーションを使う |
.env | 安全な場所にコピーする。特に BETTER_AUTH_SECRET を失うと、ログイン中のセッションがすべて無効になる |
DB、ページ本文の順に取ってください。ページの版は一度書いたら変わらず、本文を書いてからDBに行を作るので、この順なら「DBにあるのに本文がない」状態になりません。
以下は deploy/docker で実行します。volumeの名前は <project名>_<volume名>(既定のproject名は traypage-selfhost)です。
# 1. DB(アプリを止めなくてよい)
docker compose exec -T postgres pg_dump -U traypage -d traypage -Fc > traypage-db-$(date +%F).dump
# 2. ページ本文(書き込みを止めてからvolumeを固める)
docker compose stop app seaweedfs
docker run --rm -v traypage-selfhost_seaweedfs-data:/data:ro -v "$PWD":/backup alpine \
tar czf /backup/traypage-pages-$(date +%F).tar.gz -C /data .
docker compose start seaweedfs appcaddy-data(証明書)はバックアップしなくても、起動時に取り直せます(Let's Encryptの発行回数の上限には注意してください)。
復元
docker compose up -d postgres seaweedfs
docker compose stop seaweedfs
# ページ本文
docker run --rm -v traypage-selfhost_seaweedfs-data:/data alpine find /data -mindepth 1 -delete
docker run --rm -v traypage-selfhost_seaweedfs-data:/data -v "$PWD":/backup:ro alpine \
tar xzf /backup/traypage-pages-YYYY-MM-DD.tar.gz -C /data
# DB(既存のテーブルを消してから入れ直す)
docker compose exec -T postgres pg_restore -U traypage -d traypage --clean --if-exists --no-owner \
< traypage-db-YYYY-MM-DD.dump
docker compose up -d片付け
docker compose down # コンテナを止める(データは残る)
docker compose down -v # volume(DB・ページ本文・証明書)も消す。元に戻せないうまく動かないとき
docker compose psで全サービスがrunning(healthy)か確認します。docker compose logs appでアプリのログを見ます。- ログインリンクが届かない:
SMTP_*を確認します(docker compose logs appに送信の失敗が出ます)。ローカルでは http://localhost:8025 を見ます。 - 共有ページの本文が表示されない:
VIEWER_ORIGINがAPP_ORIGINと別のホスト名で、ブラウザから開けるかを確認します。 - 証明書が取れない: DNSがこのサーバーを向いているか、80番・443番に外から届くかを確認します(
docker compose logs caddy)。
すべての設定項目は 設定項目のリファレンス にあります。