「開発環境では動いたけれど、本番ではどこにPDFを保存すればいいの?」「サーバーを入れ替えたらファイルが消えた、を防ぎたい」。システムを本番で動かすときに最初に決めるのが、データの置き場所と、設定・秘密情報の渡し方です。最終回の今回は、PDF本体の保存先を開発用のローカルディスクから Amazon S3(AWS のファイル保存サービス)に切り替え、署名付きURL(期限付きでだけ使えるURL)でダウンロードさせ、本番サーバーで起動するまでをまとめます。
前回の第10回:見積書・請求書をデータから生成するでは、データから帳票PDFを作りました。これで機能はそろったので、今回は「本番で安全に動かす」ための仕上げです。記事の最後に、連載全体の振り返りと、本番運用までに残っている作業の一覧を載せています。
「保存先を S3 に変えるのは大変?今までのコードを全部直すことになるの?」
結論から言うと、第1回から保存先を「差し替え可能な部品」にしてあったので、直すのは保存先の部品と、ダウンロードの1か所だけです。アップロード・結合・書き込み・帳票生成などのコードは一切変えずに、環境変数1つで保存先を切り替えられます。むしろ大事なのは、S3 の権限設定・認証情報の渡し方・バックアップといった「運用の設計」です。
保存先を切り替えられるようにしておいた理由
第1回で、PDF本体の保存・読み出し・削除を StorageAdapter という小さなインターフェースにまとめました。今回は、それに「署名付きURLを作る」機能(対応している保存先だけが持つ任意の機能)を1つ足し、S3 版を実装します。
server/utils/storage/types.ts
export interface StorageAdapter {
/** key の場所にデータを保存する */
put(key: string, data: Buffer, contentType: string): Promise<void>
/** key のデータを読み出すストリームを返す(ダウンロード用) */
getStream(key: string): Promise<Readable>
/** key のデータを削除する(存在しなければ何もしない) */
delete(key: string): Promise<void>
/**
* 期限付きでダウンロードできるURL(署名付きURL)を作る。対応していない保存先(ローカル)では未定義。
* 認可はこのURLを作る前にサーバー側で済ませておく
*/
getSignedDownloadUrl?(key: string, options: { fileName: string, expiresIn: number }): Promise<string>
}
| ローカル(開発) | S3(本番) | |
|---|---|---|
| 保存場所 | サーバーのディスク(uploads/) | S3 バケット(pdf-kanri/ 以下) |
| サーバーを作り直したとき | ディスクごと消える可能性がある | 残る(サーバーと分離) |
| 複数台のサーバー | 共有できない | 共有できる |
| ダウンロード | サーバーが中継して返す | 署名付きURLで S3 から直接返す |
S3 版の StorageAdapter
AWS 公式の SDK(AWS SDK for JavaScript v3 の @aws-sdk/client-s3 と @aws-sdk/s3-request-presigner)を使います。
server/utils/storage/s3.ts(抜粋)
/**
* Amazon S3 の StorageAdapter。
* 認証情報(アクセスキー)はここでは受け取らない。AWS SDK の既定の探し方
* (IAM ロール、環境変数 AWS_ACCESS_KEY_ID など)に任せる。
*/
export function createS3Storage(options: S3StorageOptions): StorageAdapter {
const client = new S3Client({
region: options.region,
...(options.endpoint ? { endpoint: options.endpoint, forcePathStyle: options.forcePathStyle } : {}),
})
const keyOf = (key: string) => `${options.prefix}${key}`
return {
async put(key: string, data: Buffer, contentType: string): Promise<void> {
await client.send(new PutObjectCommand({
Bucket: options.bucket,
Key: keyOf(key),
Body: data,
ContentType: contentType,
// 同じキーがあれば上書きせずエラーにする(ローカル版の flag: 'wx' と同じ考え方)
IfNoneMatch: '*',
}))
},
async getStream(key: string): Promise<Readable> {
try {
const res = await client.send(new GetObjectCommand({ Bucket: options.bucket, Key: keyOf(key) }))
// Node.js では Body は Readable(SDK の型は環境共通のため広い型になっている)
return res.Body as Readable
} catch (err) {
if (err instanceof NoSuchKey) throw new Error(`not found: ${key}`)
throw err
}
},
async delete(key: string): Promise<void> {
await client.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: keyOf(key) }))
},
async getSignedDownloadUrl(key: string, { fileName, expiresIn }): Promise<string> {
const command = new GetObjectCommand({
Bucket: options.bucket,
Key: keyOf(key),
// S3 からの応答ヘッダーを指定(保存名を元のファイル名にする。キャッシュさせない)
ResponseContentDisposition: contentDisposition(fileName, 'attachment'),
ResponseContentType: 'application/pdf',
ResponseCacheControl: 'private, no-store',
})
return getSignedUrl(client, command, { expiresIn })
},
}
}
- 認証情報はコードにも設定にも書かない:
S3Clientにcredentialsを渡していません。AWS SDK は、IAM ロール(EC2・ECS などに付ける「サーバー自身の権限」)や、標準の環境変数を自動で探して使います。本番では、アクセスキーを発行せずに IAM ロールで権限を与えるのが基本です。 - 上書き防止:
IfNoneMatch: '*'を付けると、同じキーのファイルが既にあれば S3 が保存を断ります(条件付き書き込み)。版管理(第7回)では新しい版を必ず新しいキーで保存するので、上書きは起こらないはずですが、念のための安全装置です。 - ダウンロード時のファイル名:S3 に保存したキーはUUIDですが、署名付きURLに
ResponseContentDispositionを含めると、S3 が応答にContent-Dispositionヘッダーを付けてくれます。第5回で作った日本語ファイル名の関数をそのまま使えます。
📰 出典:AWS SDK for JavaScript v3 デベロッパーガイド(Node.js での認証情報の設定)
📰 出典:Amazon S3 ユーザーガイド(条件付き書き込み)
設定で切り替える
server/utils/storage/index.ts
/** 設定(runtimeConfig.storageDriver)に応じた StorageAdapter を返す。local(開発)/s3(本番) */
export function useFileStorage(): StorageAdapter {
if (!adapter) {
const config = useRuntimeConfig()
if (config.storageDriver === 's3') {
if (!config.s3.bucket || !config.s3.region) {
throw new Error('NUXT_S3_BUCKET と NUXT_S3_REGION を設定してください')
}
adapter = createS3Storage(config.s3)
} else {
adapter = createLocalStorage(config.uploadDir)
}
}
return adapter
}
nuxt.config.ts の runtimeConfig に storageDriver: 'local' と s3: { bucket, region, prefix, endpoint, forcePathStyle, signedUrlExpires } を追加しました。本番では NUXT_STORAGE_DRIVER=s3、NUXT_S3_BUCKET、NUXT_S3_REGION などの環境変数で上書きします。endpoint は S3 互換のストレージや検証環境のためのもので、本番の Amazon S3 では空のままにします。
ダウンロードは署名付きURLで S3 から直接
第5回のダウンロードAPIは、認可を確認したあと、ファイルをサーバーが読み出して返していました。S3 の場合は、認可を確認したあとに 60秒だけ有効な署名付きURL を作り、そこへリダイレクトします。ファイル本体は S3 から利用者のブラウザへ直接送られるため、大きなPDFでもサーバーの負荷が増えません。
server/api/files/[id]/download.get.ts(追加部分)
// 4. S3 など署名付きURLに対応した保存先で、保存(attachment)の場合は、
// 短時間だけ有効なURLへリダイレクトし、ファイル本体は S3 から直接ダウンロードさせる(第11回)。
// プレビュー(inline)は PDF.js が同じオリジンから読むため、これまでどおりサーバー経由で返す
if (!inline && storage.getSignedDownloadUrl) {
const url = await storage.getSignedDownloadUrl(target.storageKey, {
fileName: name,
expiresIn: useRuntimeConfig(event).s3.signedUrlExpires,
})
setResponseHeader(event, 'Cache-Control', 'private, no-store')
return sendRedirect(event, url, 302)
}
- 認可は今までどおりサーバーで:署名付きURLは「持っている人なら誰でもダウンロードできるURL」です。URLを作る前に、第5回の
requireRoleでログインと権限を必ず確認します。 - 有効期限は短く:URLが画面の履歴やログに残っても、期限が過ぎれば使えません。サンプルは60秒(
NUXT_S3_SIGNED_URL_EXPIRES)にしています。 - プレビューはサーバー経由のまま:第6回のビューア(PDF.js)はファイルをブラウザの中で読み込むため、S3 から直接読ませるには S3 側でクロスオリジンの設定(CORS)が必要になります。設定を増やさないよう、プレビューだけは従来どおりサーバーが中継して返します。
📰 出典:Amazon S3 ユーザーガイド(署名付きURLによるオブジェクトの共有)
本番サーバーで起動する
Nuxt は nuxt build で .output/ フォルダに本番用のサーバーを作り、node .output/server/index.mjs で起動します。公式ドキュメントでは NODE_ENV=production を付けて起動するよう案内されており、ポートは PORT(または NITRO_PORT)で変えられます。
📰 出典:Nuxt 公式ドキュメント Deployment(Node.js Server)
npm ci
npm run build
NUXT_DATABASE_PATH=/var/lib/pdf-kanri/app.db npm run db:migrate
NODE_ENV=production node .output/server/index.mjs
- ビルド後のサーバーは
.envを読まない:開発中は Nuxt が.envを読み込みますが、node .output/server/index.mjsで起動したサーバーは読みません(第0回で説明したとおり)。本番では、サーバーの環境変数として渡します。Node.js の--env-fileオプションでファイルから読み込ませることもでき、今回の検証でもこの方法で起動しました。 - 常駐させる:サンプルには systemd(Linux のサービス管理)の設定例
deploy/pdf-kanri.service.exampleを入れました。秘密情報は設定ファイル本体ではなく、権限を絞った別ファイル(EnvironmentFile)に置きます。 - サーバー上に置くもの:SQLite のデータベースファイル、日本語フォント(第8回)は S3 ではなくサーバーのディスクにあります。フォントのパスは、起動する場所に左右されないよう
NUXT_PDF_FONT_PATHに絶対パスで指定するのが安全です。
📰 出典:Node.js ドキュメント Command-line API(–env-file)
本番で渡す環境変数(値は書かない)
| 環境変数 | 内容 |
|---|---|
NUXT_SESSION_PASSWORD | セッションCookieの暗号化鍵(32文字以上、第4回) |
NUXT_DATABASE_PATH | SQLite のファイルの場所 |
NUXT_STORAGE_DRIVER・NUXT_S3_BUCKET・NUXT_S3_REGION・NUXT_S3_PREFIX | 保存先の切り替えと S3 の場所 |
NUXT_S3_SIGNED_URL_EXPIRES | 署名付きURLの有効期限(秒) |
NUXT_PDF_FONT_PATH | 日本語フォントの場所(第8回) |
NUXT_INVOICE_ISSUER_* | 帳票の発行元(第10回) |
| (IAM ロール) | S3 の権限。アクセスキーの環境変数は、ロールを使えない場合だけ |
S3 バケットの設定
- パブリックアクセスをブロックする:S3 には、バケットやファイルが誤って公開されないようにする「ブロックパブリックアクセス」の設定があります。業務ファイルのバケットでは必ず有効にし、ファイルは署名付きURLでだけ渡します。
- 権限は最小限に:アプリのロールに与えるのは、このアプリの保存場所(
pdf-kanri/以下)に対する読み取り・書き込み・削除だけにします。例をdeploy/s3-policy.example.jsonに入れました(バケット名は置き換えて使います)。 - バックアップ:S3 のバージョニング(上書き・削除前の状態を残す機能)を有効にすると、誤って消したファイルを復元できます。SQLite のファイルは、SQLite のオンラインバックアップの仕組みなどで定期的に別の場所へコピーします。ファイル(S3)とデータベース(SQLite)は必ずセットで、同じ時点に戻せるように運用を決めます。
📰 出典:Amazon S3 ユーザーガイド(ブロックパブリックアクセス)
📰 出典:Amazon S3 ユーザーガイド(バージョニング)
動作確認の方法
実際の AWS アカウントは使わず、手元で動く S3 互換のサーバー(Docker で起動した S3 互換ゲートウェイ)を NUXT_S3_ENDPOINT に指定して確認しました。認証情報も検証用のダミー値です。
| 操作 | 結果 |
|---|---|
NUXT_STORAGE_DRIVER=s3 でアップロード | バケットの pdf-kanri/<UUID>.pdf に保存 |
| ダウンロード(保存) | 302 で署名付きURLへ。S3 からの応答に日本語ファイル名の Content-Disposition が付き、中身は元のPDFとバイト単位で一致 |
| 有効期限(検証のため5秒)が過ぎた後に同じURL | 403(Request has expired) |
| URLのファイル名部分を別のキーに書き換え | 403(署名が合わない) |
| 未ログインでダウンロードAPI | 401(URLは作られない) |
| プレビュー(inline) | サーバー経由で 200、中身が一致 |
| 書き込み(第8回)・分割(第7回)・帳票生成(第10回) | すべて S3 に保存。過去の版も署名付きURLでダウンロードでき、名前に _v1 |
| ファイルを削除 | 全版の本体が S3 から消える |
| S3 から本体を直接消したファイル | プレビューは 404。保存は、リダイレクト先の S3 が 404 |
| ブラウザでプレビュー表示 → 「ダウンロード」 | S3 のURLから日本語ファイル名で保存される |
NUXT_STORAGE_DRIVER=local に戻す | これまでどおりサーバー経由でダウンロードでき、中身も一致 |
本物の Amazon S3・IAM ロールでの動作、条件付き書き込み(IfNoneMatch)で実際に保存が断られること、EC2 などへの配置は確認できていません。本番に出す前に、検証用の AWS 環境で同じ表の項目を確かめてください。
つまずきやすい点・注意点
- 削除とストレージの不整合:DB を消してから S3 の削除に失敗すると、S3 にファイルが残ります(逆もあり得ます)。定期的に「DBに無いファイル」を洗い出す仕組みを検討します。
- 署名付きURLの共有:有効期限内なら、URLを知っている人は誰でもダウンロードできます。チャットなどにURLを貼る運用は避け、共有はシステムの権限設定で行います。
- S3 互換サービスの差:S3 互換をうたうサービスでも、条件付き書き込みや署名の細かな仕様が違うことがあります。今回の検証でも、古い版の検証用ツールでは、最新の SDK が作る署名付きURLが受け付けられませんでした。
- SQLite は1台のサーバー向け:サーバーを複数台にする場合は、データベースを PostgreSQL などに移す必要があります(第2回で Drizzle ORM を選んだ理由の1つです)。
発注者向けメモ:「本番環境」と「運用」は別に見積もる
開発会社の見積もりで、「開発」と「本番環境の構築」「運用・保守」が分かれているのは自然なことです。開発環境で動くことと、本番で安全に動き続けることは、別の作業だからです。
| 確認すること | 影響 |
|---|---|
| どこで動かすか(AWS・社内サーバー等)、誰のアカウントか | 費用の支払い、障害時の対応、契約終了時の引き継ぎ |
| バックアップの頻度・保存期間・復元の手順 | 「いつの時点まで戻せるか」。復元の練習をしているか |
| 権限・秘密情報の管理 | アクセスキーの有無、誰が管理画面に入れるか |
| 監視・ログ | 障害に誰がいつ気づくか、操作の記録をどれだけ残すか |
| ライブラリの更新 | PDF処理のライブラリ(pdf-lib)は長く更新がない。更新の方針 |
- AWS アカウントは発注者名義で:開発会社の名義のアカウントで本番を動かすと、契約終了時にデータの引き継ぎが難しくなります。
- 「復元できること」を受け入れ条件に:バックアップを取っているだけでなく、実際に戻せることを確認してもらいます。
- 運用の範囲を契約で明確に:障害対応の時間帯、ライブラリの更新、容量の監視などが、保守契約に含まれるかを確認します。
打ち合わせでは、次のように聞いてみてください。
- 「S3 の認証情報は、アクセスキーですか、IAM ロールですか?誰が管理しますか?」
- 「バケットの公開設定と、ダウンロードURLの有効期限はどうなっていますか?」
- 「バックアップからの復元手順を、一度実際に試してもらえますか?」
- 「本番環境の費用(サーバー・S3・通信量)の目安と、増える条件を教えてください」
- 「使っているライブラリの更新が止まった場合、どう対応しますか?」
連載の振り返り:全12回の目次
| 回 | タイトル | 主な内容 |
|---|---|---|
| 第0回 | 全体像とNuxt 4プロジェクトの土台づくり | 連載の全体像、共通レイアウト、runtimeConfig と .env |
| 第1回 | PDFをアップロードして安全に保存する | サイズ・形式・中身の検証、UUIDで保存、StorageAdapter |
| 第2回 | ファイル情報をDBに持ち、一覧を表示する | Drizzle ORM と SQLite、ページ数の取得 |
| 第3回 | 検索・絞り込み・ページングをつける | zod によるクエリ検証、タグ、期間 |
| 第4回 | ログイン機能を入れる | nuxt-auth-utils、パスワードのハッシュ化 |
| 第5回 | 権限管理と「認可つきダウンロード」 | ロール、requireRole、日本語ファイル名 |
| 第6回 | ブラウザでPDFをプレビューする | pdfjs-dist、ワーカー、日本語の cMap |
| 第7回 | PDFの結合と分割 | pdf-lib、元を上書きしない版管理 |
| 第8回 | 注釈・スタンプ・署名画像を貼る | 座標変換、日本語フォントの埋め込み、日付印 |
| 第9回 | PDFフォーム(AcroForm)に入力する | 入力欄の一覧、日本語での入力、フラット化 |
| 第10回 | 見積書・請求書をデータから生成する | 税率ごとの消費税計算、単体テスト、改ページ |
| 第11回 | 本番運用へ:S3への切り替えとデプロイ(この記事) | S3、署名付きURL、本番での起動、バックアップ |
本番運用までに残っている作業
このサンプルは、連載で仕組みを説明するために、本番で必要な作業の一部を省略しています。実際の業務システムにするには、少なくとも次の作業が必要です。
| 分類 | 残っている作業 |
|---|---|
| 認証・権限 | 社内ID(Microsoft Entra ID・Google Workspace 等)との連携、多要素認証、部署単位の権限、ログイン試行の回数制限 |
| 操作の記録 | 誰がいつ何を見た・変えた・ダウンロードしたかの監査ログ |
| 版管理 | 「この版に戻す」操作、古い版の削除ルール、同時編集の衝突検知(第7・8回) |
| PDF処理 | 回転ページへの書き込み(第8回)、しおり・フォームを残す結合(第7回)、大きなPDFの処理の順番待ち(キュー)、パスワード付きPDFの扱い |
| 帳票 | 文書番号の自動採番、内税・値引き、社印やロゴ、経理部門と確定した記載項目(第10回) |
| ライブラリ | pdf-lib は npm の最新版が2021年公開のまま。更新が続いているフォーク(@cantoo/pdf-lib など)への切り替えを、互換性を確かめたうえで検討 |
| インフラ・運用 | 複数台構成(DBを PostgreSQL へ)、監視・アラート、バックアップと復元の訓練、脆弱性診断、ウイルスチェック |
| 画面 | スマートフォン対応、アクセシビリティ、Firefox・Safari での表示確認(第6回) |
📰 出典:@cantoo/pdf-lib(pdf-lib のフォーク)GitHub リポジトリ
まとめ
最終回の第11回では、PDF本体の保存先を S3 に切り替え、本番で動かすための設定をまとめました。
- 第1回から保存先を
StorageAdapterに分けていたので、S3 版の追加と、ダウンロードの1か所の変更だけで切り替えられた - ダウンロードは、認可を確認してから短時間の署名付きURLで S3 から直接返す。プレビューはサーバー経由のまま
- 認証情報はコード・設定ファイルに書かず、IAM ロール(または AWS SDK 標準の環境変数)で渡す
- 本番では
.envは読まれない。環境変数・パブリックアクセスのブロック・最小権限・バックアップを運用として設計する
12回にわたって、「集める・探す・見る・直す・作る・守る」を1つずつ小さく作ってきました。PDFの機能は、一見シンプルでも、対象のPDFや運用によって難しさが大きく変わります。発注の際は、この連載の「発注者向けメモ」を、開発会社との打ち合わせの確認リストとして使ってみてください。
この連載の記事一覧
この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Nuxtで作るPDF管理システム 第0回】全体像とNuxt 4プロジェクトの土台づくり
- 【Nuxtで作るPDF管理システム 第1回】PDFをアップロードして安全に保存する
- 【Nuxtで作るPDF管理システム 第2回】ファイル情報をDBに持ち、一覧を表示する
- 【Nuxtで作るPDF管理システム 第3回】検索・絞り込み・ページングをつける
- 【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる
- 【Nuxtで作るPDF管理システム 第5回】権限管理と「認可つきダウンロード」
- 【Nuxtで作るPDF管理システム 第6回】ブラウザでPDFをプレビューする
- 【Nuxtで作るPDF管理システム 第7回】PDFの結合と分割
- 【Nuxtで作るPDF管理システム 第8回】注釈・スタンプ・署名画像を貼る
- 【Nuxtで作るPDF管理システム 第9回】PDFフォーム(AcroForm)に入力する
- 【Nuxtで作るPDF管理システム 第10回】見積書・請求書をデータから生成する
- 【Nuxtで作るPDF管理システム 第11回】本番運用へ:S3への切り替えとデプロイ(この記事)









