Ubuntu 24.04でDocker Compose v2をインストールする方法
Ubuntu 24.04 VPSにDocker EngineとCompose v2を導入する手順を解説します。古いCompose v1との違いや、ufwによるポート公開の失敗を防ぐ設定、PostgreSQLを含む2つのservice構成、データのバックアップ方法まで、実用的な構成を網羅しています。
学習内容
Docker Compose は、当サイトのほぼすべての基盤となっています。Nextcloud、Vaultwarden、n8n、Immich、Rocket.Chat。これらのガイドはすべて「この compose file を作成してください」という指示から始まります。このページでは、その file の具体的な意味を解説します。Ubuntu 24.04 上で、Docker 自身の apt repository から Docker Engine と Compose v2 plugin をインストールします。その後、2 つの service で構成される stack(RSS reader の Miniflux と PostgreSQL)を構築します。この組み合わせは、大規模なアプリで使用される以下のパターンをすべて網羅しています:pinned images、healthcheck 付きの database、named volume、.env file 内の secrets、および localhost のみに公開された port です。
インストールには 5 分かかります。本ガイドの後半では、後々問題となる要素を扱います。root と同等の権限を持つ docker group、ufw rules をバイパスする published ports、そして確認なしに database を削除する docker compose down の flag です。
前提条件:新規の Ubuntu 24.04 KVM VPS、sudo 権限を持つ user、および 1 GB 以上の RAM。既存の Docker がインストールされていても問題ありません。最初のセクションで削除すべき内容を説明します。
Ubuntuのrepoではなく、Dockerのrepoからインストールする
最初のコマンドを実行する前に、2つの誤った選択肢を避けてください。Ubuntu独自の docker.io パッケージは動作しますが、Dockerのリリースより更新が遅れており、他のツールが想定するpluginの構成になっていません。また、ハイフンを含むスタンドアロンの docker-compose バイナリはCompose v1です。これはPythonベースで、2023年にサポートが終了しており、古いチュートリアルが機能しない原因となっています。現在のComposeはスペースを含む docker compose であり、CLI pluginとして、engineと同じrepositoryからインストールされます。
すでにシステムにこれらがインストールされている場合は、まず削除してください。これには、すべてのコンポーネントを単一のrepositoryから取得するために、Ubuntu独自のpluginパッケージである docker-compose-v2 も含みます。
sudo apt remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc新規のVPSでの標準的な出力は Package 'docker.io' is not installed, so not removed です。次に、Dockerのrepositoryを追加してインストールします。
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo 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" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin以下の3つのレイヤーを確認してください。
docker --version
docker compose version
sudo docker run --rm hello-world最初の2つはversion文字列を表示します。Docker Compose version v2.x.x は、サポート終了したv1バイナリではなく、pluginが正しくインストールされていることを確認するために使用します。hello-world を実行した結果は Hello from Docker! で終わる必要があります。このパッケージは起動時にserviceを有効にします。systemctl is-enabled docker を実行すると enabled と表示されます。
docker group は root です — リスクを理解して判断してください
現在、docker コマンドを実行するには sudo が必要です。/var/run/docker.sock にある daemon の socket の所有者が root および docker グループだからです。グループに所属していない場合、最も頻繁に検索される Docker のエラーが発生します。
permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock標準的な解決策は以下の通りです。
sudo usermod -aG docker $USERグループへの所属はログイン時に適用されるため、現在の shell ではエラーが解消されません。このセッションに適用するには newgrp docker を実行するか、ログアウトして再度ログインしてください。その後、id を実行して docker がグループに含まれていることを確認してください。
ここで重要な事実を述べます。docker グループの所有者はホスト上の root です。 「root に近い権限」でも「昇格した権限」でもなく、root です。そのグループのメンバーであれば、パスワードなしで docker run --rm -it -v /:/host alpine chroot /host を実行し、ファイルシステム全体を制御できます。このグループは利便性のために存在しており、セキュリティの隔離(containment)を目的としたものではありません。
Docker の rootless mode が真の代替手段です。これを使用すると、daemon 自体が権限のないユーザーとして動作します。ただし、以下の制約があります。1024 未満のポートには追加の設定が必要です。ネットワークは userspace shim を経由するため、オーバーヘッドが発生します。また、一部の image は real root がないと正常に動作しません。唯一のログインユーザーがすでに sudo 権限を持つ単一管理者の VPS では、グループ設定による実質的な変化はありません。本チュートリアルのすべてのガイドはこの前提に基づいています。ただし、docker グループの権限を sudo よりも低いものとして扱わないよう注意してください。
Anatomy of a compose file
各stackに専用のディレクトリを作成してください。ディレクトリ名がプロジェクト名となり、container、network、volumeの接頭辞として使用されます。
sudo mkdir -p /opt/miniflux && sudo chown $USER /opt/miniflux && cd /opt/minifluxcompose.ymlを作成してください(現在の推奨名称です。docker-compose.ymlも引き続き使用可能です)。古いversion:キーは使用しないでください。これは廃止されており、使用しているとComposeが警告を出します。
services:
miniflux:
image: miniflux/miniflux:2.2.9
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
- DATABASE_URL=postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
- RUN_MIGRATIONS=1
- CREATE_ADMIN=1
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=miniflux
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=miniflux
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux", "-d", "miniflux"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:上記の各行は決定事項です。一つずつ適用してください。
Pin image versions — :latest plus a pull is an unattended upgrade
postgres:latestではなくpostgres:16-alpineを使用してください。タグは固定されていません。:latestは、pullを実行するたびに、メンテナが最後にpushした内容に再解決されます。これを、次に学ぶ定期的なアップグレードの習慣であるdocker compose pull && docker compose up -dと組み合わせると、:latestは、アップストリームがリリースしたタイミングでメジャーバージョンが自動的にジャンプすることを意味します。PostgreSQLにおいてこれは仮説ではありません。16から17への予期せぬジャンプが発生すると、containerは互換性のないdata directoryのためにcrash-loopします。Postgresのメジャーアップグレードには、restartではなくdumpとrestoreが必要だからです。
少なくともメジャーバージョンを固定してください(postgres:16-alpineは16.xのパッチリリースを指します)。また、アプリケーションはminiflux/miniflux:2.2.9のような正確なリリースに固定してください。ファイルを作成する際は、プロジェクトのreleasesページを確認し、最新のバージョンを使用してください。これにより、アップグレードはgit diffで確認できる、意図的な1行の編集作業になります。
Publish to 127.0.0.1, because Docker walks around ufw
"127.0.0.1:8080:8080" — host address, host port, container port。多くのチュートリアルでは"8080:8080"と書かれていますが、これは0.0.0.0:8080:8080の短縮形です。つまり、パブリックインターフェースを含むすべてのインターフェースでlistenすることを意味します。
ここに落とし穴があります。Dockerは、フィルタリングが行われる「前」に、パケットの宛先をcontainerの内部IPに書き換えるDNATルールを書き込むことでポートを公開します。そのため、パケットはFORWARDのパスを通り、INPUT(ufwのルールが適用される場所)には到達しません。sudo ufw deny 8080は成功を報告し、ufw statusはポートが拒否されていることを示しますが、サービスは依然としてインターネット全体からの接続に応答します。ファイアウォールが壊れているのではなく、設計上バイパスされているのです。Why Docker bypasses ufw, and how to filter container traffic for realでは、この仕組みと、パブリックに公開する必要があるポートに対するDOCKER-USERの解決策を解説しています。
この問題を根本的に解決する方法は、特別な理由がない限り、公開するポートを127.0.0.1にバインドし、外部に公開するものにはリバースプロキシを配置することです。これは、Traefik reverse proxy guideがこのページの次のステップとして構築している内容そのものです。1つのcontainerが80番と443番ポートを所有し、それ以外のすべてへhostnameによってTLS付きでルーティングします。(古いTraefik v2の設定から移行する場合:Traefik v2 to v3 migration guideにリネームとルールの変更について記載されています。)
stackの起動後にバインドを確認してください。sudo ss -tlnp | grep 8080には127.0.0.1:8080が表示されるべきであり、0.0.0.0:8080や*:8080ではありません。
Named volumes vs bind mounts
db-data:/var/lib/postgresql/dataはnamed volumeです。Dockerは/var/lib/docker/volumes/の下にディレクトリを作成・管理し、それをcontainer内にマウントします。対照的な手法がbind mount(./data:/var/lib/postgresql/data)であり、これはホスト上の指定したパスをマウントします。
実務上の使い分けは以下の通りです:containerのみが触れるデータにはnamed volumesを使用する。特にデータベースが該当します。Dockerがイメージが期待する所有権でvolumeを初期化するため、ファイル権限が正しく動作します。ホストから操作するファイルにはbind mountsを使用する。テキストエディタで編集する設定ファイルや、rsyncで同期するメディアライブラリなど、パスを明示したいものに適しています。bind mountにおける典型的な失敗は所有権の問題です。containerがUID 999で動作しているのに対し、ホストのディレクトリがUID 1000で所有されている場合、アプリは起動時にログにpermission deniedを出力して停止します。named volumesを使用すると、データがDocker管理のパスに保存されるという制約はありますが、この種のバグはほぼ解消されます。詳細は後述します。
environment and .env — keep secrets out of git
${POSTGRES_PASSWORD}はシェルから読み込まれるのではなく、Composeはcompose.ymlの隣にある.envという名前のファイルから値を補完します。以下の手順で作成してください。
cat > .env <<'EOF'
POSTGRES_PASSWORD=change-me-to-something-long
ADMIN_PASSWORD=change-me-too
EOF
chmod 600 .env
echo ".env" >> .gitignore値の生成にはopenssl rand -hex 24を使用してください。意図的にbase64ではなくHexを使用しています。これは、パスワードがDATABASE_URLの接続文字列内に配置されるためです。base64で生成される/、+、=の文字はURLの解析を壊します。これは構文エラーではなく認証エラーとして表面化し、解決に時間を要します。.gitignoreの行は、最初のcommitの「前」に記述してください。composeファイルは公開やバージョン管理が可能ですが、.envファイルは不可能です。gitの履歴に残ったシークレットは、ローテーションが必要なシークレットとなります。変数がない状態でstackを起動すると、Composeは警告を出して空文字列として処理を続行します。これはPostgresのパスワードの場合、デプロイの失敗を意味します。
WARN[0000] The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string.docker compose configは、完全に補完されたファイルを出力します。containerが実際に受け取る内容を確認する最も速い方法です。出力にシークレットが含まれていることに注意してください。
depends_on waits for nothing — unless you add a healthcheck
単なるdepends_on: [db]は、起動の「順序」のみを制御します。ComposeはまずPostgresを起動し、その直後にアプリを起動しますが、この時点ではPostgresはまだ接続を受け入れられる状態にありません。アプリはデータベースにアクセスして失敗し、実装に応じてクラッシュするかリトライを繰り返します。
信頼できる構成は、上記のファイルで使用している方法です。dbサービスでhealthcheckを定義し(Postgresにはまさにこのためのpg_isreadyが付属しています)、アプリ側でcondition: service_healthyを伴うdepends_onを宣言します。Composeはデータベースを起動し、10秒ごとにチェックを行い、チェックがパスした後に初めてMinifluxを起動します。データベースが正常な状態(healthy)にならない場合(パスワード間違いやvolumeの破損など)、アプリは起動せず、Composeはどの依存関係が失敗したかを通知します。
dependency failed to start: container miniflux-db-1 is unhealthyこのメッセージはdocker compose logs dbを指し示しており、そこに真のエラーの原因があります。
restart: unless-stopped
両方のサービスにrestart: unless-stoppedを設定すると、クラッシュ時やVPSの再起動後にはcontainerが自動復旧しますが、意図的にdocker compose stopを実行した場合は停止したままになります。対照的なalwaysは、手動で停止した後でもcontainerを復活させます。これは通常、意図した動作ではありません。restart policyを設定していない場合、午前4時のカーネルアップデートによる再起動などで、気づかないうちにサービスが停止したままになります。
日常的な操作
日々の作業は、プロジェクトディレクトリから実行する5つのコマンドで行います。
docker compose up -d # create and start; idempotent, recreates only what changed
docker compose ps # status, ports, and health of this project's containers
docker compose logs -f miniflux # follow one service's logs; --tail 100 for recent history
docker compose pull && docker compose up -d # upgrade to the pinned tags
docker compose down # stop and remove containers and the networkup -d は繰り返し実行しても安全です。このコマンドはファイルと現在の状態を比較し、設定または image が変更されたサービスのみを操作します。アップグレード用のペアは、現在指定されている tag の内容を取得します。postgres:16-alpine 未満の patch release は取得されますが、exact pin を編集しない限り、指定したバージョンは更新されません。アップグレードを繰り返すと古い image が蓄積されます。docker image prune -f を使用してディスク容量を解放してください。
次に、注意が必要な破壊的な操作について説明します。docker compose down は安全です。container や network は破棄可能です。データは volume に保存されています。docker compose down -v は named volume も削除します。これにはデータベースが含まれます。確認プロンプトはなく、取り消しもできないため、実行すると即座にデータが消失します。 -v flag は実験環境を破棄するために用意されています。本番データを含む stack に対しては、rm -rf と同様に慎重に扱ってください。/var/lib/docker/volumes/ にゴミ箱はありません。
実行中の container 内で一時的に shell を起動する場合:docker compose exec db psql -U miniflux を使うと database に接続でき、docker compose exec miniflux sh を使うと app の shell にアクセスできます。
データの実際の保存場所
Named volumes にはプロジェクトの prefix が付与されます。そのため、miniflux ディレクトリ内の db-data は miniflux_db-data になります。
docker volume ls
docker volume inspect miniflux_db-datainspect の出力には、重要な行が含まれています。
"Mountpoint": "/var/lib/docker/volumes/miniflux_db-data/_data"このディレクトリがデータベースの実体です。root 権限でホストの filesystem 上に存在し、down、アップグレード、コンテナの再構築後も保持されます。バックアップでは、このディレクトリを正確に取得する必要があります。
名前付きボリュームのバックアップ
標準的な手法は、ボリュームをホストディレクトリに読み取り専用でマウントした使い捨てのコンテナを作成し、tarでアーカイブすることです。
docker run --rm \
-v miniflux_db-data:/data:ro \
-v "$PWD":/backup \
alpine:3.22 tar czf /backup/miniflux-db-$(date +%F).tar.gz -C /data .追加のインストールや常駐プロセスは不要です。リストアはこれの鏡合わせになります。マウント設定を逆に設定した新しい空のボリュームへ、tar xzf で書き戻します。
データベースに関する注意点があります。実行中のPostgresデータディレクトリをtarで固めると、書き込み途中の状態が保存され、正常に起動できない場合があります。tarを実行する数秒間、docker compose stop を行うか、あるいは論理ダンプを取得してください。論理ダンプは構造的に整合性が保たれます。
docker compose exec -T db pg_dump -U miniflux miniflux | gzip > miniflux-$(date +%F).sql.gz-T は、Compose がデフォルトで割り当てる pseudo-terminal を無効にします。ダンプ出力を TTY 経由でパイプすると、データが破損する可能性があります。これらのコマンドを cron に登録し、結果を VPS の外部にコピーしてください。保護対象のデータと同じディスクにバックアップを保存しても、それは単なるコピーであり、バックアップとは呼べません。Nextcloud guide では、これら2つのパターンに基づいたスケジュール実行の手順を詳しく説明しています。
失敗パターンと表示される文字列
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock — まだ docker group に所属していないか、所属していても現在のセッションに反映されていません。id で現在のグループを確認できます。newgrp docker で現在のシェルに反映されますが、ログアウトして再ログインすればすべての変更が適用されます。
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? — 別の問題です。daemon 自体が停止しています。sudo systemctl status docker と sudo journalctl -u docker -n 50 がその理由を表示します。VPS の場合、ディスク容量不足が一般的な原因です。まず df -h /var/lib/docker を確認してください。
Bind for 127.0.0.1:8080 failed: port is already allocated — 別のコンテナがすでにホストポートを公開しています。docker ps でそのコンテナを確認できます。数週間前の実験的な docker run による古いコンテナが原因であることが多いです。docker ps で問題がない場合は、Docker 以外のプロセスがポートを使用しています。sudo ss -tlnp | grep 8080 でそのプロセスを特定できます。
yaml: line 14: did not find expected key — 指定された行、またはその直上でのインデントエラーです。Compose ファイルは YAML 形式です。インデントは半角スペース2つで行い、タブ文字を使用するとエラーになります。docker compose config を使うと、コンテナを起動せずにファイルの検証ができます。編集のたびに実行することを推奨します。
ufw による予期せぬ動作 は、エラーを一切表示しないため危険です。デプロイは成功し、ufw status は正常に見えますが、外部からのポートスキャンでデータベースにアクセスできてしまいます。上記の ports セクションを読み直し、ports: の各項目に 127.0.0.1: プレフィックスが欠けていないか確認してください。その後、別のマシンから curl http://your-vps-ip:8080 を使って接続テストを行い、connection refused と返ってくることを確認してください。
ここからは、Traefik ガイド を参照して、この単一の stack を 1 つの HTTPS エントリポイントの背後にある複数のアプリへと拡張できます。また、2026年にセルフホストすべきもの は、それらを実行するためのリストです。
VPS 上の Minecraft サーバー のようなゲームサーバーは、Compose の練習に最適な最初のプロジェクトです。
FAQ
Why do I get "permission denied while trying to connect to the Docker daemon socket"?
Your user is not in the docker group, or was added after the current session began — membership only applies at login. Run sudo usermod -aG docker $USER, then newgrp docker or log out and back in, and confirm with id. The group grants root-equivalent access to the host, so only add users you would give sudo.
Does docker compose down delete my data?
Plain docker compose down does not — it removes containers and the project network; named volumes survive and the next up -d reattaches them. docker compose down -v is the destructive form: it deletes the named volumes, meaning your database, with no confirmation and no undo. Never run -v on a stack with real data unless you hold a verified backup.
What is the difference between docker-compose and docker compose?
docker-compose (hyphen) is Compose v1, a standalone Python binary that reached end of life in 2023 and should not be installed on new servers. docker compose (space) is Compose v2, a Go plugin for the Docker CLI, installed as docker-compose-plugin from Docker's apt repository. Commands and YAML are almost fully compatible, so when an old tutorial says docker-compose up, type docker compose up.
Why can I reach my Docker container from the internet even though ufw blocks the port?
Because Docker publishes ports with DNAT rules in iptables' PREROUTING chain, and the rewritten packets travel the FORWARD path through Docker's own chains — they never hit the INPUT chain where ufw's rules apply. ufw deny 8080 therefore does nothing to a published container port. Fix it at the source: publish to 127.0.0.1: and expose services through a reverse proxy instead.
Should I use a named volume or a bind mount?
Named volumes for data only the container touches — databases especially, since Docker sets the ownership the image expects and permissions just work. Bind mounts for files you also handle from the host: configs you edit, media you upload, anything whose path you want obvious. If a container fails at startup with permission denied on a bind mount, host-vs-container UID mismatch is the first thing to check.