インフラ・DevOps
内部サービスのためのTLS証明書を正しく設定する
TLS certificates for internal services done right (tuxnet.dev)
要約
この記事では、内部サービス向けのTLS証明書を正しく管理する方法について解説しています。自己署名証明書や、.internalドメインの使用に伴う課題を避け、パブリックドメインとSplit-Horizon DNS、Let's Encrypt、Nginx、NetBirdを組み合わせることで、セキュアで管理しやすいTLS設定を実現するアプローチを紹介しています。
全文翻訳
内部サービスのためのTLS証明書を正しく設定する
2026-07-09
Jakub Kołodziejczak
dnstlsacmeletsencryptnetbirdnginxacme.sh
タイトルの「正しく設定する」は少し煽り気味かもしれませんが、なぜこれが「正しい方法」だと私が考えるのか説明しましょう。
シンプルな例から始めましょう。私たちは、多くのHTTPサービスをホストするサーバーを持っています。これらのサービスの一部は外部向けで、他は内部向けです。内部サービスにアクセスするには、VPNに接続している必要があります。
簡単のため、2つの選択肢を考えます。
ICANNによってプライベート利用に制限されたトップレベルドメイン(例: .internal)を使用する。
所有しているパブリックなルートドメイン(例: tuxnet.dev)を使用する。
例として、内部アプリにGrafanaを使用するとしましょう。これは内部IPアドレス10.0.1.10でアクセス可能で、VPNにはDNSリゾルバ機能があると仮定します。
では、.internalドメインでは何が問題なのでしょうか?
Grafana.tuxnet.internalのような、内部IPアドレス10.0.1.10に解決されるタイプ「A」のDNSレコードを単純に作成できます。しかし、これをプレーンテキストのHTTPサービスにしたくない場合、自己署名証明書を作成する必要があります。良い点は、これを行うチュートリアルが豊富にあることです(例: こちら)。悪い点は、すべてのHTTPクライアントがこの自己署名証明書を信頼するように設定する必要があることです。あるいは、TLS証明書の警告を無視するようにユーザーに伝えることもできます。
「正しい方法」とは?
「Split-Horizon DNS」という設定を紹介します。パブリックDNSリゾルバに対しては、grafana.tuxnet.devドメインはパブリックIPに解決され、VPNに接続されたクライアントに対しては、このドメインは内部IPに解決されます。
良い点は、パブリックIPに解決されるため、Let's EncryptやZeroSSLのようなパブリックCAを使用できることです。悪い点は、VPNから発信されていないトラフィックを拒否するWAF(Web Application Firewall)が必要になることです。
両方のソリューションの長所と短所を考慮すると、各マシンに自己署名証明書をインストールする(またはユーザーにTLSエラーを抑制するようにアドバイスする)よりも、1箇所(サーバー上)にWAFを設定する方がはるかに簡単だと考えます。
「口で言うだけじゃなくコードを見せろ」
理論は終わり、実践の時間です。必要なものは以下の通りです。
DNSリゾルバ機能を持つVPN — NetBirdを選択しました。
証明書発行のためのACMEクライアント — acme.shを選択しました。
Grafanaの前に配置するWAF機能を持つリバースプロキシ — nginxを選択しました。
私の他のブログ記事を読んだことがある方は、私がNetBird(Tailscaleごめん)のファンであることに気づいているかもしれません。Custom Zones機能のおかげで、NetBirdは「Split-Horizon DNS」に必要な重労働のほとんどを私たちに代わって行ってくれます。ユーザーグループやピアグループを使用することで、Custom Zonesを選択的に適用し、サーバーがgrafana.tuxnet.devに対してパブリックDNSリゾルバを使用するようにできます。
なぜサーバーをそのカスタムゾーンから除外するのか? http-01チャレンジを使用しない限り、それは必須ではありません。他の方法も可能ですが、このブログ記事ではhttp-01を選択しました。
さて、これで証明書を取得しましょう。
acme.sh --issue -d grafana.tuxnet.dev --server letsencrypt --standalone
acme.shは非常に柔軟で、多くのモードがあります。スタンドアロンモード(--standaloneフラグで有効)のクールな点は、nginxがポート80でリッスンする必要がないことです。このポートは、acme.shが証明書を取得する際にのみ「アクティブ」になります。
さて、nginxを稼働させましょう。これが設定です。
upstream grafana {
server localhost:3000;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen our-server.netbird.cloud:443 ssl;
server_name grafana.tuxnet.dev;
http2 on;
ssl_certificate /etc/ssl/certs/grafana.tuxnet.dev.crt;
ssl_certificate_key /etc/ssl/private/grafana.tuxnet.dev.key;
access_log /var/log/nginx/grafana.tuxnet.dev.access.log main;
error_log /var/log/nginx/grafana.tuxnet.dev.error.log warn;
location / {
proxy_pass http://grafana;
proxy_set_header Host $host;
}
# Proxy Grafana Live WebSocket connections.
location /api/live/ {
proxy_pass http://grafana;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
}
}
この設定の中で説明に値する重要な設定が1つあります — listen our-server.netbird.cloud:443 ssl;。これは、サーバーのVPNネットワークインターフェイスにバインドしています。our-server.netbird.cloudの代わりに、これはVPNのIPアドレスでも構いません。実際には、これはgrafana.tuxnet.devへのパブリックインターネットからのトラフィックを拒否します — これが私たちのWebアクセスファイアウォールです。
セキュリティはレイヤー(玉ねぎやお化けのように)でできています。私たちの最初のレイヤーはSplit-Horizon DNSですが、何らかの理由でそれが失敗したり、巧妙に回避されたりした場合、2番目のレイヤーであるWAFがラインを保持するはずです。
最後になりましたが、証明書の自動更新です。acme.shには標準で--cronフラグがあります。次に、acme.sh --cronを呼び出す毎日のcronジョブが必要です。acme.shは自動的にどの証明書を更新するかを選択します。私たちがする必要があるのは、cronジョブが新しい証明書をnginxのssl_certificateとssl_certificate_keyで定義された場所にコピーすることを確認することだけです。nginxも新しい証明書を使用するためにリロードする必要があります。
私たちのcronジョブは次のようになります。
main() {
refresh_certs
sync_api_tuxnet_dev_certs
sync_internal_tuxnet_dev_certs
reload_nginx
}
refresh_certs() {
setcap CAP_NET_BIND_SERVICE=+ep /usr/bin/socat1
sudo -u acmesh /home/acmesh/acme.sh/acme.sh --cron
setcap -r /usr/bin/socat1
}
sync_api_tuxnet_dev_certs() {
local green=$(get_checksum "$API_SRC_KEY")
local blue=$(get_checksum "$API_DST_KEY")
local key_allowed_group=www-data
if [[ "$green" != "$blue" ]]; then
sync_certs "$API_SRC_KEY" "$API_DST_KEY" "$API_SRC_CERT" "$API_DST_CERT" "$key_allowed_group"
fi
}
sync_internal_tuxnet_dev_certs() {
local green=$(get_checksum "$INTERNAL_SRC_KEY")
local blue=$(get_checksum "$INTERNAL_DST_KEY")
local key_allowed_group=www-data
if [[ "$green" != "$blue" ]]; then
sync_certs "$INTERNAL_SRC_KEY" "$INTERNAL_DST_KEY" "$INTERNAL_SRC_CERT" "$INTERNAL_DST_CERT" "$key_allowed_group"
fi
}
reload_nginx() {
nginx -t
systemctl reload nginx
}
get_checksum() {
sha256sum "$1" | cut -d' ' -f1
}
sync_certs() {
local src_key="$1"
local dst_key="$2"
local src_cert="$3"
local dst_cert="$4"
local key_allowed_group="$5"
cp -v "$src_key" "$dst_key"
logger "synced $dst_key"
cp -v "$src_cert" "$dst_cert"
logger "synced $dst_cert"
chown root:${key_allowed_group} "$dst_key"
chown root:ssl-cert "$dst_cert"
chmod 640 "$dst_key" "$dst_cert"
}
main "$@"
setcap CAP_NET_BIND_SERVICE=+ep /usr/bin/socat1 に関する小さなコメント。acme.shはスタンドアロンモードでsocatを使用してポート80でリッスンします。一方、可能であればacme.shをroot権限で実行したくありません。他方、ポート80は「特権ポート」の1つであり、デフォルトでは、特権ポートはroot以外のプロセスにバインドできません。ここでCAP_NET_BIND_SERVICE=+epが役立ちます。このトピックに興味がある場合は、この記事を確認できます。
これで、サービスが内部か外部か、翌日か来年かに関わらず、TLSが機能するようになりました。
ボーナス — SANとCNAME
さらに内部サービスがある場合はどうでしょうか?個別のサブドメインの下に置きたい場合はどうでしょうか?それぞれに個別の証明書を生成する必要がありますか?答えは「いいえ」で、2つの解決策があります。
ワイルドカード証明書 — セキュリティ上の懸念から、私はこれを好みません。
TLS SAN(Subject Alternative Name) — CN(Common Name)に加えて、言及されたSANを定義します。詳細については、https://www.ssl.com/faqs/common-name/ を参照してください。
実際には、internal.tuxnet.devの「A」レコードを作成し、次にgrafana.tuxnet.devやanalytics.tuxnet.devのようなCNAMEレコードを作成してinternal.tuxnet.devに解決させることができます。次に、次のような1つの証明書を生成します。
acme.sh --issue -d internal.tuxnet.dev -d grafana.tuxnet.dev -d analytics.tuxnet.dev --server letsencrypt --standalone
その詳細は次のようになります。