目次
DockerでWebサイトをクラウドサーバー(VPS)にデプロイする方法
この節では、これまでに学んだ内容をつなげて、Docker Composeで管理するWebサイトをLinuxクラウドサーバー(VPS)にデプロイします。
この節の出力では、同じマシン上で一連の手順をシミュレーションします。Docker HubやGitHub Container Registryの代わりにローカルの
registry:3(localhost:5000)を使い、正式なドメインの証明書の代わりにCaddyがlocalhost用に発行したローカル証明書を使います。実際にデプロイするときは、イメージ名とドメインを自分のものに置き換えるだけです。
全体の流れ
本機またはCI ──docker build / push──▶ Registry ──docker compose pull──▶ クラウドサーバー
コードはローカルまたはCIでイメージにビルドし、Registryにプッシュします。サーバーはイメージをダウンロードして実行するだけで、サーバー上ではビルドしません。そのためサーバーにソースコードやビルドツールは不要で、デプロイのたびに同じ結果が得られます。以前のバージョンに戻すには、古いタグに戻すだけです。
1. サーバーの準備
どのクラウドサービスのLinuxサーバーでも使えます。ここではUbuntuを例にします。
-
SSHでログインし、一般ユーザーを作成してパスワードログインを無効にします(鍵のみを使用します)。
-
DockerのインストールにあるLinuxの手順に従って、Docker EngineとComposeプラグインをインストールします。
-
ファイアウォールでは22(SSH)、80、443のみを開放します。
sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw enable注意:
-pで公開したポートはufwを迂回します(セキュリティのベストプラクティスを参照)。そのため、以下のcompose.yamlではリバースプロキシだけにportsを設定しています。 -
Dockerが起動時に自動起動するよう設定します:
sudo systemctl enable docker。
2. アプリケーション
例として、Node.js + PostgreSQLを使った小さなWebサイトを用意します。リクエストのたびにアクセスを1件記録し、現在のバージョンとアクセス数を返します。
myapp/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
└── .dockerignore
server.js:
const express = require("express");
const { Pool } = require("pg");
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const version = process.env.APP_VERSION || "dev";
app.get("/", async (req, res) => {
await pool.query("CREATE TABLE IF NOT EXISTS visits (id serial PRIMARY KEY, at timestamptz DEFAULT now())");
await pool.query("INSERT INTO visits DEFAULT VALUES");
const { rows } = await pool.query("SELECT count(*)::int AS visits FROM visits");
res.json({ app: "myapp", version, visits: rows[0].visits });
});
const server = app.listen(3000, () => console.log(`myapp ${version} listening on port 3000`));
process.on("SIGTERM", () => server.close(() => pool.end().then(() => process.exit(0))));
package.jsonの依存関係はexpressとpgです。Dockerfile(書き方の説明はNode.jsアプリケーションを参照)では、最後にARGでバージョン番号を受け取り、ENVに設定します。これにより、プログラムが自身のバージョンを表示できます。
FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=node:node . .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
USER node
EXPOSE 3000
CMD ["node", "server.js"]
3. イメージをビルドしてプッシュする
ローカルまたは CI で、固定のバージョン番号をタグとして使います。
$ docker build --build-arg APP_VERSION=1.0.0 -t localhost:5000/myapp:1.0.0 .
[+] Building 6.7s (10/10) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 308B 0.0s
=> [internal] load metadata for docker.io/library/node:24-alpine 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 63B 0.0s
=> [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 2.4s
=> => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 0.0s
=> => extracting sha256:e2de96513ba9eb53b431787ec8a65cdde380ac4772a3e4c4b714dcfde2a102b5 0.1s
=> => extracting sha256:3c504d5a797be28f9de43147d49cd8ecb2eea46dfaf7beb8715db695d5f0a7f0 2.1s
=> => extracting sha256:02f40d53b06f6b91fec0cd6a6719656da99270bff8ee54ed8dcdf4c157763fc1 0.1s
=> => extracting sha256:7ce7dcbafb7f750cf0c6be3c30e34372b06dd1182fa67f6ae30782c8150c5444 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 36.48kB 0.0s
=> [2/5] WORKDIR /app 0.1s
=> [3/5] COPY package.json package-lock.json ./ 0.0s
=> [4/5] RUN npm ci --omit=dev && npm cache clean --force 3.5s
=> [5/5] COPY --chown=node:node . . 0.1s
=> exporting to image 0.6s
=> => exporting layers 0.3s
=> => exporting manifest sha256:a0bd86a3b62021adb05a5e710c5a59f9e6287c8c84016ba22f1923bc4aaeeabf 0.0s
=> => exporting config sha256:7f75124e75541f256217e31a844e8c9b687434955d053e47e5dae571aa3944c6 0.0s
=> => exporting attestation manifest sha256:9d6688e6e287f9a0b3d3a95d23765cde8496c89bf6dd0c7fd7a191ac58abe9db 0.0s
=> => exporting manifest list sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 0.0s
=> => naming to localhost:5000/myapp:1.0.0 0.0s
=> => unpacking to localhost:5000/myapp:1.0.0 0.3s
$ docker push localhost:5000/myapp:1.0.0
The push refers to repository [localhost:5000/myapp]
44136fa355b3: Pushed
2297ea3ee753: Pushed
7ce7dcbafb7f: Pushed
fffb74b09a22: Pushed
e2de96513ba9: Pushed
1d73dbdb2af1: Pushed
bd9735416a50: Pushed
02f40d53b06f: Pushed
22ac32835411: Pushed
3c504d5a797b: Pushed
1.0.0: digest: sha256:d7d0950bb72768da05a47e8bf234754cecde01a8fd8d920e8261fcf7bf031522 size: 856
実際に使うときは、イメージ名に Registry のアドレスとアカウントを指定します。たとえば Docker Hub なら yourname/myapp:1.0.0、GitHub なら ghcr.io/yourname/myapp:1.0.0 です。また、事前に docker login を実行してください(Docker Hub へのプッシュを参照)。
Apple Silicon 搭載の Mac でビルドし、一般的な amd64 ホストにデプロイする場合は、
--platform linux/amd64を追加してください。追加しないと、サーバーでexec format errorが発生します。詳しくはマルチプラットフォームイメージを参照してください。
4. サーバー上のファイル
サーバーに /srv/myapp などのディレクトリを作成します。必要なファイルは3つだけで、ソースコードは不要です。
/srv/myapp/
├── compose.yaml
├── .env
└── Caddyfile
compose.yaml:
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
app:
image: localhost:5000/myapp:${APP_VERSION}
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U app -d app"]
interval: 5s
retries: 10
restart: unless-stopped
logging:
options: { max-size: "10m", max-file: "3" }
volumes:
caddy_data:
caddy_config:
pgdata:
.env:
APP_VERSION=1.0.0
DB_PASSWORD=change-this-to-a-long-random-password
Caddyfile(実際にデプロイするときは、localhost を example.com などのドメインに置き換えます):
localhost {
reverse_proxy app:3000
}
ポイント:
| 設定 | 説明 |
|---|---|
image: ...:${APP_VERSION} | バージョン番号は .env で管理します。更新やロールバックではこの行を変更するだけです |
caddy だけに ports がある | アプリケーションとデータベースには外部から接続できません |
| Caddy | HTTPS 証明書を自動で取得・更新し、HTTP を HTTPS に転送します。詳しくは Nginx とリバースプロキシの最後の節をご覧ください |
pgdata:/var/lib/postgresql | PostgreSQL 18 のマウントパスです。詳しくはPostgreSQLをご覧ください |
pg_isready -h 127.0.0.1 | データベースの初期化中に healthy と判定されるのを防ぎます |
restart: unless-stopped | プログラムが停止したり、ホストが再起動したりした後に自動で起動します |
logging | ログのサイズを制限して、ディスクがいっぱいになるのを防ぎます。詳しくはディスク容量のクリーンアップをご覧ください |
.env にはパスワードが含まれるため、権限を設定してください:chmod 600 .env。
5. 起動
$ ls -a
.
..
.env
Caddyfile
compose.yaml
$ docker compose pull
[+] pull 35/35
✔ Image caddy:2 Pulled 2.8s
✔ Image localhost:5000/myapp:1.0.0 Pulled 0.3s
✔ Image postgres:18 Pulled 12.2s
$ docker compose up -d
[+] up 7/7
✔ Network srv-myapp_default Created 0.0s
✔ Volume srv-myapp_caddy_config Created 0.0s
✔ Volume srv-myapp_caddy_data Created 0.0s
✔ Volume srv-myapp_pgdata Created 0.0s
✔ Container srv-myapp-db-1 Healthy 6.0s
✔ Container srv-myapp-app-1 Started 6.2s
✔ Container srv-myapp-caddy-1 Started 6.7s
$ docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
srv-myapp-app-1 localhost:5000/myapp:1.0.0 "docker-entrypoint.s…" app 10 seconds ago Up 4 seconds 3000/tcp
srv-myapp-caddy-1 caddy:2 "caddy run --config …" caddy 10 seconds ago Up 3 seconds 0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp, 443/udp, 2019/tcp
srv-myapp-db-1 postgres:18 "docker-entrypoint.s…" db 10 seconds ago Up 9 seconds (healthy) 5432/tcp
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":1}
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":2}
$ docker compose logs app
app-1 | myapp 1.0.0 listening on port 3000
docker compose pullは、プッシュしたmyapp:1.0.0を含むすべてのイメージを先にダウンロードします。up -dは依存関係の順に起動します。dbがHealthyになってからappを起動し、最後にcaddyを起動します。psでは、外部にポート 80 と 443 を公開しているのは caddy だけです。- HTTPS で2回リクエストすると、
visitsは 1 から 2 になり、データが PostgreSQL に書き込まれます。(ここではローカルの証明書を使っているため-kを指定しています。本番ドメインでは Let's Encrypt の証明書を使うので、-kは不要です。)
本番環境にデプロイしたら、https://あなたのドメイン を開いてサイトが正常に動作することを確認し、docker compose logs -f app でログを確認します。
6. バージョンの更新
ローカルで新しいバージョンをビルドしてプッシュします。
$ docker build -q --build-arg APP_VERSION=1.1.0 -t localhost:5000/myapp:1.1.0 .
sha256:aac108fcd63549926e767b781e0b9aa25a86e2f2cd449710a41b5e8d0accda16
$ docker push -q localhost:5000/myapp:1.1.0
localhost:5000/myapp:1.1.0
サーバーで .env の APP_VERSION を変更し、app だけを pull して再作成します。
$ grep APP_VERSION .env
APP_VERSION=1.1.0
$ docker compose pull app
[+] pull 1/1
✔ Image localhost:5000/myapp:1.1.0 Pulled 0.1s
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 1.0s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.1.0","visits":3}
$ docker compose images app
CONTAINER REPOSITORY TAG PLATFORM IMAGE ID SIZE CREATED
srv-myapp-app-1 localhost:5000/myapp 1.1.0 linux/amd64 aac108fcd635 251MB 44 seconds ago
pull appはappの新しいイメージだけをダウンロードします。(ここでは同じマシン上でシミュレーションしているため、イメージはすでにローカルにあり、0.1 秒で完了しています。)up -d appはイメージが変わったことを検出し、appコンテナだけを再作成します。dbとcaddyには影響せず、サイトの停止は約 1 秒です。- レスポンスの
versionは1.1.0に変わり、visitsは 3 から引き続き増加します。データベースのデータはそのまま保持されます。 docker compose images appで、現在1.1.0が使われていることを確認できます。
ロールバック
新しいバージョンに問題がある場合は、APP_VERSION を 1.0.0 に戻してから up -d app を実行します。
$ grep APP_VERSION .env
APP_VERSION=1.0.0
$ docker compose up -d app
[+] up 2/2
✔ Container srv-myapp-db-1 Healthy 0.7s
✔ Container srv-myapp-app-1 Started 0.9s
$ curl -sk https://localhost/
{"app":"myapp","version":"1.0.0","visits":4}
コマンドを 1 つ実行するだけで以前のバージョンに戻せます。古いイメージはサーバー上に残っているため、再ダウンロードも不要です。これが latest ではなく固定バージョン番号を使う理由です。latest を使うと、サーバーでどのバージョンが動いているか確認できず、前のバージョンに明示的に戻すこともできません。
データベーススキーマの変更(マイグレーション)は、通常、自動ではロールバックできません。新しいバージョンを公開する前にデータベースをバックアップしてください。また、新バージョンによるデータベースの変更は、旧バージョンとの互換性を保つのが望ましいです。
7. バックアップ
最低でもデータベースを毎日バックアップし、別のマシンにファイルを保存してください。/srv/myapp に backup.sh を作成します。
#!/bin/sh
set -e
cd "$(dirname "$0")"
mkdir -p backup
docker compose exec -T db pg_dump -U app app | gzip > backup/app-$(date +%F).sql.gz
find backup -name 'app-*.sql.gz' -mtime +14 -delete
$ ./backup.sh && ls -lh backup
total 4K
-rw-r--r-- 1 root root 872 Oct 13 01:17 app-2026-10-13.sql.gz
$ gunzip -c backup/app-*.sql.gz | grep -A 6 "^COPY public.visits"
COPY public.visits (id, at) FROM stdin;
1 2026-10-13 01:16:44.277875+00
2 2026-10-13 01:16:44.390504+00
3 2026-10-13 01:16:57.763022+00
4 2026-10-13 01:17:02.44854+00
\.
- バックアップファイルには日付が付き、
find ... -mtime +14 -deleteで 14 日より前のバックアップを削除します。 - 解凍して内容を確認します。
visitsテーブルの 4 件のレコードがすべて含まれています。
chmod +x backup.sh を設定し、crontab -e で毎日午前 3 時に実行するようにします。
0 3 * * * /srv/myapp/backup.sh
バックアップを同じホストに保存すると、ホストの故障時に一緒に失われます。そのため rsync や rclone などのツールで backup/ を別のマシンまたはクラウドストレージにコピーしてください。復元方法はPostgreSQL のバックアップと復元を参照し、定期的に実際の復元をテストしてください。
8. 自動化(任意)
手動で SSH 接続して更新する方法は、すぐに面倒になります。一般的な自動化の方法は次のとおりです。
-
CI/CD:GitHub Actions などの CI でイメージをビルドしてプッシュし、SSH 経由でサーバー上の
APP_VERSIONを変更して、docker compose pull app && docker compose up -d appを実行します。 -
Docker context:サーバーにログインせず、ローカルからリモートサーバーの Docker を直接操作できます。
docker context create prod --docker "host=ssh://user@your-server" docker --context prod psdocker --context prod compose up -dはローカルディレクトリにあるcompose.yamlを使い、リモートサーバー上で実行します。
公開前チェックリスト
- SSH は鍵認証のみを許可し、ファイアウォールで開放するのは 22、80、443 のみ
-
portsがあるのはリバースプロキシだけで、データベースは外部に公開されていない -
latestは使わず、固定バージョン番号のイメージタグを使っている - すべてのサービスに
restart: unless-stoppedがある - ログサイズの上限を設定している
- ヘルスチェックの記述が正しい(
pg_isready -h 127.0.0.1) -
.envの権限が 600 で、Git にコミットされていない - データベースを毎日自動で別の場所にバックアップし、実際に復元をテストしている
- 古いイメージを定期的に削除し(ディスク容量の整理を参照)、セキュリティ更新を取り込むため定期的に再ビルドしている(セキュリティのベストプラクティスを参照)
まとめ
- イメージはローカルまたは CI でビルドして Registry にプッシュし、サーバーでは pull して実行します。
- バージョン番号を
.envに記述しておけば、1 行変更してup -dを実行するだけで更新やロールバックができます。 - 外部に公開するのはリバースプロキシだけにし、データベースのヘルスチェックを正しく記述します。
- 毎日別のマシンに自動バックアップし、定期的に復元をテストします。
練習問題
練習 1:サーバー上の APP_VERSION を 1.1.0 から 1.0.0 にロールバックした後も、新しいバージョンがデータベースに書き込んだデータは残っていますか?
解答
残っています。ロールバックで入れ替わるのは app のイメージだけで、データベースの Volume は変更されません。この章での実測では visits は増え続けました。ただし、新しいバージョンでテーブル構造を変更した場合、旧バージョンのプログラムが新しい構造を使えないことがあります。そのため、公開前にバックアップしてください。
よくある質問
Registryは必ず必要ですか?サーバー上でdocker buildする方法ではだめですか?
可能ですが、サーバーにソースコードとビルドツールが必要になり、デプロイのたびに異なる結果になる可能性もあります。Registry経由でデプロイすれば、サーバーはテスト済みのイメージをプルするだけで済み、ロールバックも簡単です。
どのクラウドサーバーを使えばよいですか?
Docker EngineをインストールできるLinuxサーバーならどれでも使えます。小規模なWebサイトなら通常はメモリ1~2 GBで十分です。データベースが大きくなったら増やしてください。
デプロイを自動化できますか?
できます。GitHub Actionsを使って、バージョンタグを作成したときにビルドとプッシュを行い、SSHでサーバーに接続して更新できます。[GitHub Actionsによる自動ビルド](/ja/docker/ci-cd/)を参照してください。
更新日 2026年10月14日