背景・イントロ
エンジニアやクリエイターの皆さんは、ポートフォリオや個人のホームページを作ったことはありますか?
自分が過去に制作したアレコレをまとめたいと思い、いざ作ってみるものの、長期間運用していく中でコンテンツ更新が大変だと思うことが個人的には何度もありました。
これまで試してきた方法として
- ノーコードツールで、レイアウトをドラッグアンドドロップして配置を組み替える
- HTMLを直接触って更新する
- CSVなど表データを用意し、GASやAPI経由で呼び出して配置する
これらは、作り立ての頃は無意識に更新もできてとても快適なのですが、コードを整えたりマウスで微調整したりと本来の更新作業とは別のタスクが発生してしまい、後になってきて面倒くさいからなぁと思ってしまい気づけば更新が止まってしまいがち...
WordPressを始めとする CMS(Content Management System) はこうした継続的なコンテンツ運用 という点で非常に優れた仕組みだと思います。
そこで今回は Next.js + Echo + GORM + Docker で自身の制作物をまとめる事に特化したCMS兼ポートフォリオのシステムをAmazon Lightsailインスタンスで公開するまでを行ったので紹介します。
完成品
完成品は次のリンクで現在公開しております(記事投稿時点)
また、こちらのシステムはGitHubでコードを公開しており、バックエンドとデータベース(CREATE TABLE等カラム情報)、フロントエンドが入ったモノレポ構成になっています。
したがって、 docker compose up -dで起動するところが今回のゴールです。
スクリーンショット(管理画面)
↑ 管理画面も公開しております(閲覧のみ可)。公開しても大丈夫なくらい今回はセキュリティも考慮した構成としています。
全体像
運用システム(アプリケーション側)の構成図です。
アプリケーション側
フロントエンドではユーザーインターフェースと認証を担当し、APIリクエストをBFFを経由したうえで、バックエンドからレスポンスを得ます。バックエンドはREST APIとしてリソースの管理を行います。
REST APIのうち、コンテンツ更新に関わる(例: DELETE, PUT, POST)ものにはトークン認証を要求することで未認証のユーザーを弾くのですが、そのトークン付与の役割を担っているのがBFFにあたるところです。
BFFは今回独立したサーバーではないのですが、Next.jsのApp RouterでWebリクエスト・レスポンスが書けることを利用して、フロントエンド専用のバックエンド的役割を担うレイヤとして実装しており、本記事の便宜上BFFと呼ばせていただきました。
運用側
本システムは、フロントエンド・バックエンド・DB定義・インフラ設定の全てを1つのリポジトリで管理する形を取っており、開発するローカル環境・本番環境の二者間で差が出ないようにしています。
CIはGitHub Actionsを使い、次の部分を担います。
- フロントエンド・バックエンドのサービスのイメージをそれぞれビルド
- GitHub Container Registry(GHCR)へプッシュ
本番環境は今回Amazon Lightsailインスタンス上に構築しており、latestのイメージをpullした後、Docker Composeで再起動(Recreate)することにより反映します。
本番環境への反映を明示的に制御できるので、ヒューマンエラーもここで何とかなります
環境変数(OAuth関連や管理者権限用のシークレット等)はリポジトリやイメージにもバンドルせず、本番環境で管理します。
技術選定など
当方、まだ大学生であり、資金に余裕があるわけではありません。
そのため、月額のコストは極力抑えつつ、学習・運用の両方で有効な構成を意識して選定を行いました。
フロントエンド: Next.js
- ルートハンドラを使ったBFFの実装ができる
- SSR / CSR の切り替えがしやすい
- ドキュメントが豊富 (個人的に重要)
OAuth認証についてはNextAuthを採用しました。
バックエンド: Echo + GORM
バックエンドはGo言語を採用しました。
- デプロイが簡単
- 1つのバイナリファイルで動かせる
個人的に書いてみたかった言語ランキング上位だったのもあります
フレームワークはEchoを採用し、ORMは GORM (Gen) を採用しました。
この2つを活用してREST APIをシンプルに実装できるようにしました。
データベース:PostgreSQL
オープンソースなRDBです。今回一番オーバースペックな子です。
インフラ:Docker + Caddy + Amazon Lightsail
今回は、開発環境・本番環境共にDocker Composeを前提とした構成にします。Dockerがあれば環境の差が発生しないラインを目指しました。(途中でデスクトップ開発からノートパソコン開発に乗り換えましたが問題が起こらない強み)
また、WebサーバはCaddyを今回採用しました。Let's Encryptに対応しており、証明書取得を自動で行ってくれます。
本番環境はAmazon Lightsailインスタンスを利用しました。コンテナの方法もありましたが、DBも別口で同時に用意しないといけなさそうですのでコストが...
→ VMならインスタンス内でアプリケーションとデータベースの両方を保持可能!
結果的に運用コストは インスタンスの維持費 (+ ドメイン)になります。
開発環境の構築
Docker・Docker Composeが利用可能な環境を前提とします。
開発用にcompose.dev.ymlを作成します。
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app_password
POSTGRES_DB: appdb
ports:
- "5432:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U app -d appdb" ]
interval: 5s
timeout: 5s
retries: 20
web:
build:
context: .
dockerfile: ./frontend/Dockerfile.dev
volumes:
- ./frontend:/app
depends_on:
db:
condition: service_healthy
backend:
image: golang:1.23-bookworm
working_dir: /app
command: [ "bash", "-c", "go mod download && go install github.com/air-verse/air@v1.52.3 && air -c .air.toml" ]
volumes:
- ./backend:/app
- ./uploads:/uploads
depends_on:
db:
condition: service_healthy
caddy:
image: caddy:2
ports:
- "3000:3000"
volumes:
- ./Caddyfile.dev:/etc/caddy/Caddyfile:ro
depends_on:
web:
condition: service_started
backend:
condition: service_started
FROM node:20-bookworm-slim
WORKDIR /app
RUN corepack enable
CMD ["sh","-lc","pnpm install && node node_modules/next/dist/bin/next dev -H 0.0.0.0 -p 3000"]
webとbackendはdbを、caddyはwebとbackendが起動してからという関係を組みました。これで不要なエラーは避けられるようにしております。
認証・認可(セキュリティ)
認証(Authentication)・認可(Authorization)についてです。
今回ので言いますと
- 認証: NextAuth(Google OAuth)を使って、ログイン状態の管理はフレームワークに委ね、既存のサービスのアカウントのメールアドレスを使った判定
- 認可:管理者ロールが与えられたユーザのみが
/api/worksや/api/works/:idのPOSTやPUT、DELETEが実行可能になるよう制限
にあたります。
認証
NextAuth v5を使って実装しました。
ホワイトリスト制にしており、環境変数でカンマ区切りされたメールアドレスのみがadminロールとして編集権限が持てるようになっています。
export const { auth, handlers, signIn, signOut } = NextAuth({
// 中略
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async signIn({ profile }) {
return isAllowedEmail(profile?.email);
},
async session({ session }) {
if (session.user?.email && isAllowedEmail(session.user.email)) {
session.user.role = "admin";
} else if (session.user) {
session.user.role = "guest";
}
return session;
},
},
// 中略
});
現在はisAllowedEmail関数でbooleanを返すようになっており、サインイン時に登録されていないユーザーはfalseとなり、サインインのフローが中断されるようになっています。async signIn({ profile })
認可
コンテンツの更新は管理者権限(ログインしており、尚且つadminロール)である必要があります。クライアントにはREST APIの形でGETも含めて公開しています。
しかし、ユーザーがバックエンドの操作系エンドポイントに直接アクセスできる状態は望ましくないため、BFFを経由した場合のみシークレットを付与する構成にします。そして、バックエンド側でヘッダのシークレットを検証するフローを実装します。
const ADMIN_HEADER = "X-Admin-Secret";
const ADMIN_SECRET = process.env.ADMIN_SECRET;
async function proxy(request: Request) {
const { searchParams } = new URL(request.url);
const apiUrl = searchParams.get("api_url");
const session = await auth();
// 管理者以外はここで弾きます
if (!session) return new Response("Unauthorized", { status: 401 });
if (!isAllowedEmail(session.user?.email) || session.user?.role !== "admin")
return new Response("Forbidden", { status: 403 });
// 中略
const upstreamUrl = new URL(apiUrl, BACKEND_BASE_URL);
const upstreamRequest = request.clone();
const bodyAllowed = request.method !== "GET" && request.method !== "HEAD";
// secretをヘッダーに追加
const headers = new Headers(upstreamRequest.headers);
headers.set(ADMIN_HEADER, ADMIN_SECRET);
headers.delete("host");
const fetchInit: RequestInit & { duplex?: "half" } = {
method: upstreamRequest.method,
headers,
redirect: "manual",
};
// bodyがある場合
if (bodyAllowed) {
fetchInit.body = upstreamRequest.body;
fetchInit.duplex = "half";
}
// バックエンド側へfetch
const upstreamResponse = await fetch(upstreamUrl, fetchInit);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
headers: upstreamResponse.headers,
});
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
X-Admin-Secretというヘッダを管理者からのリクエストに追加で付与し、バックエンドへfetchを行います。
バックエンド側の検証を書き足すのを忘れずに!
func (pSrv *server) requireAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
secret := c.Request().Header.Get("X-Admin-Secret")
if secret == "" || secret != pSrv.adminSecret {
return c.String(http.StatusForbidden, "admin authentication failed")
}
return next(c)
}
}
構造体にADMIN_SECRETを保持させ、X-Admin-Secretと一致しない場合は即座に403エラーを返します。
後は、権限を制限したいエンドポイント・リクエストメソッドのハンドラにこの関数を下のように挟むことで完成です。
router := echo.New()
epImages := router.Group("/images")
epImages.GET("", pSrv.handleGetImages) // 権限フリー🔓
epImages.POST("", pSrv.requireAdmin(pSrv.handleUploadImage)) // 制限🔒
epImages.GET("/:id", pSrv.handleGetImage) // 権限フリー🔓
epImages.GET("/:id/raw", pSrv.handleServeImage) // 権限フリー🔓
epImages.DELETE("/:id", pSrv.requireAdmin(pSrv.handleDeleteImage)) // 制限🔒
画面の設計
これで、「管理者権限を持つ人が編集し、誰もがコンテンツを取得可能」になりました。
次はNext.jsで画面を組み立てていきます。
...ここで、実装前にワイヤーフレームを予め作っておくことがおすすめです。
自分が何を見せたいかを先に言語化して、吟味することが可能なので、必要なAPIエンドポイントやデータベースのカラムなどの決定も行いやすいです。
- 一覧で見せる情報 (サムネ・タイトル・技術スタック等)
- 詳細画面で見せる情報 (説明用本文・関連リンク)
- 画像の表示数 (1枚 or 複数枚)
私の場合はこの辺りをレイアウトと併せて考えたのち、実装に入りました。
↓ (自分も頭を悩ませていたらしい)
コンテンツはどう見せるかで印象がかなり変わると思います。このセクションはじっくり時間を確保して考えていくのがおすすめです。
運用に向けて
- Docker Compose + Next.jsでフロントエンドを構築
- Echo + GORMで、REST APIの構築
- 認証と認可を実装し、管理画面でCRUD操作が可能になるよう構築
- GET APIで得られる情報をもとにフロント側のUIを構築
- GitHub Actionsでコンテナをプッシュ、本番環境へデプロイ
GHCRへプッシュする際は、シークレットなどの環境変数がバンドルされてないかを確認したうえで行ってください!
作ってみて感じたこと
認証と認可の分離について
今回は最初から、管理画面を制限なく公開することを視野にいれて実装していました。
- ログインできるか(管理者 or 管理者以外)
- 更新してしまってもよいのか(シークレットの検証)
この認証・認可の部分は分離しているので、今後の機能改修もしやすいですし、ごっちゃになっていた部分が多少整理できたかなと感じています。
カラムの設計について
デザインのタイミングでは公開・非公開などのカラムも考えていたりと、頭の中では機能が増えたり減ったりして収集がつきにくくなってしまいます。予め、ワイヤーフレームを設計しておくことはその情報の整理にもなるため、なるべく早いタイミングで作成することがおすすめです。
まとめ
今回は、Next.js + Go(Echo) + Dockerで更新しやすい、CMS兼ポートフォリオを作ってみました。参考値ですが、制作期間は設計が1週間、実装が2週間でした。まだ、管理画面の一部UPDATEの機能改善等もありますので今後も更新予定です。
本記事では、その中でも、
- 管理画面を敢えて傍に置くことで更新が止まりにくくする(+自分への戒め)
- 認証・認可も設計して安全な運用
- クライアント側にシークレットを公開せず、BFFを挟むことで対策
- Docker composeで、本番・環境で差が発生しない環境構築
これらをポイントとして紹介させていただきました。
本記事が、ポートフォリオを作りたいけど更新が続かない、という悩みをお持ちの方の一助になりますと幸いです。
リポジトリやコンテナパッケージも公開していますので、気になった方がいましたらぜひお手元で動かしてみてください。
もし、コンポーネントの設計まわりなども需要がありましたら、別記事にてまとめようと思います。
それではよいお年を

