From 1b035de16c1172a9316cc13f87aa42087cca7b16 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 13:48:23 +0900 Subject: [PATCH] =?UTF-8?q?Docker=20=EB=9F=B0=ED=83=80=EC=9E=84=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EB=B3=80=EC=88=98=20=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/changelog.md | 4 +++ docs/deploy.md | 1 + docs/history.md | 6 ++++ docs/spec.md | 1 + docs/update.md | 6 ++++ package-lock.json | 4 +-- package.json | 2 +- server/api/auth/bootstrap-status.get.js | 3 +- server/api/auth/email-otp/request.post.js | 7 ++-- .../api/auth/password-reset/confirm.post.js | 4 +-- server/api/auth/signup.post.js | 5 +-- server/repositories/postgres-client.js | 7 ++-- server/routes/admin/api/auth/login.post.js | 6 ++-- server/utils/admin-auth.js | 7 ++-- server/utils/member-auth.js | 4 +-- server/utils/resend-mail.js | 5 +-- server/utils/runtime-env.js | 36 +++++++++++++++++++ 17 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 server/utils/runtime-env.js diff --git a/docs/changelog.md b/docs/changelog.md index 851a579..9003891 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,9 @@ # 업데이트 요약 +## v1.0.5 + +- Docker 운영 컨테이너가 빌드 시점 설정 대신 `.env.production`의 런타임 환경 변수를 우선 읽도록 보강. + ## v1.0.4 - owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강. diff --git a/docs/deploy.md b/docs/deploy.md index 79fdb2f..ad7b943 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -171,6 +171,7 @@ docker compose --env-file .env.production up -d --build - 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 - 운영 환경에서 `DATABASE_URL`이 없으면 샘플 콘텐츠로 대체하지 않고 서버 오류로 실패 +- Docker 운영 컨테이너는 `.env.production`의 서버 환경 변수를 런타임 `process.env`에서 우선 읽는다. ### 이메일 인증(Resend, 선택) diff --git a/docs/history.md b/docs/history.md index de2611f..cd2eaa3 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-14 v1.0.5 + +### Docker 런타임 환경 변수 우선 + +Nuxt `runtimeConfig`에 `process.env.*`를 직접 대입하면 Docker 이미지 빌드 시점에 값이 비어 있는 상태로 번들에 들어갈 수 있다. 운영 컨테이너는 `.env.production`을 런타임에 주입하므로, 서버 전용 비밀값과 DB 연결값은 `useRuntimeConfig()`보다 `process.env`를 우선 조회하도록 공통 유틸을 추가했다. 이 기준은 관리자 최초 로그인, 세션 서명, DB 연결, 이메일 OTP 설정에 적용한다. + ## 2026-05-14 v1.0.4 ### 최초 관리자 기준을 owner/admin 존재 여부로 변경 diff --git a/docs/spec.md b/docs/spec.md index 05ca5a9..1fc94fa 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -595,6 +595,7 @@ components/content/ - 사용자 설정 화면은 공개 본문 폭에 맞춰 프로필 요약을 상단에 두고, 프로필 입력과 활동 정보를 하단에 배치한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다. - 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다. - 회원 세션 서명은 `MEMBER_SESSION_SECRET`만 사용하며, 값이 없으면 서버 오류로 실패한다. +- Docker 운영 서버 환경 변수는 이미지 빌드 시점 `runtimeConfig`보다 컨테이너 런타임 `process.env` 값을 우선한다. --- diff --git a/docs/update.md b/docs/update.md index 33ea1db..84c9824 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.0.5 + +- Docker 운영 이미지에서 빌드 시점 `runtimeConfig`가 비어도 컨테이너 런타임 환경 변수(`DATABASE_URL`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `MEMBER_SESSION_SECRET`, Resend 설정)를 우선 읽도록 수정. +- 서버 런타임 환경 변수 조회 유틸 추가. +- 패키지 버전 `1.0.5`로 갱신. + ## v1.0.4 - 최초 관리자 부트스트랩 기준을 전체 사용자 수가 아니라 owner/admin 존재 여부로 변경. diff --git a/package-lock.json b/package-lock.json index 0afbd53..48fb606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.0.4", + "version": "1.0.5", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 95cde6d..5ac137d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.0.4", + "version": "1.0.5", "private": true, "type": "module", "imports": { diff --git a/server/api/auth/bootstrap-status.get.js b/server/api/auth/bootstrap-status.get.js index d59ea5f..f6fbeb0 100644 --- a/server/api/auth/bootstrap-status.get.js +++ b/server/api/auth/bootstrap-status.get.js @@ -1,5 +1,6 @@ import { getMemberBootstrapState } from '../../repositories/member-repository' import { isResendConfigured } from '../../utils/resend-mail' +import { getRuntimeEnvValue } from '../../utils/runtime-env' /** * 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다. @@ -8,7 +9,7 @@ import { isResendConfigured } from '../../utils/resend-mail' export default defineEventHandler(async () => { const base = await getMemberBootstrapState() const config = useRuntimeConfig() - const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim()) + const hasPepper = Boolean(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()) const emailOtpConfigured = isResendConfigured(config) && hasPepper return { ...base, diff --git a/server/api/auth/email-otp/request.post.js b/server/api/auth/email-otp/request.post.js index 89e66e5..e05469b 100644 --- a/server/api/auth/email-otp/request.post.js +++ b/server/api/auth/email-otp/request.post.js @@ -13,6 +13,7 @@ import { } from '../../../repositories/email-otp-repository' import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp' import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail' +import { getRuntimeEnvValue } from '../../../utils/runtime-env' const bodySchema = z.object({ email: z.string().trim().email(), @@ -28,7 +29,7 @@ const MAX_SENDS_PER_HOUR = 5 * @returns {string} */ const resolveOtpPepper = (config) => { - const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim() + const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim() if (!pepper) { throw createError({ statusCode: 500, @@ -150,8 +151,8 @@ export default defineEventHandler(async (event) => { try { await sendResendEmail({ - apiKey: String(config.resendApiKey).trim(), - from: String(config.resendFromEmail).trim(), + apiKey: getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey').trim(), + from: getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail').trim(), to: email, subject, html diff --git a/server/api/auth/password-reset/confirm.post.js b/server/api/auth/password-reset/confirm.post.js index 9f56634..f24b484 100644 --- a/server/api/auth/password-reset/confirm.post.js +++ b/server/api/auth/password-reset/confirm.post.js @@ -3,6 +3,7 @@ import { z } from 'zod' import { createError, readBody } from 'h3' import { updateMemberPasswordByEmail } from '../../../repositories/member-repository' import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository' +import { getRuntimeEnvValue } from '../../../utils/runtime-env' const bodySchema = z.object({ email: z.string().trim().email(), @@ -24,8 +25,7 @@ export default defineEventHandler(async (event) => { }) } - const config = useRuntimeConfig() - const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim() + const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim() if (!pepper) { throw createError({ statusCode: 500, diff --git a/server/api/auth/signup.post.js b/server/api/auth/signup.post.js index 5187727..cd47a4c 100644 --- a/server/api/auth/signup.post.js +++ b/server/api/auth/signup.post.js @@ -6,6 +6,7 @@ import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repositor import { setMemberSession } from '../../utils/member-auth' import { setAdminSession } from '../../utils/admin-auth' import { isResendConfigured } from '../../utils/resend-mail' +import { getRuntimeEnvValue } from '../../utils/runtime-env' const signupSchema = z.object({ username: z.string().trim().min(1), @@ -24,7 +25,7 @@ const isSignupOtpRequired = (config, bootstrap) => { if (bootstrap.needsAdminSetup) { return false } - const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim()) + const hasPepper = Boolean(getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim()) return isResendConfigured(config) && hasPepper } @@ -77,7 +78,7 @@ export default defineEventHandler(async (event) => { message: '이메일 인증번호를 입력해 주세요.' }) } - const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim() + const pepper = getRuntimeEnvValue('EMAIL_OTP_PEPPER', 'emailOtpPepper', getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret')).trim() const verify = await verifyAndConsumeEmailOtp({ email: emailNorm, purpose: 'signup', diff --git a/server/repositories/postgres-client.js b/server/repositories/postgres-client.js index fb8e772..77bb35a 100644 --- a/server/repositories/postgres-client.js +++ b/server/repositories/postgres-client.js @@ -1,4 +1,5 @@ import postgres from 'postgres' +import { getRuntimeEnvValue } from '../utils/runtime-env' let client = null @@ -13,9 +14,9 @@ const isProductionRuntime = () => process.env.NODE_ENV === 'production' * @returns {ReturnType | null} PostgreSQL 클라이언트 */ export const getPostgresClient = () => { - const config = useRuntimeConfig() + const databaseUrl = getRuntimeEnvValue('DATABASE_URL', 'databaseUrl').trim() - if (!config.databaseUrl) { + if (!databaseUrl) { if (isProductionRuntime()) { throw new Error('DATABASE_URL_REQUIRED') } @@ -24,7 +25,7 @@ export const getPostgresClient = () => { } if (!client) { - client = postgres(config.databaseUrl, { + client = postgres(databaseUrl, { max: 5, idle_timeout: 20, connect_timeout: 10 diff --git a/server/routes/admin/api/auth/login.post.js b/server/routes/admin/api/auth/login.post.js index db5ff25..1dfbcdf 100644 --- a/server/routes/admin/api/auth/login.post.js +++ b/server/routes/admin/api/auth/login.post.js @@ -4,6 +4,7 @@ import bcrypt from 'bcrypt' import { safeCompare, setAdminSession } from '../../../../utils/admin-auth' import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsertBootstrapOwner } from '../../../../repositories/member-repository' import { setMemberSession } from '../../../../utils/member-auth' +import { getRuntimeEnvValue } from '../../../../utils/runtime-env' const loginSchema = z.object({ email: z.string().email(), @@ -26,9 +27,8 @@ const createBootstrapUsername = (email) => { * @returns {Promise} 생성된 관리자 */ const createBootstrapAdminUser = async (credentials) => { - const config = useRuntimeConfig() - const adminEmail = String(config.adminEmail || '').trim().toLowerCase() - const adminPassword = String(config.adminPassword || '') + const adminEmail = getRuntimeEnvValue('ADMIN_EMAIL', 'adminEmail').trim().toLowerCase() + const adminPassword = getRuntimeEnvValue('ADMIN_PASSWORD', 'adminPassword') if (!adminEmail || !adminPassword || credentials.email.trim().toLowerCase() !== adminEmail || !safeCompare(credentials.password, adminPassword)) { return null diff --git a/server/utils/admin-auth.js b/server/utils/admin-auth.js index 76f1394..9630783 100644 --- a/server/utils/admin-auth.js +++ b/server/utils/admin-auth.js @@ -1,5 +1,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto' import { createError, deleteCookie, getCookie, setCookie } from 'h3' +import { getRuntimeEnvValue } from './runtime-env' const adminSessionCookieName = 'sori_admin_session' const sessionMaxAge = 60 * 60 * 12 @@ -9,16 +10,16 @@ const sessionMaxAge = 60 * 60 * 12 * @returns {string} 세션 서명 비밀값 */ const getSessionSecret = () => { - const config = useRuntimeConfig() + const adminPassword = getRuntimeEnvValue('ADMIN_PASSWORD', 'adminPassword') - if (!config.adminPassword) { + if (!adminPassword) { throw createError({ statusCode: 500, message: '관리자 비밀번호 환경 변수가 없습니다.' }) } - return config.adminPassword + return adminPassword } /** diff --git a/server/utils/member-auth.js b/server/utils/member-auth.js index 684fb60..6dc44b1 100644 --- a/server/utils/member-auth.js +++ b/server/utils/member-auth.js @@ -1,5 +1,6 @@ import { createHmac, timingSafeEqual } from 'node:crypto' import { createError, deleteCookie, getCookie, setCookie } from 'h3' +import { getRuntimeEnvValue } from './runtime-env' const memberSessionCookieName = 'sori_member_session' const sessionMaxAge = 60 * 60 * 24 * 14 @@ -9,8 +10,7 @@ const sessionMaxAge = 60 * 60 * 24 * 14 * @returns {string} 세션 서명 비밀값 */ const getSessionSecret = () => { - const config = useRuntimeConfig() - const sessionSecret = String(config.memberSessionSecret || '').trim() + const sessionSecret = getRuntimeEnvValue('MEMBER_SESSION_SECRET', 'memberSessionSecret').trim() if (!sessionSecret) { throw createError({ diff --git a/server/utils/resend-mail.js b/server/utils/resend-mail.js index c427bdd..e090276 100644 --- a/server/utils/resend-mail.js +++ b/server/utils/resend-mail.js @@ -1,4 +1,5 @@ import { createError } from 'h3' +import { getRuntimeEnvValue } from './runtime-env' /** * Resend가 서버 설정으로 사용 가능한지 @@ -6,8 +7,8 @@ import { createError } from 'h3' * @returns {boolean} */ export const isResendConfigured = (config) => { - const key = String(config?.resendApiKey || '').trim() - const from = String(config?.resendFromEmail || '').trim() + const key = getRuntimeEnvValue('RESEND_API_KEY', 'resendApiKey', String(config?.resendApiKey || '')).trim() + const from = getRuntimeEnvValue('RESEND_FROM_EMAIL', 'resendFromEmail', String(config?.resendFromEmail || '')).trim() return Boolean(key && from) } diff --git a/server/utils/runtime-env.js b/server/utils/runtime-env.js new file mode 100644 index 0000000..c2d7152 --- /dev/null +++ b/server/utils/runtime-env.js @@ -0,0 +1,36 @@ +/** + * 서버 런타임 환경 변수 값을 조회한다. + * @param {string} envName - process.env 변수명 + * @param {string} configName - Nuxt runtimeConfig 키 + * @param {string} fallback - 기본값 + * @returns {string} 환경 변수 값 + */ +export const getRuntimeEnvValue = (envName, configName, fallback = '') => { + const directValue = process.env[envName] + if (typeof directValue === 'string' && directValue.length > 0) { + return directValue + } + + const config = useRuntimeConfig() + const configValue = config?.[configName] + + return typeof configValue === 'string' && configValue.length > 0 + ? configValue + : fallback +} + +/** + * 숫자형 서버 런타임 환경 변수 값을 조회한다. + * @param {string} envName - process.env 변수명 + * @param {string} configName - Nuxt runtimeConfig 키 + * @param {number} fallback - 기본값 + * @returns {number} 환경 변수 숫자 값 + */ +export const getRuntimeEnvNumber = (envName, configName, fallback) => { + const value = getRuntimeEnvValue(envName, configName, '') + const parsed = Number(value) + + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : fallback +}