目次
DockerでNginxを実行する方法:静的サイトとリバースプロキシ
このセクションは2部構成です。まずNginxで静的サイトを配信し、次にNginxをリバースプロキシとして他のアプリケーションコンテナの前段に配置します。最後に、CaddyでHTTPS証明書を自動取得する方法を紹介します。
最速で静的サイトを公開する
nginx-siteフォルダーを用意し、サイトのファイルをpublic/に配置します。
nginx-site/
└── public/
├── index.html
├── style.css
└── app.js
public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Static Site</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>My Static Site</h1>
<p>Served by Nginx in a Docker container.</p>
<p id="path"></p>
</main>
<script src="/app.js"></script>
</body>
</html>
public/app.jsは1行だけで、現在のURLパスをページに表示します(後でSPAをテストするときに使います)。
document.getElementById("path").textContent = "Current path: " + location.pathname;
style.cssの内容は自由です。public/を公式イメージのWebルート/usr/share/nginx/htmlにマウントします。
$ docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
806fb20a7c3ba980710bf43520c118d724346248d387f8743f8ba26e5e182345
$ curl -s http://localhost:8080/ | head -n 6
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Static Site</title>
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
404
Windowsでの書き方(タブをクリックして切り替えます):
docker run -d --name site -p 8080:80 -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
:roは読み取り専用を意味します。Nginxがファイルを読み取るだけでよいためです。http://localhost:8080を開きます。

最後のコマンドは存在しないパス/dashboard/settingsをリクエストし、404を返します。一般的なWebサイトではこれで正しい動作です。しかし、ReactやVueなどのシングルページアプリケーション(SPA)では、URLパスをフロントエンドが処理するため、サーバーはindex.htmlを返す必要があります。そのためにはカスタム設定ファイルが必要です。
カスタム設定ファイル
公式イメージは /etc/nginx/conf.d/ 内のすべての .conf ファイルを読み込みます。デフォルトでは default.conf が 1 つだけあります。最も簡単な方法は、自分のファイルで置き換えることです。
最初の default.conf:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# シングルページアプリケーション(SPA):ファイルが見つからない場合は index.html を返す
location / {
try_files $uri $uri/ /index.html;
}
# 静的アセットを長期間キャッシュ
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
}
| 設定 | 役割 |
|---|---|
try_files $uri $uri/ /index.html | ファイル、ディレクトリの順に探し、どちらも見つからなければ /index.html を返します |
location ~* \.(js|css|...)$ | ~* は大文字と小文字を区別しない正規表現を表し、静的アセットにマッチします |
gzip on + gzip_types | テキスト形式のレスポンスを圧縮します。HTML はデフォルトで圧縮されますが、その他の形式は gzip_types に指定する必要があります |
設定ファイルもマウントします。
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
89c2d3163e3db3bb4192bac6dfd48732c5f72d2d9d4584b9d3e1a32416c01f2a
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/dashboard/settings
200
$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css
HTTP/1.1 200 OK
Server: nginx/1.30.5
Date: Tue, 13 Oct 2026 00:40:31 GMT
Content-Type: text/css
Last-Modified: Tue, 13 Oct 2026 00:38:42 GMT
Connection: keep-alive
ETag: W/"6acd7d92-154"
Expires: Thu, 12 Nov 2026 00:40:31 GMT
Cache-Control: max-age=2592000
Cache-Control: public, immutable
Content-Encoding: gzip
Windows での書き方(タブをクリックして切り替えます):
docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
/dashboard/settingsは200を返すようになり、try_filesが機能しています。Content-Encoding: gzipは CSS が圧縮されていることを示します。- ただし
Cache-Controlが 2 回表示されています。expires 30dはCache-Control: max-age=2592000を自動生成し、add_headerでもう 1 つ追加しています。ブラウザーは通常これを処理できますが、意図した結果ではありません。
ブラウザーで http://localhost:8080/dashboard/settings を開くと、表示されるのは index.html です。JavaScript が読み取るパスは /dashboard/settings なので、フロントエンドルーターはこのパスに応じてページを表示できます。

キャッシュ設定を修正します。expires を削除し、add_header だけで完全な値を指定します。
location ~* \.(js|css|png|jpg|svg|woff2)$ {
add_header Cache-Control "public, max-age=2592000, immutable";
}
$ docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
868d331a579d208e646ee1be3a3d2a906720578a39418db2c4bd50febb892fd7
$ curl -sI -H "Accept-Encoding: gzip" http://localhost:8080/style.css | grep -E "Cache-Control|Content-Encoding"
Cache-Control: public, max-age=2592000, immutable
Content-Encoding: gzip
Windows での書き方(タブをクリックして切り替えます):
docker run -d --name site -p 8080:80 -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "${PWD}/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name site -p 8080:80 -v "%cd%/default.conf:/etc/nginx/conf.d/default.conf:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
2592000 秒は 30 日です。immutable は、有効期限内にファイルが更新されたかどうかをブラウザーが再確認する必要がないことを示します。そのため、バージョンハッシュを含むファイル名(例:app.3f9a1c.js)にのみ適しています。フロントエンドのビルドツールは通常、このようなファイル名を自動生成します。
構文を確認して再読み込みする
設定を変更したら、まず nginx -t で構文を確認し、問題がなければ nginx -s reload で再読み込みします。コンテナを再起動する必要はなく、処理中の接続も切断されません。
$ docker exec site nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ docker exec site nginx -s reload
2026/10/13 00:40:34 [notice] 27#27: signal process started
$ docker logs site 2>&1 | tail -n 4
2026/10/13 00:40:34 [notice] 1#1: reconfiguring
2026/10/13 00:40:34 [notice] 1#1: using the "epoll" event method
2026/10/13 00:40:34 [notice] 1#1: start worker processes
2026/10/13 00:40:34 [notice] 1#1: start worker process 32
ログの reconfiguring と start worker process は、Nginx が新しい設定で新しい worker を起動したことを示しています。
構文に誤りがあると、nginx -t が問題の箇所を示します。たとえば、listen 80; のセミコロンを削除します。
$ sed 's/listen 80;/listen 80/' default.conf > broken.conf
$ docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: can not modify /etc/nginx/conf.d/default.conf (read-only file system?)
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/13 00:40:35 [emerg] 1#1: invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: [emerg] invalid parameter "server_name" in /etc/nginx/conf.d/default.conf:3
nginx: configuration file /etc/nginx/nginx.conf test failed
Windows での書き方(タブをクリックして切り替えます)。
docker run --rm -v "$(pwd)/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "${PWD}/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
docker run --rm -v "%cd%/broken.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
エラーメッセージでは 3 行目の server_name が示されていますが、実際の問題は 2 行目のセミコロンが抜けていることです。Nginx は次の行の server_name も listen の引数として扱います。理解しにくいエラーが出たら、直前の行も確認してください。
先頭が /docker-entrypoint.sh: の数行は、公式イメージの起動スクリプトのメッセージです。can not modify ... (read-only file system?) は、:ro を指定して設定ファイルを読み取り専用でマウントしているため、スクリプトが IPv6 設定を自動で追加できないことを示します。このメッセージは無視できます。
docker run ... nginx -tだけで設定ファイルを確認できます。Web サイトを実際に起動する必要はありません。CI でこの方法を使えば、デプロイ前に設定を確認できます。
環境変数で設定を生成する
公式イメージはテンプレートに対応しています。ファイルを /etc/nginx/templates/ の下に置き、拡張子を .conf.template にすると、起動時に ${変数} が環境変数で置き換えられ、/etc/nginx/conf.d/ に出力されます。
templates/default.conf.template:
server {
listen 80;
server_name ${SERVER_NAME};
root /usr/share/nginx/html;
location / {
add_header X-Environment ${APP_ENV};
try_files $uri $uri/ /index.html;
}
}
$ docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
77b6991d1d85b713cc741c86504d61e6a8dc867997e80219ec77b3f3c3be3165
$ docker logs tpl 2>&1 | grep -i template
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
20-envsubst-on-templates.sh: Running envsubst on /etc/nginx/templates/default.conf.template to /etc/nginx/conf.d/default.conf
$ docker exec tpl cat /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
location / {
add_header X-Environment staging;
try_files $uri $uri/ /index.html;
}
}
$ curl -sI http://localhost:8080/ | grep X-Environment
X-Environment: staging
Windows での書き方(タブをクリックして切り替えます)。
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "$(pwd)/templates:/etc/nginx/templates:ro" -v "$(pwd)/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "${PWD}/templates:/etc/nginx/templates:ro" -v "${PWD}/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
docker run -d --name tpl -p 8080:80 -e SERVER_NAME=example.com -e APP_ENV=staging -v "%cd%/templates:/etc/nginx/templates:ro" -v "%cd%/public:/usr/share/nginx/html:ro" nginx:1.30-alpine
${SERVER_NAME} と ${APP_ENV} は環境変数の値に置き換えられますが、$uri などNginx独自の変数は変更されません。起動スクリプトが置き換えるのは、定義済みの環境変数だけです。同じイメージを使い、環境変数を変えるだけでテスト環境や本番環境にデプロイできます。
イメージにパッケージ化する
開発にはマウントが適しています。デプロイ時は、設定とサイトのファイルをまとめてイメージにパッケージ化します。Dockerfile:
FROM nginx:1.30-alpine
COPY default.conf /etc/nginx/conf.d/default.conf
COPY public/ /usr/share/nginx/html/
$ docker build -t my-site .
[+] Building 2.3s (8/8) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 145B 0.0s
=> [internal] load metadata for docker.io/library/nginx:1.30-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [1/3] FROM docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 1.9s
=> => resolve docker.io/library/nginx:1.30-alpine@sha256:0985e772fb9f729e6fa0980da05fca5d9c468e870eed43071545afa9d2e27d94 0.1s
=> => extracting sha256:e3320d02d5786716dcc40d9f6267baec5d3d2c46945082958050c3ac5ea23751 0.3s
=> => extracting sha256:3d85d110b167fae23d8eb3a7060cd079682b682b04d295fc7bbdf454c9e722c8 0.0s
=> => extracting sha256:22e5a8a110ec69c6af30325ccfe8bb0bfb94e5783117808061bae5115e1e3dcb 0.0s
=> => extracting sha256:6b4dfb2e8f8a72d7ae1380b667f50faccdc19147869b2afa09aeddfdea59ce64 0.0s
=> => extracting sha256:703c5424632fab32aa3327bebe6e5202ac53a93740062efb830f8188e580b9db 0.0s
=> => extracting sha256:b335b7ac3a400afa780d0d89e0b778ee19dc3e3df95bed4b274a763af2264fd7 0.0s
=> => extracting sha256:b27cf3f7c39dd0da0aa52a285f0c0cce938ac61472babfea353b1daa83586c49 1.4s
=> [internal] load build context 0.1s
=> => transferring context: 1.51kB 0.0s
=> [2/3] COPY default.conf /etc/nginx/conf.d/default.conf 0.1s
=> [3/3] COPY public/ /usr/share/nginx/html/ 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.0s
=> => exporting manifest sha256:8bac0b1cbdfd6612d9eb656089bb18a18c4dba64cb8557aec5d427164a205865 0.0s
=> => exporting config sha256:5b6710aa0888ae70f084ad7911539cb362f4f919de995cf997898d37a9968baf 0.0s
=> => exporting attestation manifest sha256:9be9e0e07eacaece4ad72b53c87a2a7f84b4fa99af726bf98a33229fbbf9e4eb 0.0s
=> => exporting manifest list sha256:af4d78aa254eab0a55dd440f1e220ed73d0a5141343d9ae0fe19890e8471cd04 0.0s
=> => naming to docker.io/library/my-site:latest 0.0s
=> => unpacking to docker.io/library/my-site:latest 0.0s
$ docker run -d --name site -p 8080:80 my-site
d614148de5a6c2243074f865a0c03266a8e81ba069733546412d4abe9ad64fa0
$ curl -s http://localhost:8080/ | grep "<h1>"
<h1>My Static Site</h1>
$ docker images my-site
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
my-site:latest af4d78aa254e 92.8MB 26.1MB U
イメージのサイズは nginx:1.30-alpine(93.7 MB)よりわずかに大きいだけです。事前にビルドが必要なフロントエンドプロジェクト(React、Vue など)については、マルチステージビルドを参照してください。第1ステージでは Node.js で npm run build を実行し、第2ステージでは出力されたフォルダーを Nginx イメージにコピーします。
リバースプロキシ:入口は1つ、バックエンドは複数
よくある構成では、外部にポートを公開するのは Nginx だけで、バックエンドのアプリケーションコンテナはどれも公開しません。
ユーザー ──▶ nginx:80 ──┬──▶ app:3000 (/ → Express)
└──▶ api:8000 (/api/ → FastAPI)
ここでは、前の2つのセクションで作成したプロジェクトをバックエンドとしてそのまま使います:Node.js アプリケーション の Express と、Python アプリケーション の FastAPI です。
proxy-demo/
├── compose.yaml
├── nginx.conf
├── app/ ← Node.js のセクションの Express プロジェクト
└── api/ ← Python のセクションの FastAPI プロジェクト
nginx.conf:
server {
listen 80;
location / {
proxy_pass http://app:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
client_max_body_size 20m;
}
| 設定 | 役割 |
|---|---|
proxy_pass http://app:3000 | Compose サービス app のポート 3000(コンテナ内のポート)に転送します |
Host | ユーザーのリクエストのドメインを保持します。そうしないと、バックエンドには app:3000 と表示されます |
X-Real-IP、X-Forwarded-For | ユーザーの実際の IP を渡します。そうしないと、バックエンドには Nginx コンテナの IP しか表示されません |
X-Forwarded-Proto | 元のリクエストが http と https のどちらかをバックエンドに伝えます |
client_max_body_size 20m | アップロードできるサイズを指定します。デフォルトは 1 MB です |
compose.yaml:
services:
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
- api
restart: unless-stopped
app:
build: ./app
restart: unless-stopped
api:
build: ./api
restart: unless-stopped
ports があるのは nginx だけです。起動します。
$ docker compose up -d --build
[+] Building 2.6s (23/23) FINISHED
=> [internal] load local bake definitions 0.0s
=> => reading from stdin 885B 0.0s
=> [app internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 263B 0.0s
=> [api internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 290B 0.0s
=> [api internal] load metadata for docker.io/library/python:3.14-slim 0.0s
=> [app internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [app internal] load .dockerignore 0.0s
=> => transferring context: 82B 0.0s
=> [api internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [app 1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.1s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.1s
=> [app internal] load build context 0.1s
=> => transferring context: 30.70kB 0.0s
=> [api 1/5] FROM docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 0.1s
=> => resolve docker.io/library/python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 0.1s
=> [api internal] load build context 0.1s
=> => transferring context: 655B 0.0s
=> CACHED [app 2/5] WORKDIR /app 0.0s
=> CACHED [app 3/5] COPY package.json package-lock.json ./ 0.0s
=> CACHED [app 4/5] RUN npm ci --omit=dev && npm cache clean --force 0.0s
=> [app 5/5] COPY --chown=node:node . . 0.0s
=> CACHED [api 2/5] WORKDIR /app 0.0s
=> CACHED [api 3/5] COPY requirements.txt . 0.0s
=> CACHED [api 4/5] RUN pip install -r requirements.txt 0.0s
=> CACHED [api 5/5] COPY . . 0.0s
=> [app] exporting to image 0.6s
=> => exporting layers 0.1s
=> => exporting manifest sha256:30fc2b30e552f64db78c5b43bafd2ada5445c202a13517f1413cfbbf94b39fd3 0.0s
=> => exporting config sha256:ec00425160f63ab1fc89cf3ffb5cd5c91034dfe42b069922b6db604c6512dc94 0.0s
=> => exporting attestation manifest sha256:bcd908d6b45f083d6373facdb8103d3cad7f5c8855744d68614f3c983fe2a3be 0.0s
=> => exporting manifest list sha256:ec1d3f8403286973e156b78f0db4566b8815c8e885f10c176b64c2c6cb2a75c5 0.0s
=> => naming to docker.io/library/proxy-demo-app:latest 0.0s
=> => unpacking to docker.io/library/proxy-demo-app:latest 0.4s
=> [api] exporting to image 2.1s
=> => exporting layers 0.0s
=> => exporting manifest sha256:89889ca77d8a1fe2e05952bd565d6f4b777e87d2343df4d846ad16bec3752caa 0.0s
=> => exporting config sha256:6bc4e896d1388da575d77436bebb27a4b9941ed84896155384aef74eb4c6bf7c 0.0s
=> => exporting attestation manifest sha256:c4130d12dd13346aa1cc105159cb8ff35177f368269cca237584c9e648fd2da7 0.0s
=> => exporting manifest list sha256:104bdf1ed74105f0bd21955d695d87d5225e50e4df7ab5715ac86eb551aac340 0.0s
=> => naming to docker.io/library/proxy-demo-api:latest 0.0s
=> => unpacking to docker.io/library/proxy-demo-api:latest 2.1s
=> [app] resolving provenance for metadata file 0.0s
=> [api] resolving provenance for metadata file 0.0s
[+] up 6/6
✔ Image proxy-demo-api Built 2.7s
✔ Image proxy-demo-app Built 2.7s
✔ Network proxy-demo_default Created 0.0s
✔ Container proxy-demo-app-1 Started 0.4s
✔ Container proxy-demo-api-1 Started 0.7s
✔ Container proxy-demo-nginx-1 Started 1.2s
各パスをテストします。
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
proxy-demo-api-1 proxy-demo-api "uvicorn main:app --…" api 7 seconds ago Up 6 seconds 8000/tcp
proxy-demo-app-1 proxy-demo-app "docker-entrypoint.s…" app 7 seconds ago Up 6 seconds 3000/tcp
proxy-demo-nginx-1 nginx:1.30-alpine "/docker-entrypoint.…" nginx 7 seconds ago Up 5 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
$ curl -s http://localhost:8080/
{"message":"Hello from Express","node":"v24.21.0"}
$ curl -s http://localhost:8080/api/
{"message":"Hello from FastAPI"}
$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"item_id":7,"q":"proxy"}
$ curl -s http://localhost:8080/api/docs | grep -o "<title>.*</title>"
<title>Todo API - Swagger UI</title>
psではappとapiの PORTS は3000/tcpと8000/tcpのみで、ホストには割り当てられていません。外部からは Nginx 経由でしかアクセスできません。/は Express に転送され、/api/と/api/items/7は FastAPI に転送されます。- FastAPI の
/docsも/api/docsからアクセスできます。(実際に使う場合は、ページで参照される/openapi.jsonのパスを FastAPI のroot_path設定で調整する必要があります。)
実測:proxy_pass の末尾のスラッシュ
proxy_pass http://api:8000/; の末尾に / があると、Nginx は location に一致した /api/ を / に置き換えるため、/api/items/7 は /items/7 としてバックエンドに転送されます。末尾に / がない場合は /api/items/7 のまま転送され、バックエンドにそのパスがなければ 404 になります。これは最もよくある設定ミスの一つです。実際に試してみましょう。
末尾の / を削除してから、再読み込みします。ここで別の落とし穴にもはまります。
$ sed -i 's#http://api:8000/;#http://api:8000;#' nginx.conf
$ grep "api:8000" nginx.conf
proxy_pass http://api:8000;
$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
proxy_pass http://api:8000/;
$ docker compose restart nginx
Container proxy-demo-nginx-1 Restarting
Container proxy-demo-nginx-1 Started
$ docker compose exec nginx grep "api:8000" /etc/nginx/conf.d/default.conf
proxy_pass http://api:8000;
$ curl -s "http://localhost:8080/api/items/7?q=proxy"
{"detail":"Not Found"}
$ docker compose logs --tail 1 api
api-1 | INFO: 172.19.0.4:44428 - "GET /api/items/7?q=proxy HTTP/1.1" 404 Not Found
順に説明します。
sed -iでホスト上のnginx.confを変更し、grepで変更されたことを確認します。- しかし、コンテナ内の
/etc/nginx/conf.d/default.confは古いままです。sed -i(vim など多くのエディターも同様)は保存時に新しいファイルを作成して古いファイルと置き換えます。単一ファイルのマウントが紐付けているのは元のファイルなので、コンテナから新しいファイルは見えません。この状態ではnginx -s reloadを実行しても効果がありません。 docker compose restart nginxでコンテナを再起動すると、再マウントされて新しい内容が反映されます。- 末尾のスラッシュがないため、
/api/items/7?q=proxyは{"detail":"Not Found"}を返します。 - FastAPI のログから、
/items/7ではなくGET /api/items/7を受信したことがわかります。
単一ファイルのマウントによる問題を避けるには、
./nginx:/etc/nginx/conf.d:roのようにフォルダー全体をマウントします。フォルダー内のファイルが置き換えられても、コンテナから確認できます。または、設定を変更した後は必ずコンテナを再起動します。
502 Bad Gateway
502 は、Nginx がリクエストを受信したものの、バックエンドに接続できないことを示します。よくあるケースをいくつか実測してみましょう。
バックエンドのポート番号が間違っている(app:3000 を app:3001 に変更してから Nginx を再起動します)。
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 0.001010s
$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1 | 2026/10/13 00:43:32 [error] 21#21: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3001/", host: "localhost:8080"
すぐに 502 が返り、エラーログには Connection refused と記録されています。app というホストは見つかりましたが、ポート 3001 で待ち受けているプログラムがありません。バックエンドのプログラムが 127.0.0.1 のみにバインドされている場合や、起動中の場合も同じエラーになります。

バックエンドのコンテナが停止している場合:
$ docker compose stop app
Container proxy-demo-app-1 Stopping
Container proxy-demo-app-1 Stopped
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/
502 3.062307s
$ docker compose logs nginx 2>&1 | grep "\[error\]" | tail -n 1
nginx-1 | 2026/10/13 00:43:39 [error] 20#20: *1 connect() failed (113: Host is unreachable) while connecting to upstream, client: 172.19.0.1, server: , request: "GET / HTTP/1.1", upstream: "http://172.19.0.2:3000/", host: "localhost:8080"
約 3 秒後に 502 が返り、エラーは Host is unreachable です。Nginx は起動時に app を IP アドレスに解決して記憶しており、コンテナの停止後、その IP アドレスから応答がありません。ネットワークが接続不能をすぐに通知しない場合、Nginx はタイムアウトまで待ち続けます(デフォルトは 60 秒)。その場合は 504 Gateway Timeout が返ります。
バックエンドサービスが存在しない場合(Nginx のみを起動し、app と api は起動しません)。
$ docker compose up -d --no-deps nginx
Container proxy-demo-nginx-1 Started
$ docker compose ps -a nginx
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
proxy-demo-nginx-1 nginx:1.30-alpine "/docker-entrypoint.…" nginx 3 seconds ago Restarting (1) Less than a second ago
$ docker compose logs nginx 2>&1 | grep emerg | head -n 2
nginx-1 | 2026/10/13 00:43:48 [emerg] 1#1: host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
nginx-1 | nginx: [emerg] host not found in upstream "app" in /etc/nginx/conf.d/default.conf:5
今度は Nginx の起動自体に失敗します。エラーは host not found in upstream "app" です。Nginx は起動時に proxy_pass 内のホスト名を解決する必要があります。解決できないと起動を拒否し、コンテナは restart: unless-stopped によって再起動を繰り返します(Restarting (1))。
502 の調査は次の順で行います。
- バックエンドのコンテナが実行中か:
docker compose ps proxy_passのサービス名とポート番号が正しいか(portsに指定したホスト側ポートではなく、コンテナ内のポートを使います)- バックエンドが
127.0.0.1ではなく0.0.0.0で待ち受けているか - Nginx とバックエンドが同じネットワークに接続されているか。ネットワークとコンテナ間通信を参照してください。
- Nginx のエラーログを確認する:
docker compose logs nginx | grep error
HTTPS:Caddy で証明書を自動取得する
Nginx で Let's Encrypt の証明書を使うには、certbot の設定、定期的な更新、再読み込みが別途必要です。手早く HTTPS を導入したい場合は、Caddy が証明書を自動で取得、インストール、更新します。設定もずっと簡潔です。
Caddyfile:
example.com {
reverse_proxy app:3000
}
compose.yaml:
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
app:
build: ./app
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
実際のドメインを使うための条件:
- ドメインの DNS がこのホストを指していること。
- インターネットからポート 80 と 443 に接続できること(Let's Encrypt はこれらのポートを使って検証します)。
caddy_dataに証明書が保存されます。削除しないでください。削除すると再作成のたびに証明書を再取得する必要があり、Let's Encrypt のレート制限に達する可能性があります。
ローカルで試す
実際のドメインがない場合は、Caddyfile のドメインを localhost にできます。Caddy は独自のローカル認証局を作成し、証明書を発行します。
localhost {
reverse_proxy app:3000
}
$ docker compose up -d
[+] up 5/5
✔ Network caddy-demo_default Created 0.0s
✔ Volume caddy-demo_caddy_config Created 0.0s
✔ Volume caddy-demo_caddy_data Created 0.0s
✔ Container caddy-demo-caddy-1 Started 0.3s
✔ Container caddy-demo-app-1 Started 0.6s
$ curl -sk https://localhost/
{"message":"Hello from Express","node":"v24.21.0"}
$ curl -sI http://localhost/ | head -n 3
HTTP/1.1 308 Permanent Redirect
Connection: close
Location: https://localhost/
$ docker compose logs caddy 2>&1 | grep -oE '"msg":"(enabling automatic[^"]*|obtaining certificate|certificate obtained successfully)"'
"msg":"enabling automatic HTTP->HTTPS redirects"
"msg":"enabling automatic TLS certificate management"
"msg":"obtaining certificate"
"msg":"certificate obtained successfully"
$ curl -svk https://localhost/ 2>&1 | grep -E "issuer|subject:"
* subject:
* issuer: CN=Caddy Local Authority - ECC Intermediate
https://localhost/から Express に正常にアクセスできます。証明書は公開認証局が発行したものではないため、curlでは検証を省略する-kが必要で、ブラウザーにも警告が表示されます。http://へのリクエストは自動的に308 Permanent Redirectでhttps://に転送されます。追加の設定は不要です。- ログには、Caddy が HTTP→HTTPS のリダイレクトと証明書管理を自動で有効にし、証明書を取得したことが表示されています。
- 証明書の発行者は
Caddy Local Authorityです。実際のドメインを使う場合は、Let's Encrypt に証明書を申請します。
Caddy の reverse_proxy はデフォルトで X-Forwarded-For や X-Forwarded-Proto などのヘッダーを転送するため、Nginx のように個別に設定する必要はありません。
まとめ
- 公式イメージは
/etc/nginx/conf.d/*.confを読み込み、templates/*.conf.templateの環境変数置換にも対応しています。 - SPA では
try_files $uri $uri/ /index.htmlを使います。expiresとadd_header Cache-Controlは同時に使わないでください。 proxy_passの末尾に/があると、location のプレフィックスが取り除かれます。- 外部に公開するポートはリバースプロキシだけにしてください。
練習問題
練習 1:リバースプロキシの例で、FastAPI のパスを /api/ から /backend/ に変更するには、どこを修正しますか?
解答
nginx.conf の location /api/ を location /backend/ に変更するだけです。proxy_pass http://api:8000/; の末尾の / はそのままにし、その後 docker compose exec nginx nginx -s reload を実行します(単一ファイルをマウントしている場合は docker compose restart nginx を使います)。
練習 2:Web サイトを起動せずに、nginx の設定ファイルに構文エラーがないかを 1 つのコマンドで確認するにはどうしますか?
解答
docker run --rm -v "$(pwd)/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.30-alpine nginx -t
よくある質問
nginx.confを変更した後、コンテナを再起動する必要がありますか?
まず docker exec コンテナ nginx -t で確認し、その後 nginx -s reload を実行すればよく、再起動は不要です。ただし、単一ファイルをマウントしている場合、エディターで保存するとコンテナから新しいファイルが見えなくなることがあります。実測結果はこの章を参照してください。
502 Bad Gatewayは何を意味しますか?
Nginxがバックエンドに接続できない状態です。バックエンドコンテナが実行中か、proxy_passのサービス名とコンテナ内ポートが正しいか、バックエンドが0.0.0.0でリッスンしているかを確認してください。
NginxとCaddyのどちらを選べばよいですか?
細かく制御する必要がある場合や、チームがNginxに慣れている場合はNginxを使いましょう。手早くHTTPSを設定したいだけなら、Caddyは設定がずっと短く、証明書も自動で更新します。
更新日 2026年10月14日