目次

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を開きます。

Nginxコンテナが配信する静的サイト
Nginxが配信する静的ページ。CSSとJavaScriptも正常に読み込まれています

最後のコマンドは存在しないパス/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 なので、フロントエンドルーターはこのパスに応じてページを表示できます。

SPA のパスを index.html で処理
存在しないパスにも index.html を返し、フロントエンドが読み取るパスは /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:3000Compose サービス 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

順に説明します。

  1. sed -i でホスト上の nginx.conf を変更し、grep で変更されたことを確認します。
  2. しかし、コンテナ内の /etc/nginx/conf.d/default.conf は古いままです。sed -i(vim など多くのエディターも同様)は保存時に新しいファイルを作成して古いファイルと置き換えます。単一ファイルのマウントが紐付けているのは元のファイルなので、コンテナから新しいファイルは見えません。この状態では nginx -s reload を実行しても効果がありません。
  3. docker compose restart nginx でコンテナを再起動すると、再マウントされて新しい内容が反映されます。
  4. 末尾のスラッシュがないため、/api/items/7?q=proxy は {"detail":"Not Found"} を返します。
  5. 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 のみにバインドされている場合や、起動中の場合も同じエラーになります。

Nginx 502 Bad Gateway エラーページ
ブラウザーに表示される 502 Bad Gateway エラーページ

バックエンドのコンテナが停止している場合:

$ 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 の調査は次の順で行います。

  1. バックエンドのコンテナが実行中か:docker compose ps
  2. proxy_pass のサービス名とポート番号が正しいか(ports に指定したホスト側ポートではなく、コンテナ内のポートを使います)
  3. バックエンドが 127.0.0.1 ではなく 0.0.0.0 で待ち受けているか
  4. Nginx とバックエンドが同じネットワークに接続されているか。ネットワークとコンテナ間通信を参照してください。
  5. 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日