目次

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を例にします。

  1. SSHでログインし、一般ユーザーを作成してパスワードログインを無効にします(鍵のみを使用します)。

  2. DockerのインストールにあるLinuxの手順に従って、Docker EngineとComposeプラグインをインストールします。

  3. ファイアウォールでは22(SSH)、80、443のみを開放します。

    sudo ufw allow OpenSSH
    sudo ufw allow 80,443/tcp
    sudo ufw enable

    注意:-pで公開したポートはufwを迂回します(セキュリティのベストプラクティスを参照)。そのため、以下のcompose.yamlではリバースプロキシだけにportsを設定しています。

  4. 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 があるアプリケーションとデータベースには外部から接続できません
CaddyHTTPS 証明書を自動で取得・更新し、HTTP を HTTPS に転送します。詳しくは Nginx とリバースプロキシの最後の節をご覧ください
pgdata:/var/lib/postgresqlPostgreSQL 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 ps

    docker --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日