ブログに戻る
8月 19, 2026ガイド

VPS上でGunicornとNginxを使ってPythonアプリを動かす方法

Ubuntu上でFlaskまたはFastAPIアプリを、virtualenv、Gunicornのsystemdサービス、Nginxリバースプロキシ、TLS、環境ファイル、ログ、502エラーとワーカー規模のチェックリスト付きでデプロイします。

VPS上でGunicornとNginxを使ってPythonアプリを動かす方法

組み込みのFlaskサーバーと uvicorn --reload は開発用です。公開VPSでは、ワーカーを再起動し、localhostにバインドし、TLSと遅いクライアントを扱うリバースプロキシの後ろに座るプロセスマネージャが欲しいです。GunicornはFlaskとDjango向けの定番WSGIです。FastAPIはUvicornワーカークラス付きでGunicornの下でも動かせます。Nginx(またはCaddy)がHTTPSを終端し、127.0.0.1:8000 へ転送します。

このガイドは再起動後も残る配置を歩きます。プロジェクトは /srv/app、virtualenv、秘密入りの.env、gunicorn.serviceユニット、Nginxのserverブロック、Let's Encrypt、実際にgrepできるログファイルです。ワーカー数、タイムアウト、なぜ502 Bad Gatewayがほとんど『Nginxが壊れた』ではなく、ほとんど常にGunicornが動いていない、間違ったソケットにバインド、またはimportでクラッシュなのかも話します。例はFlaskで、コマンドが違うところではFastAPIの注記を付けます。

このスタックを使う理由

Gunicornはワーカープロセスをpreforkします。別のワーカークラスを使わない限り、各ワーカーは一度に1リクエストです。Nginxは遅いクライアントをバッファし、ワーカーがモバイル網へバイトを送り続けて詰まらないようにします。systemdは死んだらアプリを再起動します。合わせると退屈です。午前3時に欲しいものです。

  • Gunicorn: 安定したWSGI/ASGIプロセスモデル
  • systemd: 起動時に開始、クラッシュ時に再起動、journaldログ
  • Nginx: TLS、静的ファイル、リクエストサイズ制限、gzip
  • venv: システムのPythonをきれいに保つ
  • localhostバインド: アプリはNginx経由以外では届かない

要件

Ubuntu 22.04/24.04のPython 3.10+で問題ありません。システムのsite-packagesへrootでpipしないでください。TLSにはドメインが必要です。プロキシにCaddyを好むなら、この記事のGunicornユニットはそのままです。変わるのはフロントの設定だけです(Caddyの記事を参照)。

  • Ubuntu 22.04または24.04 VPS
  • requirements.txtまたは同等があるアプリケーション
  • WSGIエントリポイント(Flaskなら app:app)またはASGI(FastAPIならuvicornワーカー付き app:app)
  • HTTPS用のドメインAレコード

ステップ1:システムパッケージ、ユーザー、プロジェクトディレクトリ

対話ログインできないシステムユーザーを作り、コードを所有させ、Gunicornを動かします。python3-venvとビルドツールを入れると、まだC拡張をコンパイルするパッケージでのpip失敗を避けられます。

bash
ssh root@YOUR_VPS_IP
apt update && apt -y upgrade
apt -y install python3 python3-venv python3-pip python3-dev build-essential nginx curl

adduser --system --group --home /srv/app appuser
mkdir -p /srv/app
chown appuser:appuser /srv/app

# Copy your code (example):
# rsync -a --delete ./myproject/ appuser@YOUR_VPS_IP:/srv/app/

ステップ2:virtualenvと依存関係

ファイル所有が正しくなるよう、appuserとしてvenvを作ります。本番ではバージョンをピン留めします。インストール後、一度限りの gunicorn --check-config またはPythonのimportでアプリを取り込めることを確認します。ここでのimportエラーは、あとで502になる同じエラーです。

bash
sudo -u appuser -H bash -lc '
cd /srv/app
python3 -m venv /srv/app/venv
/srv/app/venv/bin/pip install --upgrade pip
/srv/app/venv/bin/pip install -r /srv/app/requirements.txt gunicorn
'

# Flask example check:
sudo -u appuser -H /srv/app/venv/bin/python -c "from app import app; print('import ok')"

ステップ3:環境ファイル

ワールド読み取り可能なgitに載る形で、SECRET_KEYやデータベースURLをsystemdユニットにハードコードしないでください。EnvironmentFileを使います。chmod 640、所有者root、グループappuser(または好みで所有者appuser)。

bash
cat >/srv/app/.env <<'EOF'
FLASK_ENV=production
SECRET_KEY=replace-with-openssl-rand-hex-32
DATABASE_URL=postgresql://app:password@127.0.0.1:5432/app
EOF
chown appuser:appuser /srv/app/.env
chmod 600 /srv/app/.env

ステップ4:Gunicornのsystemdサービス

Nginxを飛ばす理由がない限り、0.0.0.0 ではなく 127.0.0.1:8000 にバインドします。ワーカー数はsyncワーカーならよく (2 x CPU) + 1 です。2 vCPUのVPSなら5ですが、各ワーカーが重いMLモデルを載せるなら多すぎることがあり、そのときは2〜3です。FastAPIでは --worker-class uvicorn.workers.UvicornWorker を設定し、uvicornを入れます。タイムアウトは最遅の誠実なリクエストより長くします。2分のエクスポートがあるのに30秒にはしません。

bash
cat >/etc/systemd/system/gunicorn.service <<'EOF'
[Unit]
Description=Gunicorn for the web app
After=network.target

[Service]
User=appuser
Group=appuser
WorkingDirectory=/srv/app
EnvironmentFile=/srv/app/.env
ExecStart=/srv/app/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 --timeout 60 --access-logfile - --error-logfile - app:app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable --now gunicorn
systemctl status gunicorn --no-pager
ss -tulpn | grep 8000

ステップ5:NginxリバースプロキシとTLS

Nginxは80/443で待ち、Gunicornへプロキシします。アップロードではclient_max_body_sizeが効きます。proxy_read_timeoutはGunicornのタイムアウト以上に揃えます。serverブロックがHTTPで動いたら、Certbotで証明書を出すか、フロントをCaddyに切り替えます。以下のスニペットはテスト用にHTTPのみです。その後、ファイルを編集できるCertbotを実行します。

bash
cat >/etc/nginx/sites-available/app <<'EOF'
server {
    listen 80;
    server_name app.example.com;
    client_max_body_size 20m;

    location /static/ {
        alias /srv/app/static/;
    }

    location / {
        proxy_pass http://127.0.0.1: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;
        proxy_read_timeout 90s;
    }
}
EOF

ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
nginx -t && systemctl reload nginx

ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable

apt -y install certbot python3-certbot-nginx
certbot --nginx -d app.example.com

ステップ6:静的ファイル、権限、Flask/FastAPI固有の点

できるなら静的ファイルはNginxが出す方が、Gunicornより速いです。Djangoのcollectstatic、ごく小さなアプリのFlask send_from_directory、または後のCDNです。appuserはツリーを読める必要があります。FastAPIならvenvにuvicorn[standard]を入れ、ExecStartをUvicornWorkerに変えます。TCPではなくUnixソケットを使うなら、proxy_passをソケットへ向け、www-dataが書けるよう権限を揃えます。

bash
# FastAPI ExecStart example:
# ExecStart=/srv/app/venv/bin/gunicorn -k uvicorn.workers.UvicornWorker --workers 2 --bind 127.0.0.1:8000 app:app

# Unix socket variant:
# --bind unix:/run/gunicorn/app.sock
# Nginx: proxy_pass http://unix:/run/gunicorn/app.sock:

chown -R appuser:appuser /srv/app

ワーカー、メモリ、無停止リロード

同期Gunicornワーカーは単純で、CPUの軽いリクエスト/レスポンスAPIには十分です。遅いI/O待ちが多数なら、geventやASGIワーカーを検討します。推測せず測ります。各ワーカーがアプリを載せるので、RAM ≒ ワーカー数 × アプリRSSです。2 GBのVPSに300 MBアプリのワーカー8つはスワップし、『ランダムに遅い』と感じます。その場でファイルをデプロイしたなら systemctl reload gunicorn(HUP)で新コードのワーカーを再起動できます。依存関係が変わったときはフル再起動の方が明確です。

bash
# After git pull / rsync of new code:
sudo -u appuser -H /srv/app/venv/bin/pip install -r /srv/app/requirements.txt
systemctl restart gunicorn
curl -I https://app.example.com/health

502と静かなクラッシュのトラブルシューティング

502は、Nginxがアップストリームから有効な応答を得られなかったという意味です。最初のコマンドは journalctl -u gunicorn -e です。よくある原因は、間違った module:app 名、欠けた.envキー、Postgres未起動、127.0.0.1にバインドしているのにNginxが別ホスト、SELinux(Ubuntuでは稀)、アプリがIPv6だけ待っている、です。504はタイムアウトです。301ループは、X-Forwarded-Protoが無視されアプリがHTTPへリダイレクトするときに起きます。

  • systemctl status gunicorn — activeか?
  • journalctl -u gunicorn -n 100 — ImportError、欠けたenv、データベース
  • VPSから curl -v http://127.0.0.1:8000/ — これが失敗ならNginxは無実
  • nginx -t と error.log — upstream prematurely closed connection
  • ss -tulpn | grep 8000 — 誰も待っていない
  • ディスク満杯 — ワーカーが不思議な死に方をする

セキュリティ

アプリは公開バインドしません。秘密は.envに残します。venvとOSはパッチします。Gunicornをrootで動かさないでください。ログインを扱うなら、セッションクッキーにSecureとSameSiteを付け、フレームワークがNginxからのX-Forwarded-Protoだけを信頼するよう設定します。ログイン経路はNginxまたはアプリでレート制限します。

  • 127.0.0.1 またはunixソケットにバインド
  • chmod 600 .env
  • systemdのUser=は非root
  • TLSはCertbotまたはCaddy経由
  • 本番ではdebugモードと自動リロードを無効化

ヒント

  • Composeとロードバランサ向けに、DB接続を確認する /health ルートを追加
  • Nginxで静的アセットにcache-controlヘッダーを付ける
  • メールと重いジョブは別ワーカーまたはキュー(Redis + systemd)
  • requirements.txtでgunicornとuvicornの版をピン留め
  • 最初の本番切り替え前にスナップショットを取る

VPS上のPythonアプリは、Gunicornがsystemdサービスとして動き、localhostだけにバインドし、TLS付きのNginxまたはCaddy経由で届くときに本番準備が整います。秘密はEnvironmentFileに置き、ワーカーはブログの公式ではなくRAMに合わせ、502はまずGunicornのjournalから切り分けます。この経路がリポジトリに文書化されれば、以降のデプロイは毎回 rsync または git pull、pip install、systemctl restart gunicorn です。