VPS上でDocker Composeを使ってアプリケーションをデプロイする方法
Ubuntu上のDocker Compose v2向けの実務的な本番ガイド。エンジンのインストール、ネットワークとボリューム付きのComposeファイル、シークレットの扱い、手前のリバースプロキシ、慌てない更新、名前付きボリュームのバックアップ。

Docker Composeは、1台のVPSで小さなスタックを動かす定番です。Webアプリ、データベース、Redisキャッシュ、リバースプロキシを、1つのYAMLファイルで記述します。Kubernetesではありません。マルチノードのフェイルオーバーは得られません。得られるのは、新しいHiddence VPS上で数分で再現できる、レビュー可能な繰り返し可能な構成です。サイドプロジェクトと小さな製品の多くがまさにそれを必要としています。
この記事は、金曜の夜の docker run より本番に近いものを想定しています。Docker EngineとComposeプラグインを入れ、非rootのデプロイユーザーを作り、ヘルスチェックと再起動ポリシー付きのComposeファイルを書き、シークレットをGitの外に保ち、インターネットへ公開するのはリバースプロキシのポートだけにし、更新とバックアップの手順を定義します。例のスタックは典型的なWebアプリ + PostgreSQL + Caddyですが、同じ型はNode、PHP、Python、ワーカー群にも使えます。
単一VPS上のComposeが今でも意味を持つ理由
流行だからとKubernetesに飛び、週末を1サイトのYAMLに費やす人がいます。Composeは読みやすいです。ファイルを版管理し、環境変数を文書化し、適用前に差分を見られます。分離は十分です。ネットワークと最小権限ユーザーを使えば、侵害されたアプリコンテナがデータベースのUNIXソケットを持つべきではありません。リソース制限は、1つのメモリリークがVPS全体を凍らせるのを止めます。
- 1ファイルでスタック全体を記述
- 名前付きボリュームはコンテナ再作成後も残る
- 内部DockerネットワークがPostgresを公衆インターネットから外す
- restart: unless-stopped がほとんどの再起動をカバー
- 最初のVPSが手狭になったら、2台目へコピーしやすい
要件
アプリとPostgres分のRAMがあるVPSを使います。アプリ + データベース + プロキシなら2 GBプランが現実的な下限です。Ubuntu 24.04を前提とします。cgroupsへの影響を読まずに無作為のSnapからDockerを入れないでください。以下はDocker公式のaptリポジトリを使います。
- Ubuntu 22.04/24.04、rootまたはsudo
- RAM 2 GB推奨(Postgresなしのごく小さなスタックなら1 GB)
- 自動HTTPSが欲しいならVPSを指すドメイン
- サーバーへプロジェクトをコピーするGitなどの手段
ステップ1:Docker EngineとCompose v2をインストールする
Compose v2はプラグインで、docker compose(スペースあり)として呼び出します。古いdocker-composeのPythonバイナリではありません。最新のセキュリティパッチを得るため、どちらもDockerのリポジトリから入れます。
ssh root@YOUR_VPS_IP
apt update && apt -y install ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker version
docker compose versionステップ2:デプロイユーザーとディレクトリ構成
rootでComposeを動かすことはできますが、dockerグループに入った専用ユーザーの方がきれいです。プロジェクトは /root ではなく /opt または /srv に置き、バックアップと権限が分かりやすくします。.envを含むディレクトリをワールド書き込みにしないでください。
adduser --disabled-password --gecos '' deploy
usermod -aG docker deploy
mkdir -p /srv/app
chown deploy:deploy /srv/app
chmod 750 /srv/app
# Log in as deploy for the rest of the file editing:
# su - deploy
# cd /srv/appステップ3:本番を意識したComposeファイルを書く
以下はテンプレートです。アプリアメージを自分のものに置き換えます。Postgresは 0.0.0.0:5432 に公開しません。公開するのはCaddyの80と443だけです。ヘルスチェックは、まだデータベース移行中のアプリへリバースプロキシがトラフィックを送るのを止めます。イメージタグはピン留めします。latestは想定外の破壊の典型です。
# /srv/app/compose.yaml
services:
caddy:
image: caddy:2.8-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
app:
condition: service_healthy
app:
image: ghcr.io/example/webapp:1.4.2
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 10
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
env_file: .env
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
networks:
- backend
networks:
frontend: {}
backend: {}
volumes:
pgdata:
caddy_data:
caddy_config:ステップ4:シークレット、.env、Caddyfile
パスワードはサーバー上の.envに置き、chmod 600し、そのファイルをコミットしないでください。パスワードは openssl rand -base64 32 で生成します。Caddyfileは、Dockerネットワーク上のアプリのサービス名へリバースプロキシするだけで十分です。
# /srv/app/.env (permissions 600)
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-long-random
POSTGRES_DB=app
# plus any APP_SECRET / NEXTAUTH_SECRET your image needs
# /srv/app/Caddyfile
app.example.com {
encode gzip
reverse_proxy app:8000
}
chmod 600 /srv/app/.env
cd /srv/app
docker compose up -d
docker compose ps
docker compose logs -f --tail=100ステップ5:ファイアウォール、ログ、リソース制限
UFWは22、80、443だけ許可すべきです。Dockerは公開ポートでUFWを迂回することがあります。ホストのファイアウォールを厳密にしたいなら、自分のUbuntu版でのDocker + UFWの相互作用を調べるか、ポートを 127.0.0.1 だけに公開してホストのCaddyを手前に置きます。Composeでメモリ制限を付け、PostgresがVPSを食い尽くさないようにします。
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Optional in compose.yaml under app or db:
# deploy:
# resources:
# limits:
# memory: 512M
# Logs (do not let json-file grow forever):
# { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
# in /etc/docker/daemon.json then: systemctl restart dockerステップ6:更新とロールバック
うまくいく地味な更新は、新しいイメージをpullし、up -dし、ヘルスチェックを見守り、前のタグをgitに残してピン留めし直せるようにすることです。本番データベースでトラフィックを止めるつもりがない限り、docker compose down しないでください。down -v はボリュームを消します。再起動ではなくワイプです。
cd /srv/app
git pull # if the Compose file lives in git
# Change the image tag in compose.yaml, then:
docker compose pull
docker compose up -d
docker compose ps
# Rollback: set the old tag, pull, up -d again
# NEVER: docker compose down -v # this deletes named volumes名前付きボリュームをバックアップする
VPSスナップショットは良いです。別マシンへ復元する必要があるなら、論理的なPostgresダンプの方が良いです。cronでスケジュールします。復元を一度試さないなら、バックアップはなく、バックアップだと思いたいファイルがあるだけです。
# Postgres dump while the db container is running:
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gz
# Copy off-box
# scp /var/backups/app-*.sql.gz backup-host:~
# Restore sketch (maintenance window):
# gunzip -c app-2026-08-19.sql.gz | docker compose exec -T db psql -U app appトラブルシューティング
docker compose ps と docker compose logs service を読みます。イメージpullの失敗はたいていghcr/Docker Hubのレート制限か、ログインなしのプライベートイメージです。Caddyからの502は、アプリが健全でないか、プロキシのホスト名が間違いです(Caddyコンテナ内部からはlocalhostではなくComposeのサービス名)。ボリュームの権限エラーは、イメージがuid 1000で動きホストディレクトリがroot、ということが多いです。
- compose: command not found — 古いバイナリ名を入れた。docker compose を使う
- port is already allocated — 80/443を他が占有(Apache、Nginx、別のCaddy)
- database connection refused — アプリがPostgres準備前に起動。healthcheck + depends_on のconditionを使う
- ディスク満杯 — docker system df、未使用イメージは慎重にprune
- docker.sockでpermission denied — ユーザーがdockerグループにいない、または再ログインが必要
セキュリティ
データベースポートを公開しない。privileged: true を使わない。コンテナマネージャを意図して書いていない限り、アプリコンテナに /var/run/docker.sock をマウントしない。エンジンは更新する。自分でビルドするならイメージをスキャンする。ディスク上の.envはまだ秘密ファイルです。その箱へSSHできる人を制限します。
- 公開の5432 / 3306 / 6379はなし
- イメージダイジェスト、少なくとも不変タグをピン留め
- chmod 600 .env
- ホストOSのunattended-upgradesはまだ重要
- アプリごとに1つのComposeプロジェクトで影響範囲を小さく
ヒント
- compose.yamlはgitへ。.env.exampleには本物の秘密を入れない
- 任意ワーカーはprofiles(docker compose --profile workers up -d)
- VPSのサイズ決めでは docker stats を見る
- ソースコードのバインドマウントは本番の型ではない — イメージに焼く
- スタックが複数VPSに広がってからオーケストレーションを見る。その前ではない
1台のVPS上の本番寄りのCompose構成は、公式Dockerパッケージ、締められた.env、データベースを公開しないYAML、ヘルスチェック、80/443のリバースプロキシ、UFW、ロールバックできるイメージタグ、実際に一度復元したPostgresダンプです。この記事のテンプレートから始め、アプリアメージを置き換え、docker compose down -v は rm -rf と同じ敬意で破壊的コマンドとして扱ってください。