FastAPI×ReactのDocker本番デプロイ|uv対応Dockerfileとcompose構成
FastAPIとReactを組み合わせたアプリを本番に出す段になると、開発環境で動いていたDockerfileがそのままでは通用しません。イメージが1GBを超える、rootで動いている、Reactのビルド成果物をどこから配信するか決まっていない、といった問題が一度に出てきます。ここでは本番運用を前提に、uvを使ったFastAPIのマルチステージビルド、ViteのビルドをNginxで配信する構成、composeでの束ね方、そしてワーカー数をどこで増やすかまでを、FastAPI・uv・Dockerの各公式ドキュメントの記述に沿って整理します。開発環境でのCORS設定やAPI接続の作り込みはReact×FastAPIをDockerで連携する手順|CORS設定とdocker composeで動かす【2026年版】で扱っているため、本記事は本番側の実装に絞ります。
まとめ
本番構成の要点は次のとおりです。Reactのビルド成果物はNginxコンテナから配信し、FastAPIはホストにポートを公開せずNginx経由で受けます。同一オリジンになるため本番ではCORSミドルウェアが不要になります(同一オリジン化そのものの考え方はReact×FastAPIをDockerで連携する手順|CORS設定とdocker composeで動かす【2026年版】で扱っています)。FastAPI側のイメージはuvで依存レイヤを分離したマルチステージビルドにし、実行ステージは非rootユーザーで起動します。
ワーカー数の扱いには注意が必要です。FastAPI公式は、Kubernetesなどクラスタ側でレプリケーションを扱う場合、コンテナごとに単一プロセスとし--workersでの多重化は避けるべきだと明記しています。単一ホストのcomposeなら--workersで増やし、オーケストレータ配下ならレプリカ数で増やす、という切り分けになります。
Dockerfileで抜けやすいのはfastapi[standard]の指定とUV_PYTHON_DOWNLOADS=0の2点で、どちらも欠けるとコンテナが起動しません。以降で各構成の実装と判断基準を順に見ていきます。
本番構成の選択:Reactの配信先をNginxとFastAPIのどちらに置くか
最初に決めるのは、Reactのビルド成果物を誰が配信するかです。ここを曖昧にしたままDockerfileを書き始めると、CORS設定やパス設計を後から作り直すことになります。選択肢は実質2つで、どちらを選ぶかで必要なコンテナ数もCORSの要否も変わります。
Nginxコンテナで配信する2コンテナ構成
Nginxコンテナが静的ファイルを返し、/api/配下だけをFastAPIコンテナへプロキシする構成です。ブラウザから見るとオリジンはNginxの1つだけになるため、本番ではCORSミドルウェアが不要になります。FastAPI側はホストにポートを公開せず、compose内部ネットワークからのみ到達可能にします。
静的ファイルの配信はNginxが得意とする処理であり、ASGIアプリを経由させる必要がありません。バックエンドを再デプロイしてもフロントのイメージはそのまま使えるため、更新の単位を分けられる点も実務では効いてきます。特別な事情がなければこちらを選びます。避けたほうがよいのは、コンテナ数やサービス数に応じて課金されるPaaSに載せる場合と、Nginxの設定を管理する担当がいない小規模チームの場合です。この2つに当てはまるなら次の単一コンテナ構成のほうが運用コストが下がります。
FastAPIのStaticFilesで配信する単一コンテナ構成
FastAPIのStaticFilesでViteの出力ディレクトリをマウントし、1コンテナで完結させる構成です。コンテナが1つで済むため、検証環境や小規模な社内ツール、コンテナ数に応じて課金されるPaaSでは合理的な選択になります。
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
app = FastAPI()
@app.get("/healthz")
def healthz():
return {"status": "ok"}
# APIルーターを先に登録してから、最後に静的ファイルをマウントする
app.mount("/", StaticFiles(directory="dist", html=True), name="static")
マウントの順序が落とし穴です。StaticFilesをルートにmountすると以降のパスを飲み込むため、APIのルーター登録より後に書かないとエンドポイントが404になります。html=Trueを付けるとディレクトリへのアクセスに対してindex.htmlが返ります。ただしこれはルート直下の話で、/users/1のようなパスを直接開くとStaticFilesは該当ファイルが無いとみなして404を返します。クライアントルーティングを使うなら、404をindex.htmlへフォールバックさせる例外ハンドラを自前で追加する必要があります。Nginxのtry_filesが1行で済ませている処理をアプリ側で書くことになるため、深いパスを多用するアプリでは前者の構成に寄せたほうが確実です。
FastAPIのDockerfile:uvでの依存解決とマルチステージビルド
FastAPI公式ドキュメントは、uvでプロジェクトを管理している場合はuvのDockerガイドに従うよう案内しています。本記事のコード例はuv 0.12.1(2026年7月31日リリース)で確認したものです。pip installを並べる書き方から移行する価値があるのは、依存解決の速さよりもレイヤキャッシュの効き方が変わる点にあります。
依存レイヤ分離によるビルド時間の短縮
uv公式が示す中間レイヤ戦略は、依存関係のインストールとプロジェクト本体のインストールを別のレイヤに割る方法です。--no-install-projectを付けたuv syncを先に実行しておくと、アプリのコードだけを書き換えたビルドで依存関係の再解決が走らなくなります。
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:0.12.1 /uv /uvx /bin/
# 両ステージでベースイメージのPythonを使う
ENV UV_PYTHON_DOWNLOADS=0 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
uvバイナリはCOPY --from=ghcr.io/astral-sh/uv:0.12.1で取り込みます。タグをlatestのままにするとビルドの再現性が失われるため、公式ガイドもバージョン固定の書き方を併記しています。UV_COMPILE_BYTECODE=1はバイトコードを事前生成して起動時間を短縮する設定、UV_LINK_MODE=copyはキャッシュマウント使用時の警告を抑える設定です。--lockedはロックファイルとの不一致があればビルドを失敗させるので、本番用イメージでは付けておきます。
UV_PYTHON_DOWNLOADS=0は見落としやすい指定です。uvは既定で管理下のPythonを使おうとするため、pyproject.tomlのrequires-pythonがベースイメージのバージョンと合わないと、uvが独自にPythonをダウンロードして仮想環境をそれに紐付けます。その実体は/appの外に置かれ、次のステージへコピーされないため、実行時にインタプリタが見つからず起動に失敗します。uv公式のマルチステージ例も「両ステージでシステムPythonを使う」というコメント付きでこの1行を入れています。
あわせて.dockerignoreに.venvと.gitを入れておいてください。これが無いとCOPY . /appでホスト側の仮想環境を取り込み、直前に作ったコンテナ内の/app/.venvを上書きして壊します。
実行ステージの最小化と非rootでの起動
ビルド用ステージをそのまま出荷すると、uvバイナリやビルドキャッシュを抱えたイメージになります。実行ステージを分け、仮想環境とアプリケーションコードだけを持ち込みます。ベースイメージはビルドステージと同じタグを指定してください(ここでは主要ライブラリのwheel提供が揃っているpython:3.13-slimを使っています)。ステージ間でマイナーバージョンがずれると、コピーした仮想環境のsite-packagesのパスが実行側のインタプリタと合わず読み込めません。
FROM python:3.13-slim
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder --chown=appuser:appuser /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
EXPOSE 8000
CMD ["fastapi", "run", "app/main.py", "--port", "8000", "--proxy-headers"]
ENV PATH="/app/.venv/bin:$PATH"は仮想環境を有効化する代わりの記述で、uv公式が示している方法です。USER appuserを省くとコンテナ内のプロセスがrootで動き、コンテナ脱出を伴う脆弱性が出たときの影響範囲が広がります。
fastapi runを使う前提なら、依存にfastapiではなくfastapi[standard]を指定しておく必要があります。fastapiコマンドの実体であるfastapi-cliはstandardエクストラ側にしか含まれておらず(fastapi 0.141.1のメタデータで確認)、素のfastapiだけを入れたイメージはコンテナ起動時にコマンドが見つからず即座に終了します。
CMDは配列形式で書きます。FastAPI公式は「常にexec形式を使用してください」と明記しており、シェル形式ではシグナルがアプリケーションプロセスに届かず、停止時にグレースフルシャットダウンが働かなくなります。--proxy-headersは、NginxやTraefikのようなTLS終端プロキシの背後で動かす場合に必要です。これを付けないとアプリ側が自分をHTTPだと認識し、リダイレクト先URLのスキームが崩れます。fastapi runの実体であるASGIサーバーの挙動はUvicornとは?FastAPIを動かすPython製ASGIサーバーの基礎から本番デプロイまでで解説しています。命令ごとの書き方の基礎はDockerfileとは|書き方・主要命令・ベストプラクティスを解説【2026年版】を参照してください。
ReactのDockerfile:Viteビルド成果物のNginx配信
フロント側はNode.jsでビルドし、成果物だけをNginxイメージへ移します。ビルドツールもnode_modulesも最終イメージには不要です。Viteの出力先は既定でdistです。
# syntax=docker/dockerfile:1
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
npm installではなくnpm ciを使う点が本番ビルドでは重要です。npm ciはロックファイルどおりに厳密インストールし、不一致があれば失敗するため、ビルドのたびに依存バージョンが動くことがありません。
Nginx側の設定では、SPAのフォールバックとAPIのプロキシを定義します。
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://api:8000/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
try_files $uri $uri/ /index.html;が抜けていると、React Routerで作った/users/1のようなURLを直接開いたときやリロード時にNginxが404を返します。SPAをNginxで配信するときの定番の事故がこれです。proxy_passの宛先apiはcomposeのサービス名で、Docker内部DNSが解決します。X-Forwarded-Protoを渡しておくことで、前節の--proxy-headersが意味を持ちます。
もう一つ、proxy_passの末尾スラッシュの有無で転送先パスが変わる点に注意してください。上の例のようにproxy_pass http://api:8000/;と末尾にスラッシュを付けると、locationで一致した/api/の部分が/に置き換わります。ブラウザが/api/usersを叩くとFastAPIには/usersが届くため、ルーターは@app.get("/users")のように/apiを含めずに定義します。逆にFastAPI側を/api/usersで定義したい場合は、proxy_pass http://api:8000;と末尾スラッシュを外してパスをそのまま渡します。この不一致が、Nginxを挟んだ途端に全APIが404になる典型的な原因です。
docker composeでの本番構成の組み立て
2つのイメージをcomposeで組み合わせます。開発用composeとの違いは、ソースコードのバインドマウントとホットリロードを外すこと、そしてAPIをホストに公開しないことです。
services:
api:
build: ./backend
expose:
- "8000"
environment:
APP_ENV: production
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz', timeout=2)"]
interval: 10s
timeout: 3s
retries: 3
start_period: 20s
restart: unless-stopped
web:
build: ./frontend
ports:
- "80:80"
depends_on:
api:
condition: service_healthy
restart: unless-stopped
APIにはportsを書きません。portsで公開するとホストの8000番から直接叩けてしまい、Nginxを経由する前提の同一オリジン構成が崩れます。上の例のexposeはコンテナ間で使う内部ポートを明示するための宣言で、これ自体が外部からのアクセスを遮断するわけではありません。遮断しているのはあくまでportsを書いていないことです。
depends_onを短い記法で書くと、依存先のコンテナが起動しただけで次のサービスが立ち上がります。アプリケーションがまだリクエストを受けられない状態でNginxが起動するため、長い記法でcondition: service_healthyを指定します。Docker公式が定義しているconditionの値はservice_started、service_healthy、service_completed_successfullyの3種類で、service_healthyは依存先のhealthcheckが成功するまで起動を待たせます。start_periodは起動直後の猶予時間で、この間の失敗はリトライ回数に数えられません。
| 項目 | 開発用compose | 本番用compose |
|---|---|---|
| ソース反映 | bind mount + reload | イメージに同梱 |
| APIの公開 | ports 8000:8000 | expose のみ |
| フロント配信 | vite dev server | Nginx + dist |
| CORS | 必要 | 不要(同一オリジン) |
| 起動順制御 | 省略可 | service_healthy |
なお、composeファイルの先頭にversion: "3.8"を書く必要はありません。Docker公式は最上位のversion属性について「後方互換性のために定義されており、情報提供のみで、使用すると廃止(obsolete)である旨の警告メッセージが表示される」と記述しています。実際の検証はversionの値に関わらず最新スキーマで行われます。古い記事のコピーで残っているだけなので削除して構いません。
ワーカー数はどこで増やすか:プロセス多重化とレプリケーションの使い分け
ここは判断を間違えている構成が多い箇所です。結論から書くと、単一ホストのcompose運用なら--workersでコンテナ内のプロセスを増やし、KubernetesやECSのようにオーケストレータがレプリカを管理する環境では--workersを使わず1コンテナ1プロセスにします。両方で多重化すると、実際のプロセス数がレプリカ数×ワーカー数になり、メモリ見積もりもオートスケールの判断も狂います。
FastAPI公式は後者について「すでにクラスタ・レベルでレプリケーションを処理しているため、おそらくコンテナごとに単一の(Uvicorn)プロセスを持ちたいでしょう」「コンテナ内に複数のワーカーを持つことは避けたいでしょう」と明記しています。単一ホストの場合は逆で、公式のDockerfile例もCMD ["fastapi", "run", "app/main.py", "--port", "80", "--workers", "4"]という形でワーカー数の指定を示しています。
あわせて押さえておきたいのが、かつての公式イメージtiangolo/uvicorn-gunicorn-fastapiの扱いです。FastAPI公式は「現在は非推奨です」と明示しており、理由として、このイメージはUvicornが停止したワーカーの管理と再起動をサポートしていなかった時期にGunicornを併用するために作られたものであり、現在はUvicornおよびfastapiコマンドが--workersをサポートしているため、自前でビルドせずベースイメージを使う理由がない、と説明しています。このイメージのDocker Hubページと解説記事は現在も残っているため、検索から辿り着いてそのまま採用しないよう注意してください。GunicornとUvicornを組み合わせる設定を解説した記事も同じ理由で古い情報です。
クラウドへ載せ替えるときの確認項目
composeで固めた構成をマネージドサービスへ移す際は、composeで暗黙に成立していた前提が外れます。移行前に次を確認します。
- サービス名によるDNS解決:
proxy_pass http://api:8000/のようなサービス名参照は、移行先のサービスディスカバリ方式(同一タスク定義内のlocalhost、内部ロードバランサのDNS名など)に置き換えが必要です - ヘルスチェックのパス:
/healthzをロードバランサ側にも登録します。ただし本記事のNginx設定は/api/配下しかプロキシしないため、Nginx経由の/healthzはtry_filesでSPAのindex.htmlを返し、APIが停止していても200になります。API用のヘルスチェックはAPIコンテナへ直接向けるか、/api/healthzのようにプロキシ対象のパス配下へ寄せてください - ワーカー数:前節のとおり、レプリカ管理をサービス側に任せるなら
--workers指定を外します - TLS終端の位置:ロードバランサでTLSを終端する場合、
--proxy-headersとX-Forwarded-Protoの受け渡しが繋がっているか確認します - APIドキュメントの公開範囲:
/docsを外部に晒したままにしない設定はFastAPIのSwagger UI(/docs)自動生成|表示・カスタマイズ・本番で無効化する手順で扱っています
移行先で最初に壊れるのはサービス名によるDNS解決とヘルスチェックのパスなので、この2点はcompose上で先に固定してから載せ替えます。コンテナが起動しない、すぐ終了するといった段階で詰まる場合は、Dockerコンテナ起動コマンドの使い分け|run・start・execと起動しない時の対処で切り分け手順を確認できます。
よくある質問
tiangolo/uvicorn-gunicorn-fastapi は今も使えますか?
使わないでください。FastAPI公式が「現在は非推奨です」と明記しています。Uvicornとfastapiコマンドが--workersをサポートした結果、Gunicornを噛ませる構成そのものが不要になったためです。本記事のようにpython:3.13-slimをベースに自前でDockerfileを書く方法が現在の標準です。
compose.yaml に version: は書く必要がありますか?
不要です。Docker公式は最上位のversion属性を「後方互換性のために定義されており、情報提供のみ」とし、記述すると廃止である旨の警告が出ると説明しています。値に関係なく最新スキーマで検証されるため、書いても挙動は変わりません。
本番では fastapi run と uvicorn コマンドのどちらを使いますか?
FastAPI公式のDockerfile例はfastapi runを使っています。fastapi runは内部でUvicornを本番設定で起動するラッパーで、--workersや--proxy-headersといったオプションもそのまま指定できます。Uvicorn固有の細かなオプションを直接渡したい場合のみuvicornコマンドを使う、という切り分けで問題ありません。
ReactとFastAPIを1つのコンテナにまとめても問題ありませんか?
検証環境や小規模な社内ツールであれば実用上の問題はありません。FastAPIのStaticFilesでViteのdistを配信する構成になります。ただしフロントだけを更新したい場合もバックエンドのイメージごと作り直すことになり、静的ファイルの配信もASGIアプリを経由します。更新頻度が分かれるチームや、フロントの配信量が多いサービスではNginxとの2コンテナ構成を選んでください。
--workers はいくつに設定すればよいですか?
まず、オーケストレータでレプリカを管理しているなら設定しません。単一ホストのcompose運用では、そのホストで使えるCPUコア数を上限の目安にします。ワーカー1つにつきアプリケーションのメモリが丸ごと乗るため、コンテナのメモリ上限とアプリ1プロセスあたりの実測使用量から先に上限を出し、コア数と比べて小さいほうを採用するのが安全です。