CaddyとVaultwardenでパスワード管理

セルフホストで管理すると安心なものの一つにパスワード管理がありますね。外部サービスを利用せず、それでいてどこからでも利用出来るのは重宝します。
使いやすいパスワード管理ソフトとしては「Vaultwarden」があります。
利用するには、HTTPSアクセスが必須となります。

NginxとCaddyの比較


これには、以前も軽く紹介したNginxを利用する方法もありますが、個人用途で、特にTailscale内で利用するならCaddyが適していますね。

Caddyの動作イメージ

Caddyの動作イメージは次のようになります。

Caddyを導入しワイルドカード証明書を使うようにすれば、Caddyだけが外部(Tailscale)に面して、各サービスはホストのポートを公開しないこともでき、Dockerのコンテナ名がそのままホスト名になるため、IPアドレス管理が不要になる点もメリットですね(ワイルドカード証明書を使うにはDNS-01チャレンジが必要で、そのためにはドメインのDNSを外部プロバイダ(Cloudflare、Route53など)で管理している必要があります。Tailscaleのプライベートドメイン(.ts.net)はTailscale社が管理しているため、外部からDNS-01チャレンジができません)

実際の導入方法は後述するので、まずは構成だけ。

ディレクトリ構成

/opt/
├── caddy/
│   ├── docker-compose.yml
│   └── Caddyfile
└── vaultwarden/
    ├── docker-compose.yml
    └── data/

Caddy側 docker-compose.yml

services:
  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config

volumes:
  caddy_data:
  caddy_config:

networks:
  caddy_net:
    name: caddy_net        # 他のcomposeから参照するための固定名
    driver: bridge

# Caddyコンテナをそのネットワークにアタッチ
# (上のservices.caddyにも追記)

services.caddy に追加:

    networks:
      - caddy_net

Vaultwarden側 docker-compose.yml

services:
  vaultwarden:
    image: vaultwarden/server:latest
    restart: unless-stopped
    volumes:
      - ./data:/data
    environment:
      - WEBSOCKET_ENABLED=true
      - SIGNUPS_ALLOWED=false
    # portsは書かない → ホストには公開しない
    networks:
      - caddy_net

networks:
  caddy_net:
    external: true          # Caddyが作ったネットワークを借りる
```

---

## `Caddyfile`
```
vaultwarden.your-machine.tail????.ts.net {
    reverse_proxy vaultwarden:80
}

コンテナ名 vaultwarden がそのままDNS名として使えます。

新サービスを追加するときのルール

サービスのcomposeに以下を追加するだけです:

networks:
  caddy_net:
    external: true

そしてCaddyfileに1ブロック追記。ネットワーク設定の変更は不要で、スケールアウトが非常に楽な構成です。

Caddyを導入する

まずはTailscaleのサイトでHTTPSを有効化します。Tailscale管理コンソール → DNSEnable HTTPS をオン。

Tailscaleで HTTPS Certificatesを有効にすると、*.tail1234.ts.net のワイルドカード証明書がTailscaleの CAから自動発行されますが、CaddyがLet’s Encryptと同じ仕組み (ACME) で自動取得・自動更新してくれます。
Caddyの導入も下記を貼り付けるだけでOKです。

#!/bin/bash
# ============================================================
# Caddy セットアップスクリプト
# インストール先: /opt/docker/caddy
# 対応OS: Ubuntu 24.x / 25.x
# ============================================================
set -euo pipefail

CADDY_DIR="/opt/docker/caddy"

echo "======================================"
echo " Caddy セットアップ"
echo "======================================"

# ── 前提確認 ────────────────────────────────────────
if ! command -v docker &>/dev/null; then
    echo "[ERROR] Docker が見つかりません。先に Docker をインストールしてください。"
    exit 1
fi

if ! docker compose version &>/dev/null; then
    echo "[ERROR] Docker Compose が見つかりません。"
    exit 1
fi

# ── Tailscale FQDN の取得 ────────────────────────────
echo ""
echo "[1/4] Tailscale FQDN を取得中..."

TS_FQDN=""

if command -v tailscale &>/dev/null; then
    TS_FQDN=$(tailscale status --json 2>/dev/null \
        | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    name = d['Self']['DNSName'].rstrip('.')
    print(name)
except Exception:
    pass
" 2>/dev/null || true)
fi

if [ -z "$TS_FQDN" ]; then
    echo ""
    echo "  [WARN] Tailscale FQDN を自動取得できませんでした。"
    echo "  Tailscale が起動しているか確認: sudo tailscale status"
    echo ""
    read -rp "  FQDN を手動入力 (例: myhost.tail1234.ts.net): " TS_FQDN
    if [ -z "$TS_FQDN" ]; then
        echo "[ERROR] FQDN が入力されませんでした。終了します。"
        exit 1
    fi
fi

echo "  ✅ Tailscale FQDN: ${TS_FQDN}"

# ── ディレクトリ作成 ─────────────────────────────────
echo ""
echo "[2/4] ディレクトリを作成中..."

mkdir -p "${CADDY_DIR}/config"
mkdir -p "${CADDY_DIR}/logs"

echo "  ✅ ${CADDY_DIR}/config  … Caddyfile 置き場"
echo "  ✅ ${CADDY_DIR}/logs    … アクセスログ"

# ── Caddyfile 生成 ───────────────────────────────────
echo ""
echo "[3/4] 設定ファイルを生成中..."

# caddy fmt に通るよう、インデントはタブではなく4スペースで統一
cat > "${CADDY_DIR}/config/Caddyfile" << CADDYFILE
# ============================================================
# Caddyfile — Tailscale FQDN: ${TS_FQDN}
#
# サービス追加後のリロード(ダウンタイムなし):
#   docker exec caddy caddy reload --config /etc/caddy/Caddyfile
# ============================================================

{
    email admin@${TS_FQDN}
    admin localhost:2019
}

# ── サービス追加テンプレート ──────────────────────────────
#
# 【別コンテナ(caddy-net に参加させること)】
# myapp.${TS_FQDN} {
#     reverse_proxy myapp-container:3000
# }
#
# 【ホスト上のプロセス】
# myapp.${TS_FQDN} {
#     reverse_proxy localhost:8080
# }
#
# 【Basic 認証付き】
# private.${TS_FQDN} {
#     basicauth {
#         # ハッシュ生成: docker exec -it caddy caddy hash-password
#         admin \$2a\$14\$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#     }
#     reverse_proxy localhost:9000
# }
# ──────────────────────────────────────────────────────────

# デフォルト: 動作確認用(サービス追加後はこのブロックを削除または上書き)
${TS_FQDN} {
    respond "Caddy is running on ${TS_FQDN}" 200
}
CADDYFILE

# ── docker-compose.yml 生成 ──────────────────────────
# ヒアドキュメント内の $ をエスケープして変数展開を防ぐ
cat > "${CADDY_DIR}/docker-compose.yml" << 'COMPOSE_EOF'
# ============================================================
# Caddy 単体構成
# ============================================================
networks:
  caddy-net:
    name: caddy-net
    driver: bridge

volumes:
  caddy_data:
  caddy_config:

services:
  caddy:
    image: caddy:latest
    container_name: caddy
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - /opt/docker/caddy/config:/etc/caddy
      - caddy_data:/data
      - caddy_config:/config
      - /opt/docker/caddy/logs:/var/log/caddy
      - /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock
    networks:
      - caddy-net
    healthcheck:
      test: ["CMD", "caddy", "version"]
      interval: 30s
      timeout: 5s
      retries: 3
COMPOSE_EOF

echo "  ✅ ${CADDY_DIR}/config/Caddyfile"
echo "  ✅ ${CADDY_DIR}/docker-compose.yml"

# ── 起動 ─────────────────────────────────────────────
echo ""
echo "[4/4] コンテナを起動中..."
docker compose -f "${CADDY_DIR}/docker-compose.yml" up -d

# ── Caddyfile フォーマット警告を解消 ─────────────────
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true

# ── 完了 ─────────────────────────────────────────────
echo ""
echo "======================================"
echo " ✅ セットアップ完了!"
echo "======================================"
echo ""
echo "  Tailscale FQDN : ${TS_FQDN}"
echo "  確認URL        : https://${TS_FQDN}"
echo ""
echo "  ── よく使うコマンド ──────────────────────────"
echo "  # Caddyfile 編集後リロード"
echo "  docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
echo ""
echo "  # ログ確認"
echo "  docker compose -f ${CADDY_DIR}/docker-compose.yml logs -f"
echo ""
echo "  # Basic 認証パスワードハッシュ生成"
echo "  docker exec -it caddy caddy hash-password"
echo "=============================================="

このスクリプトがやること

tailscale status --json から Self.DNSName(例: myhost.tail1234.ts.net)を取得して、CaddyfileとDocker Composeに自動で埋め込みます。Tailscaleが起動していない場合は手動入力にフォールバックします。

サービスを追加するときの流れ

1. Caddyfileを編集

sudo nano /opt/docker/caddy/config/Caddyfile

2. ダウンタイムなしでリロード

docker exec caddy caddy reload --config /etc/caddy/Caddyfile

これだけです。再起動不要で即反映されます。

他のコンテナをCaddyの後ろに置く場合

そのサービスの docker-compose.yml に以下を追記するだけで、コンテナ名でアクセスできるようになります。

networks:
  caddy-net:
    external: true

services:
  myapp:
    ...
    networks:
      - caddy-net

Caddyfile側は reverse_proxy myapp:3000 のようにコンテナ名で指定できます。

パスワード管理のVaultwardenを導入

続いてパスワード管理のVaultwardenを導入します。

#!/bin/bash
# ============================================================
# Vaultwarden セットアップスクリプト
# インストール先: /opt/docker/vaultwarden
# 前提: Caddy(setup-caddy.sh)導入済み
# 使い方: sudo bash setup-vaultwarden.sh
# ============================================================
set -euo pipefail

VW_DIR="/opt/docker/vaultwarden"
CADDY_CONF="/opt/docker/caddy/config/Caddyfile"

echo "======================================"
echo " Vaultwarden セットアップ"
echo "======================================"

# ── 前提確認 ────────────────────────────────────────
if ! command -v docker &>/dev/null; then
    echo "[ERROR] Docker が見つかりません。"
    exit 1
fi

if [ ! -f "$CADDY_CONF" ]; then
    echo "[ERROR] Caddyfile が見つかりません: ${CADDY_CONF}"
    echo "        先に setup-caddy.sh を実行してください。"
    exit 1
fi

if ! docker inspect caddy &>/dev/null; then
    echo "[ERROR] Caddy コンテナが起動していません。"
    echo "        先に setup-caddy.sh を実行してください。"
    exit 1
fi

# ── Tailscale FQDN の取得 ────────────────────────────
echo ""
echo "[1/4] Tailscale FQDN を取得中..."

TS_FQDN=""

if command -v tailscale &>/dev/null; then
    TS_FQDN=$(tailscale status --json 2>/dev/null \
        | python3 -c "
import sys, json
try:
    d = json.load(sys.stdin)
    print(d['Self']['DNSName'].rstrip('.'))
except Exception:
    pass
" 2>/dev/null || true)
fi

if [ -z "$TS_FQDN" ]; then
    echo ""
    echo "  [WARN] Tailscale FQDN を自動取得できませんでした。"
    read -rp "  FQDN を手動入力 (例: myhost.tail1234.ts.net): " TS_FQDN
    if [ -z "$TS_FQDN" ]; then
        echo "[ERROR] FQDN が入力されませんでした。終了します。"
        exit 1
    fi
fi

echo "  ✅ Tailscale FQDN: ${TS_FQDN}"

# ── ディレクトリ・トークン準備 ───────────────────────
echo ""
echo "[2/4] ディレクトリとAdmin Tokenを準備中..."

mkdir -p "${VW_DIR}/data"

# Admin Token 生成(既存があれば引き継ぐ)
if [ -f "${VW_DIR}/.env" ] && grep -q "^ADMIN_TOKEN=" "${VW_DIR}/.env"; then
    ADMIN_TOKEN=$(grep "^ADMIN_TOKEN=" "${VW_DIR}/.env" | cut -d= -f2)
    echo "  ✅ 既存の Admin Token を引き継ぎます。"
else
    ADMIN_TOKEN=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 64)
    install -m 600 /dev/null "${VW_DIR}/.env"
    echo "ADMIN_TOKEN=${ADMIN_TOKEN}" > "${VW_DIR}/.env"
    echo "  ✅ Admin Token を生成しました。"
fi

# ── docker-compose.yml 生成 ──────────────────────────
echo ""
echo "[3/4] 設定ファイルを生成中..."

# ヒアドキュメント内で変数展開が必要な箇所のみ展開
cat > "${VW_DIR}/docker-compose.yml" << COMPOSE
networks:
  caddy-net:
    external: true

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    volumes:
      - ./data:/data
    environment:
      DOMAIN: "https://${TS_FQDN}"
      WEBSOCKET_ENABLED: "true"
      SIGNUPS_ALLOWED: "true"
      ADMIN_TOKEN: "${ADMIN_TOKEN}"
      LOG_LEVEL: "warn"
    networks:
      - caddy-net
COMPOSE

echo "  ✅ ${VW_DIR}/docker-compose.yml"

# ── Caddyfile にVaultwardenブロックを追加 ────────────
# すでにvaultwardenブロックがあれば既存のFQDNブロックを置き換え
# なければ末尾に追記

NEW_BLOCK="${TS_FQDN} {
    reverse_proxy /notifications/hub vaultwarden:3012
    reverse_proxy vaultwarden:80
}"

if grep -q "reverse_proxy.*vaultwarden" "$CADDY_CONF"; then
    echo "  ✅ Caddyfile に既存のVaultwardenブロックがあります。スキップ。"
else
    # FQDNのブロックが存在する場合(デフォルトのrespondブロック)は置き換え
    if grep -q "^${TS_FQDN}" "$CADDY_CONF"; then
        python3 - "$CADDY_CONF" "$TS_FQDN" "$NEW_BLOCK" << 'PYEOF'
import sys, re

conf_path, fqdn, new_block = sys.argv[1], sys.argv[2], sys.argv[3]

with open(conf_path) as f:
    content = f.read()

# FQDNブロック(ネストした {} を正しく対応させて検索)
def find_block(text, fqdn):
    pattern = re.compile(r'^' + re.escape(fqdn) + r'\s*\{', re.MULTILINE)
    m = pattern.search(text)
    if not m:
        return None, None
    start = m.start()
    depth, i = 0, m.end() - 1
    while i < len(text):
        if text[i] == '{':
            depth += 1
        elif text[i] == '}':
            depth -= 1
            if depth == 0:
                return start, i + 1
        i += 1
    return None, None

start, end = find_block(content, fqdn)
if start is not None:
    new_content = content[:start] + new_block + '\n' + content[end:].lstrip('\n')
    with open(conf_path, 'w') as f:
        f.write(new_content)
    print(f"  ✅ {fqdn} ブロックをVaultwardenに置き換えました。")
else:
    print(f"  [WARN] {fqdn} ブロックが見つかりませんでした。末尾に追記します。")
    with open(conf_path, 'a') as f:
        f.write(f'\n{new_block}\n')
PYEOF
    else
        # FQDNブロック自体がない場合は末尾に追記
        printf '\n%s\n' "$NEW_BLOCK" >> "$CADDY_CONF"
        echo "  ✅ Caddyfile 末尾にVaultwardenブロックを追記しました。"
    fi
fi

# Caddyfileフォーマット整形(警告解消)
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
echo "  ✅ Caddyfile をフォーマットしました。"

# ── 起動 ─────────────────────────────────────────────
echo ""
echo "[4/4] Vaultwarden を起動中..."

docker compose -f "${VW_DIR}/docker-compose.yml" up -d

# caddy-net に参加できているか確認
NETWORKS=$(docker inspect vaultwarden --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' 2>/dev/null || true)
if echo "$NETWORKS" | grep -q "caddy-net"; then
    echo "  ✅ caddy-net に参加済み"
else
    echo "  [ERROR] caddy-net への参加に失敗しました。"
    echo "          docker inspect vaultwarden で確認してください。"
    exit 1
fi

# Caddy リロード
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
echo "  ✅ Caddy をリロードしました。"

# ── 完了 ─────────────────────────────────────────────
echo ""
echo "======================================"
echo " ✅ セットアップ完了!"
echo "======================================"
echo ""
echo "  URL  : https://${TS_FQDN}"
echo "  Admin: https://${TS_FQDN}/admin"
echo "  Token: ${ADMIN_TOKEN}"
echo ""
echo "  ⚠ Admin Token は以下に保存されています:"
echo "    ${VW_DIR}/.env"
echo ""
echo "  ── よく使うコマンド ──────────────────────────"
echo "  # ログ確認"
echo "  docker compose -f ${VW_DIR}/docker-compose.yml logs -f"
echo ""
echo "  # 停止"
echo "  docker compose -f ${VW_DIR}/docker-compose.yml down"
echo ""
echo "  # 更新"
echo "  docker compose -f ${VW_DIR}/docker-compose.yml pull"
echo "  docker compose -f ${VW_DIR}/docker-compose.yml up -d"
echo "=============================================="
タイトルとURLをコピーしました