Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ MODERATION_SWEEP_INTERVAL_MS=900000
# 심사 시연 데이터를 준비할 때만 사용하는 계정입니다. 저장소에 실제 비밀번호를 넣지 않습니다.
APP_REVIEW_EMAIL=
APP_REVIEW_PASSWORD=
# 앱과 공개 약관 및 고객지원 페이지에 함께 표시할 실제 수신 가능한 메일입니다.
SUPPORT_EMAIL=
REQUEST_BODY_LIMIT=1mb
MOMENT_PHOTO_MAX_FILE_SIZE_MB=10
REVERSE_GEOCODING_BASE_URL=https://nominatim.openstreetmap.org
Expand All @@ -40,16 +42,17 @@ AUTH_RATE_LIMIT_MAX=10
# emails from the same IP. Looser than the per-account limit above.
AUTH_RATE_LIMIT_IP_WINDOW_MS=900000
AUTH_RATE_LIMIT_IP_MAX=40
# Direct server access must keep this at 0. Set the exact reverse-proxy hop count when used.
# Direct server access must keep this at 0. api.soundlog.p-e.kr의 nginx 뒤에서는 1로 설정합니다.
TRUST_PROXY_HOPS=0
UPLOAD_DIRECTORY=uploads
UPLOAD_PUBLIC_BASE_URL=http://localhost:4000
USE_MOCK_DB=false

# Production checklist:
# NODE_ENV=production
# CLIENT_URLS=https://soundlog.shop,https://www.soundlog.shop
# After API DNS/HTTPS reverse proxy is ready:
# UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.shop
# CLIENT_URLS=https://api.soundlog.p-e.kr
# api.soundlog.p-e.kr의 HTTPS nginx가 준비된 운영 환경:
# UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.p-e.kr
# TRUST_PROXY_HOPS=1
# USE_MOCK_DB=false
# ALLOW_DEV_AUTH_FALLBACK=false
329 changes: 329 additions & 0 deletions .github/workflows/deploy-gcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,329 @@
name: Deploy API to production

on:
workflow_dispatch:
inputs:
image_tag:
description: Docker image tag. Defaults to the selected commit SHA.
required: false
type: string

concurrency:
group: deploy-api-production
cancel-in-progress: false

jobs:
verify:
name: Verify release candidate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.23.0

- name: Setup Node
uses: actions/setup-node@v4
with:
cache: pnpm
node-version: 22

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Prisma client
run: pnpm run db:generate

- name: Verify server
run: |
pnpm run typecheck
pnpm run build
pnpm run check:openapi-sync
pnpm run check:no-spotify-metadata
pnpm run test:coverage
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/soundlog_ci?schema=public
JWT_SECRET: soundlog-ci-test-secret
NODE_ENV: test
USE_MOCK_DB: 'true'

build-and-push:
name: Build and push Docker image
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
image_tag: ${{ steps.meta.outputs.image_tag }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Validate Docker Hub secrets
shell: bash
env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
set -euo pipefail
for name in DOCKERHUB_USERNAME DOCKERHUB_TOKEN; do
if [ -z "${!name}" ]; then
echo "Missing required secret: $name"
exit 1
fi
done

- name: Set image tag
id: meta
shell: bash
run: |
set -euo pipefail
image_tag="${GITHUB_SHA::12}"
if [ -n "${{ inputs.image_tag }}" ]; then
image_tag="${{ inputs.image_tag }}"
fi
if [[ ! "$image_tag" =~ ^[A-Za-z0-9_.-]{1,128}$ ]]; then
echo "Invalid Docker image tag: $image_tag"
exit 1
fi
echo "image_tag=$image_tag" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/soundlog-server:${{ steps.meta.outputs.image_tag }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
name: Deploy to api.soundlog.p-e.kr
needs: build-and-push
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Validate deployment secrets and production environment
shell: bash
env:
GCP_APP_DIR: ${{ secrets.GCP_APP_DIR }}
GCP_HOST: ${{ secrets.GCP_HOST }}
GCP_SSH_KEY: ${{ secrets.GCP_SSH_KEY }}
GCP_SSH_PORT: ${{ secrets.GCP_SSH_PORT }}
GCP_USER: ${{ secrets.GCP_USER }}
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
RELEASE_APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }}
RELEASE_APP_REVIEW_PASSWORD: ${{ secrets.APP_REVIEW_PASSWORD }}
RELEASE_MODERATION_ADMIN_KEY: ${{ secrets.MODERATION_ADMIN_KEY }}
RELEASE_SUPPORT_EMAIL: ${{ secrets.SUPPORT_EMAIL }}
run: |
set -euo pipefail
missing=()
for name in GCP_HOST GCP_USER GCP_SSH_KEY GCP_SSH_PORT GCP_APP_DIR PRODUCTION_ENV RELEASE_APP_REVIEW_EMAIL RELEASE_APP_REVIEW_PASSWORD RELEASE_MODERATION_ADMIN_KEY RELEASE_SUPPORT_EMAIL; do
if [ -z "${!name}" ]; then
missing+=("$name")
fi
done

required_env=(
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_DB
JWT_SECRET
)
for key in "${required_env[@]}"; do
value="$(sed -n "s/^${key}=//p" <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')"
if [ -z "$value" ]; then
missing+=("PRODUCTION_ENV.$key")
fi
done

if [ "${#missing[@]}" -gt 0 ]; then
printf 'Missing required deployment values: %s\n' "${missing[*]}"
exit 1
fi

if grep -qE '^(DOCKER_IMAGE|DATABASE_URL)=' <<< "$PRODUCTION_ENV"; then
echo 'PRODUCTION_ENV must not contain DOCKER_IMAGE or DATABASE_URL.'
exit 1
fi

if [ "${#RELEASE_MODERATION_ADMIN_KEY}" -lt 32 ]; then
echo 'MODERATION_ADMIN_KEY must contain at least 32 characters.'
exit 1
fi
if [ "${#RELEASE_APP_REVIEW_PASSWORD}" -lt 8 ]; then
echo 'APP_REVIEW_PASSWORD must contain at least 8 characters.'
exit 1
fi
if [[ "$RELEASE_SUPPORT_EMAIL" == *@soundlog.shop ]]; then
echo 'SUPPORT_EMAIL must use a mailbox with verified mail reception.'
exit 1
fi

- name: Create production env file
shell: bash
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
IMAGE_TAG: ${{ needs.build-and-push.outputs.image_tag }}
PRODUCTION_ENV: ${{ secrets.PRODUCTION_ENV }}
RELEASE_APP_REVIEW_EMAIL: ${{ secrets.APP_REVIEW_EMAIL }}
RELEASE_APP_REVIEW_PASSWORD: ${{ secrets.APP_REVIEW_PASSWORD }}
RELEASE_MODERATION_ADMIN_KEY: ${{ secrets.MODERATION_ADMIN_KEY }}
RELEASE_SUPPORT_EMAIL: ${{ secrets.SUPPORT_EMAIL }}
run: |
set -euo pipefail
mkdir -p .deploy
postgres_user="$(sed -n 's/^POSTGRES_USER=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')"
postgres_password="$(sed -n 's/^POSTGRES_PASSWORD=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')"
postgres_db="$(sed -n 's/^POSTGRES_DB=//p' <<< "$PRODUCTION_ENV" | tail -n 1 | tr -d '\r')"

database_url="$(
POSTGRES_USER="$postgres_user" POSTGRES_PASSWORD="$postgres_password" POSTGRES_DB="$postgres_db" node <<'NODE'
const encode = encodeURIComponent;
const user = encode(process.env.POSTGRES_USER || '');
const password = encode(process.env.POSTGRES_PASSWORD || '');
const database = encode(process.env.POSTGRES_DB || '');
process.stdout.write(`postgresql://${user}:${password}@db:5432/${database}?schema=public`);
NODE
)"

{
printf 'DOCKER_IMAGE=%s/soundlog-server:%s\n' "$DOCKERHUB_USERNAME" "$IMAGE_TAG"
printf '%s\n' "$PRODUCTION_ENV" | sed '/^DOCKER_IMAGE=/d;/^DATABASE_URL=/d;/^NODE_ENV=/d;/^CLIENT_URL=/d;/^CLIENT_URLS=/d;/^UPLOAD_PUBLIC_BASE_URL=/d;/^MODERATION_ADMIN_KEY=/d;/^MODERATION_ALERT_MODE=/d;/^APP_REVIEW_EMAIL=/d;/^APP_REVIEW_PASSWORD=/d;/^SUPPORT_EMAIL=/d;/^TRUST_PROXY_HOPS=/d'
printf 'CLIENT_URLS=https://api.soundlog.p-e.kr\n'
printf 'UPLOAD_PUBLIC_BASE_URL=https://api.soundlog.p-e.kr\n'
printf 'MODERATION_ADMIN_KEY=%s\n' "$RELEASE_MODERATION_ADMIN_KEY"
printf 'MODERATION_ALERT_MODE=cloud_logging\n'
printf 'APP_REVIEW_EMAIL=%s\n' "$RELEASE_APP_REVIEW_EMAIL"
printf 'APP_REVIEW_PASSWORD=%s\n' "$RELEASE_APP_REVIEW_PASSWORD"
printf 'SUPPORT_EMAIL=%s\n' "$RELEASE_SUPPORT_EMAIL"
printf 'TRUST_PROXY_HOPS=1\n'
printf 'DATABASE_URL=%s\n' "$database_url"
printf 'NODE_ENV=production\n'
} > .deploy/production.env

- name: Verify SSH target is the live API host
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.GCP_HOST }}
username: ${{ secrets.GCP_USER }}
key: ${{ secrets.GCP_SSH_KEY }}
port: ${{ secrets.GCP_SSH_PORT }}
script: |
set -eu
resolved_ip="$(getent ahostsv4 api.soundlog.p-e.kr | awk 'NR == 1 { print $1 }')"
public_ip="$(curl -fsS --max-time 10 https://api.ipify.org)"
if [ "$public_ip" != "$resolved_ip" ]; then
echo "Refusing deployment: SSH host $public_ip does not serve api.soundlog.p-e.kr ($resolved_ip)."
exit 1
fi
app_dir="${{ secrets.GCP_APP_DIR }}"
backup_dir="$app_dir/.deploy-backups/${{ github.run_id }}"
mkdir -p "$app_dir" "$backup_dir"
[ ! -f "$app_dir/.env" ] || cp -p "$app_dir/.env" "$backup_dir/.env"
[ ! -f "$app_dir/docker-compose.prod.yml" ] || cp -p "$app_dir/docker-compose.prod.yml" "$backup_dir/docker-compose.prod.yml"
sudo docker --version
sudo docker compose version

- name: Copy production compose file
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.GCP_HOST }}
username: ${{ secrets.GCP_USER }}
key: ${{ secrets.GCP_SSH_KEY }}
port: ${{ secrets.GCP_SSH_PORT }}
source: docker-compose.prod.yml
target: ${{ secrets.GCP_APP_DIR }}
overwrite: true

- name: Copy production env file
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.GCP_HOST }}
username: ${{ secrets.GCP_USER }}
key: ${{ secrets.GCP_SSH_KEY }}
port: ${{ secrets.GCP_SSH_PORT }}
source: .deploy/production.env
target: ${{ secrets.GCP_APP_DIR }}
strip_components: 1
overwrite: true

- name: Deploy with rollback backup
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.GCP_HOST }}
username: ${{ secrets.GCP_USER }}
key: ${{ secrets.GCP_SSH_KEY }}
port: ${{ secrets.GCP_SSH_PORT }}
command_timeout: 15m
script: |
set -eu
cd "${{ secrets.GCP_APP_DIR }}"
backup_dir=".deploy-backups/${{ github.run_id }}"
mv production.env .env

rollback() {
echo 'Deployment failed. Restoring the previous compose and environment files.'
[ ! -f "$backup_dir/.env" ] || cp -p "$backup_dir/.env" .env
[ ! -f "$backup_dir/docker-compose.prod.yml" ] || cp -p "$backup_dir/docker-compose.prod.yml" docker-compose.prod.yml
sudo docker compose -f docker-compose.prod.yml up -d db api || true
}
trap rollback INT TERM HUP EXIT

echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
sudo docker compose -f docker-compose.prod.yml pull api
sudo docker compose -f docker-compose.prod.yml up -d db api

for attempt in $(seq 1 24); do
if sudo docker compose -f docker-compose.prod.yml exec -T api \
node -e "fetch('http://127.0.0.1:4000/v1/health').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"; then
break
fi
if [ "$attempt" -eq 24 ]; then
sudo docker compose -f docker-compose.prod.yml logs --tail=160 api
exit 1
fi
sleep 10
done

sudo docker compose -f docker-compose.prod.yml exec -T api node scripts/check-production-env.mjs
sudo docker compose -f docker-compose.prod.yml exec -T api node dist/prisma/seed-review.js
sudo docker compose -f docker-compose.prod.yml exec -T \
-e PUBLIC_API_BASE_URL=http://127.0.0.1:4000 \
api node scripts/check-public-api-contract.mjs

trap - INT TERM HUP EXIT
echo "Deployment completed. Rollback backup: $backup_dir"

- name: Verify public HTTPS endpoints
shell: bash
env:
PUBLIC_API_BASE_URL: https://api.soundlog.p-e.kr
run: |
set -euo pipefail
node scripts/check-public-api-contract.mjs
for endpoint in /legal/privacy /legal/terms /support; do
curl --fail --silent --show-error --head --max-time 15 "${PUBLIC_API_BASE_URL}${endpoint}" > /dev/null
done
admin_status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 15 "${PUBLIC_API_BASE_URL}/v1/admin/moderation/reports")"
if [ "$admin_status" != '401' ]; then
echo "Expected unauthenticated moderation API to return 401, got $admin_status."
exit 1
fi
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Soundlog는 웹 서비스를 배포하지 않습니다. 배포된 iOS와 Android

- Swagger UI: `http://localhost:4000/docs`
- OpenAPI YAML: `http://localhost:4000/openapi.yaml`
- 운영 API: `https://api.soundlog.shop`
- 운영 API: `https://api.soundlog.p-e.kr`

Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능한 개발용 API를 사용할 수 있습니다.

Expand All @@ -99,7 +99,7 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능
- `ALLOW_DEV_AUTH_FALLBACK=false`
- 자체 이메일/비밀번호 로그인만 사용하며, 서버는 비밀번호 원문 대신 bcrypt hash만 저장
- `CLIENT_URLS`, `UPLOAD_PUBLIC_BASE_URL`, 앱의 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`은 HTTPS 도메인 사용
- 운영 클라이언트는 네이티브 앱입니다. 공개 API URL은 `https://api.soundlog.shop`입니다.
- 운영 클라이언트는 네이티브 앱입니다. 공개 API URL은 `https://api.soundlog.p-e.kr`입니다.
- `REQUEST_BODY_LIMIT`, `MOMENT_PHOTO_MAX_FILE_SIZE_MB`, `UPLOAD_DIRECTORY`는 운영 파일 업로드 정책에 맞게 조정
- iOS 앱 설정에 전체 ATS 예외를 넣지 않기

Expand All @@ -111,6 +111,8 @@ Swagger에서 바로 DB 쓰기를 확인할 때는 인증 없이 호출 가능
NODE_ENV=production npm run check:production-env
```

현재 운영 서버의 수동 배포 절차와 GitHub Actions 시크릿 구성은 [운영 배포 안내](docs/production-deployment.md)를 따릅니다.

## Scripts

```bash
Expand Down
1 change: 1 addition & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ services:
MODERATION_SWEEP_INTERVAL_MS: ${MODERATION_SWEEP_INTERVAL_MS:-900000}
APP_REVIEW_EMAIL: ${APP_REVIEW_EMAIL}
APP_REVIEW_PASSWORD: ${APP_REVIEW_PASSWORD}
SUPPORT_EMAIL: ${SUPPORT_EMAIL}
REQUEST_BODY_LIMIT: ${REQUEST_BODY_LIMIT:-1mb}
MOMENT_PHOTO_MAX_FILE_SIZE_MB: ${MOMENT_PHOTO_MAX_FILE_SIZE_MB:-10}
REVERSE_GEOCODING_BASE_URL: ${REVERSE_GEOCODING_BASE_URL:-https://nominatim.openstreetmap.org}
Expand Down
Loading
Loading