さくらのVPS 1台で Bluesky PDS + メールサーバー + 招待システムを全部自前構築した記録
「BlueskyのPDSを建てて、メールも無制限に使いたい。ついでにPythonのWebも動かしたい」
——そんな思いつきから始まった構築が、完成するまでの記録です。
完成した構成
さくらのVPS 1台(Ubuntu 24.04)+ さくらのクラウドDNS(月額44円)の上に、以下がすべて同居しています。
| サービス | ホスト名 | 役割 |
|---|---|---|
| Bluesky PDS |
example.jp + *.example.jp
|
自分専用の分散SNSサーバー |
| Mailcow | mail.example.jp |
メールサーバー(SMTP/IMAP/Webmail) |
| 招待申請システム(自作) | www.example.jp |
Web申請→承認→招待コード自動メール |
| Caddy | 80/443を一元管理 | リバースプロキシ+TLS終端 |
※ 本記事ではドメインを example.jp、VPSのIPを 192.0.2.10 に置き換えて記載します。
アーキテクチャ
インターネット
│ 80/443
▼
┌────────────────────┐
│ Caddy (TLS終端) │
└────────────────────┘
example.jp/*.example.jp mail.… www.…
│ │ │
PDS:3000 Mailcow nginx FastAPI:8000
│
25/465/587/993 等は Mailcow が直接受ける
ポイントは「外向き80/443はCaddyだけが持ち、各サービスはDockerの共有ネットワーク(web)内部で待つ」という構成。これでポートの取り合いが起きません。
第1章: サーバー選定 — 「レンタルサーバー」ではダメだった
最初は共用レンタルサーバーを想定していましたが、Bluesky PDSは:
- root権限でのSSHアクセスが前提
- Dockerで動作
- 80/443ポートの開放が必要
という要件のため、共用レンタルサーバーでは構築不可。VPS一択です。
国内VPSを比較し、安定性の評判でさくらのVPSを選択。PDS(最低2GB推奨)+Mailcow(2〜4GB)+アプリの同居を考え、4GBプラン以上が現実的なラインです。
重要: さくらのVPSは契約後約72時間、外向き25番ポートが閉じられます(OP25B)。この間はメールの外部送信テストができません。
第2章: メールサーバー — Postfix手組みは避けてMailcowへ
Postfix/Dovecotの手動構築は SPF/DKIM/DMARC まで含めると重労働なので、Docker一発構築のMailcowを採用しました。
git clone https://github.com/mailcow/mailcow-dockerized /opt/mailcow
cd /opt/mailcow
./generate_config.sh # MAILCOW_HOSTNAME に mail.example.jp
リバースプロキシ共存のためのポイント
Mailcowも443を使いたがるので、mailcow.confで内部ポートに逃がします:
HTTP_PORT=8080
HTTP_BIND=
HTTPS_PORT=8443
HTTPS_BIND=
ここでハマったのが
HTTP_BIND=127.0.0.1にしていたこと。別コンテナのCaddyからのアクセスが拒否されます。リバースプロキシを別コンテナで前段に置く場合、BINDは空(全インターフェース)にします。8080/8443はホストへ公開しないので外部からは見えません。
さらに、MailcowのnginxコンテナをCaddyと同じwebネットワークに追加参加させます。Mailcowのdocker-compose.ymlは直接編集せず、docker-compose.override.ymlで:
services:
nginx-mailcow:
networks:
- mailcow-network
- web
networks:
mailcow-network:
external: true
name: mailcowdockerized_mailcow-network # 実際の名前はdocker inspectで確認
web:
external: true
Caddyfileでの振り分け
MailcowのnginxはHTTPアクセスに301(HTTPSへ)を返すため、CaddyからはMailcowのHTTPSポート(8443)へHTTPSで流すのが正解でした:
mail.example.jp {
reverse_proxy https://mailcowdockerized-nginx-mailcow-1:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
(内部区間の自己署名証明書は検証スキップ。外向きの正規証明書はCaddyが持つので問題なし)
ここまでのトラブル集
実際に踏んだ罠を列挙します。同じ構成を作る人は参考にしてください。
-
Caddyを
network_mode: hostで起動していた → コンテナ名での名前解決ができず、他コンテナに繋げない。ports+networks方式に変更 -
Caddyfileのパスにディレクトリができていた → compose起動時にファイルが無いと、Dockerが同名の空ディレクトリを勝手に作る。
not a directoryエラーの正体 -
Caddyfileが空でEOFエラー →
Error: adapting config using caddyfile: EOFは中身が空のときの典型 -
手順書からのコピペでMarkdownリンク記法が混入 →
[www.example.jp](https://…)がそのままCaddyfileに入り不正なホスト名に -
ACMEのメールアドレスが
you@example.comのまま → Let's Encryptに「forbidden domain」と拒否される。実在アドレスに変更 -
「Connection refused」の正体がリダイレクト追跡だった → wgetが301を追った先(443)で拒否されていただけで、8080への接続自体はずっと成功していた。
--spiderでヘッダだけ見れば一目瞭然
第3章: DNS — お名前.comからさくらのクラウドDNSへ
PDSはユーザーごとに alice.example.jp のようなサブドメインハンドルを発行するため、ワイルドカード証明書(*.example.jp)が必須。ワイルドカードはDNS-01チャレンジでしか取得できず、DNS事業者のAPIが必要です。
お名前.comのDNSには自動化に使えるAPIがないため、ネームサーバーだけをさくらのクラウドDNSに変更しました(ドメインの所有・更新はお名前.comのまま)。
- 料金: 1ゾーン月額44円
- certbot用プラグイン(
certbot-dns-sakuracloud)が存在し、日本語情報も豊富
移行手順の要点
- さくらのクラウドでDNSゾーン作成
- 全レコード(A、ワイルドカードA、MX、SPF/DKIM/DMARC)を書き写す
- 切替前に、さくらのDNSサーバーへ直接digして検証(ここ重要)
- お名前.comでネームサーバーを変更
-
dig +short NS example.jp @8.8.8.8で切替確認
ハマりポイント: さくらのクラウドDNSは、レコードを登録しただけでは配信されません。画面右上の「反映」ボタンを押す必要があります。押し忘れると、権威サーバーに直接digしても
ANSWER: 0(SOAシリアルが1のまま)になります。
副産物: DKIM問題が解決
移行前、mail-testerのスコアが5.8/10で、原因はDKIM署名の検証失敗(-4点)でした。お名前.com側で配信されていたDKIM公開鍵に問題があったようで、さくらのクラウドDNSに正しい全文を登録し直して切り替えたら、スコアは10/10(満点)に。
第4章: ワイルドカード証明書 — Caddyプラグインで挫折、certbotで解決
当初はCaddyにさくらのクラウドDNS用プラグインを組み込む構成(xcaddyビルド)を試みましたが、指定したプラグインのリポジトリが存在せず(could not read Username for 'https://github.com' = 実質404)、古い個人実装しか見つからなかったため方針転換。
certbot + certbot-dns-sakuracloudの王道ルートに切り替えました:
sudo apt install -y certbot python3-certbot-dns-sakuracloud
# APIキー(さくらのクラウドで発行: リソース操作APIキー/アクセスレベル:設定編集)
sudo tee /etc/letsencrypt/.secrets/sakura.ini << 'EOF'
dns_sakuracloud_api_token = ********
dns_sakuracloud_api_secret = ********
EOF
sudo chmod 600 /etc/letsencrypt/.secrets/sakura.ini
sudo certbot certonly --dns-sakuracloud \
--dns-sakuracloud-credentials /etc/letsencrypt/.secrets/sakura.ini \
-d 'example.jp' -d '*.example.jp' \
-m admin@example.jp --agree-tos --non-interactive
一発で Successfully received certificate。Caddyは標準イメージのまま、証明書をファイル指定で読ませます:
*.example.jp {
tls /etc/letsencrypt/live/example.jp/fullchain.pem /etc/letsencrypt/live/example.jp/privkey.pem
reverse_proxy pds:3000
}
(composeで /etc/letsencrypt を読み取り専用マウント)
第5章: Bluesky PDS本体
公式インストーラーで設定一式(/pds/pds.env、秘密鍵)を生成し、PDS付属のCaddyは使わず、PDSコンテナだけをwebネットワークに参加させてfront Caddyにぶら下げます。
# /pds/compose.yaml (要点)
services:
pds:
image: ghcr.io/bluesky-social/pds:latest
env_file: [/pds/pds.env]
volumes: [/pds:/pds]
networks: [web]
networks:
web:
external: true
curl https://example.jp/xrpc/_health
# {"version":"0.4.xxxx"} が返れば稼働
メール送信設定(pds.env)
アカウント確認・パスワードリセットのメールを自前Mailcowから送ります:
PDS_EMAIL_SMTP_URL=smtps://user%40example.jp:********@mail.example.jp:465/
PDS_EMAIL_FROM_ADDRESS=user@example.jp
ハマりポイント: SMTP URLのユーザー名の
@は%40にURLエンコード必須。パスワードに@等が含まれる場合も同様です。あと、pds.envの変更はdocker compose restartではなく**down→up**で反映を。
日本で運用する場合の設定ポイント
-
PDS_INVITE_REQUIRED=true(招待制)を維持。第三者にアカウントを提供すると、法的には特定電気通信役務提供者に該当し得るため、誰を入れるかを管理者が握るのが最良のリスク管理 -
PDS_ADMIN_EMAILで連絡窓口を明示 - 招待コードを誰に渡したかは記録しておく
- なお、PDSにR18等のコンテンツフィルタ設定は存在しません(ラベリングはAppView/ラベラー層の仕事)。管理者の実効手段は「招待制」「問題時の
pdsadmin account takedown」です
メールポートの証明書問題
メールソフト(特に新しいOutlook)での接続が「問題が発生しました」で失敗。原因は、Mailcowのメールポート(993/587)が自己署名証明書のままだったこと。Caddyが取得済みの正規証明書をMailcowへコピーして解決:
docker cp caddy:/data/caddy/certificates/acme-v02.../mail.example.jp.crt /tmp/mail.crt
docker cp caddy:/data/caddy/certificates/acme-v02.../mail.example.jp.key /tmp/mail.key
cp /tmp/mail.crt /opt/mailcow/data/assets/ssl/cert.pem
cp /tmp/mail.key /opt/mailcow/data/assets/ssl/key.pem
cd /opt/mailcow && docker compose restart postfix-mailcow dovecot-mailcow nginx-mailcow
このコピーは自動化しました(後述)。
第6章: 証明書の完全自動化
証明書は2系統あるので、それぞれ更新連携を仕込みます。
① certbot更新 → Caddy reload(deploy hook):
# /etc/letsencrypt/renewal-hooks/deploy/reload-caddy.sh
#!/bin/bash
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
② Caddyのmail証明書 → Mailcowへ日次同期(変化検知付きcron):
# /opt/mailcow/copy-caddy-cert.sh (毎朝5時にcron実行)
# caddyからcrt/keyを取り出し、cmpで差分があったときだけ
# Mailcowへコピー&postfix/dovecot/nginxを再起動
sudo certbot renew --dry-run で更新フロー全体を検証し、all simulated renewals succeeded を確認。以後、証明書に人手は不要です。
第7章: 招待申請システムを自作(Python/FastAPI)
「招待のたびにSSHしてコマンドを打つのが面倒」を解決するため、Webアプリを自作しました。これが当初構想の「Pythonも動かしたい」の回収になりました。
申請者 → https://www.example.jp/ (申請フォーム)
↓ SQLiteに保存 + 管理者へ通知メール
管理者 → /admin (Basic認証) で [承認] ボタン
↓ PDS admin API (com.atproto.server.createInviteCode) でコード発行
↓ Mailcow SMTP経由で申請者へ招待メール自動送信
- FastAPI + SQLite + Jinja2、Dockerコンテナで
webネットワークに参加 - PDS admin APIは内部ネットワーク(
http://pds:3000)経由なので外部露出なし - スパム対策: ハニーポット(不可視フィールド)+同一IP 60秒レート制限
-
www.example.jpはワイルドカード証明書の範囲内なので、追加のDNS・証明書作業ゼロ
第8章: 最後の試練 — メールが Microsoft に届かない
招待メールのテストで、Outlook系ドメイン宛てだけが届かない問題が発生。ログを追うと、3つの独立した問題が連鎖していました。
問題1: IPv6で配送しようとして失敗
connect to ...outlook.com[2a01:...]:25: Network is unreachable
PostfixがIPv6優先で配送を試みるが、VPSに外向きIPv6経路がない。対処は、Mailcow公式のカスタム設定ファイルでIPv4に固定:
echo 'inet_protocols = ipv4' >> /opt/mailcow/data/conf/postfix/extra.cf
docker compose restart postfix-mailcow
問題2: 内蔵DNSリゾルバ(unbound)の解決が不安定
Host or domain name not found. ... type=A: Host not found, try again
Mailcow内蔵のunboundはルートDNSから自力解決するフルリゾルバで、VPS環境では不安定になることがある。公共DNSへのフォワード構成に変更して解決:
# /opt/mailcow/data/conf/unbound/unbound.conf に追記
forward-zone:
name: "."
forward-addr: 8.8.8.8
forward-addr: 8.8.4.4
forward-addr: 1.1.1.1
問題3: MicrosoftによるIPブロック(ATTR37)
450 4.7.1 Unable to Relay. ATTR37
SPF/DKIM/DMARC満点・逆引き一致・一般ブラックリスト非掲載でも、Microsoftは独自のIPブロックリストを持っています。VPSのIP帯は過去の利用者の影響で予防的にブロックされていることがあり、まさにそれでした。
対処は https://sender.office.com/ (Office 365 Anti-Spam IP Delist Portal) で解除申請。3ステップの簡単な手続きで、30分程度で反映されます。
3つすべてを潰した結果:
status=sent (250 2.6.0 ... Queued mail for delivery)
Microsoft系への配送も開通しました。
完成 — 最終チェックリスト
| 項目 | 状態 |
|---|---|
PDS稼働(/xrpc/_health応答) |
✅ |
| ワイルドカードTLS(未登録サブドメインでもHTTPS応答) | ✅ |
| mail-tester 10/10 | ✅ |
| Outlook/IMAP/SMTP接続(正規証明書) | ✅ |
| Microsoft系ドメインへの到達 | ✅ |
| PDS→Mailcowのメール連携(確認・リセットメール) | ✅ |
| Web招待申請→承認→自動メール | ✅ |
| 証明書の全自動更新連携 | ✅ |
| 日次バックアップ(PDS/Mailcow/設定) | ✅ |
費用まとめ
| 項目 | 費用 |
|---|---|
| さくらのVPS(4GB以上推奨) | 月3,000円台〜 |
| さくらのクラウドDNS | 月44円/ゾーン |
| ドメイン | 年間の更新費のみ |
| 証明書 | 無料(Let's Encrypt) |
この構成を作る人へのアドバイス
- 80/443の設計を最初に決める。「front Caddy 1つ+各サービスは内部ポート」を最初から徹底すれば、途中のポート衝突トラブルの大半は起きない
- DNS-01の基盤(APIが使えるDNS)を早めに用意する。ワイルドカードが必要と分かった時点で移行を済ませると後が楽
-
エラーメッセージは省略せずに読む。
+shortやtailで切り詰めると原因が消える。--progress=plain、digのstatus:行、docker logsのlevel:50が友達 - 切り分けは「1コマンドずつ、結果を見てから次」。まとめて実行すると、どこで失敗したか分からなくなる
- メールの到達性は「送れた」で終わらない。IPv6経路、DNSリゾルバ、Microsoftの独自ブロックまで見て、初めて「届く」
この記事は、実際の構築ログを基に、機密情報を伏字化・ドメインを例示用に置換して再構成したものです。