Skip to content
Merged
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
115 changes: 115 additions & 0 deletions .github/workflows/deploy-server.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Deploy Server

# apps/server 변경이 main에 병합되면 자동 배포:
# arm64 이미지 빌드 → ECR push → EC2 재배포(SSM) → prisma migrate deploy
# web은 Vercel Git 연동으로 자동 배포되므로 별도 처리 없음.

on:
push:
branches:
- main
paths:
- "apps/server/**"
- "packages/**"
- "pnpm-lock.yaml"
- ".github/workflows/deploy-server.yml"
workflow_dispatch: {} # 수동 실행 허용

# OIDC로 AWS 역할 assume (키 저장 없음)
permissions:
id-token: write
contents: read

env:
AWS_REGION: ap-northeast-2
AWS_ROLE_ARN: arn:aws:iam::671460502594:role/dookmark-gha-deploy
ECR_REPOSITORY: dookmark-server
INSTANCE_TAG: dookmark-server
CONTAINER_NAME: dookmark-server

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}

- name: Login to Amazon ECR
id: ecr
uses: aws-actions/amazon-ecr-login@v2

- name: Set up QEMU (arm64 emulation)
uses: docker/setup-qemu-action@v3

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

- name: Build and push (linux/arm64)
uses: docker/build-push-action@v6
with:
context: .
file: apps/server/Dockerfile
platforms: linux/arm64
push: true
tags: |
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:latest
${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Deploy to EC2 via SSM
run: |
set -euo pipefail
REGISTRY="${{ steps.ecr.outputs.registry }}"
ECR_URL="$REGISTRY/${{ env.ECR_REPOSITORY }}"

INSTANCE_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=${{ env.INSTANCE_TAG }}" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].InstanceId' --output text)
echo "Target instance: $INSTANCE_ID"

# SSM 명령 JSON을 jq로 안전하게 조립(이스케이프 처리)
PARAMS=$(jq -n --arg reg "$REGISTRY" --arg url "$ECR_URL" --arg region "${{ env.AWS_REGION }}" --arg name "${{ env.CONTAINER_NAME }}" '{
commands: [
"set -e",
("aws ecr get-login-password --region " + $region + " | docker login --username AWS --password-stdin " + $reg),
("docker pull " + $url + ":latest"),
("docker rm -f " + $name + " 2>/dev/null || true"),
("docker run -d --name " + $name + " --restart always -p 3001:3001 --env-file /etc/dookmark/server.env " + $url + ":latest"),
("docker exec -w /app/apps/server " + $name + " pnpm exec prisma migrate deploy"),
"sleep 8",
"curl -fsS http://localhost:3001/health"
]
}')

CMD_ID=$(aws ssm send-command \
--instance-ids "$INSTANCE_ID" \
--document-name AWS-RunShellScript \
--comment "GHA deploy ${{ github.sha }}" \
--parameters "$PARAMS" \
--query 'Command.CommandId' --output text)
echo "SSM command: $CMD_ID"

# 완료 대기 + 결과 판정
for i in $(seq 1 50); do
ST=$(aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query Status --output text 2>/dev/null || echo Pending)
echo "status: $ST"
case "$ST" in
Success) break ;;
Failed|Cancelled|TimedOut)
echo "::error::deploy failed"
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query StandardErrorContent --output text
exit 1 ;;
esac
sleep 6
done

echo "----- deploy output -----"
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$INSTANCE_ID" --query StandardOutputContent --output text
2 changes: 2 additions & 0 deletions apps/server/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ export class AuthController {

private getCookieOptions(maxAge?: number) {
const isProd = process.env.NODE_ENV === 'production';
const domain = process.env.COOKIE_DOMAIN;
return {
httpOnly: true,
secure: isProd,
sameSite: (isProd ? 'none' : 'lax') as 'none' | 'lax',
...(domain ? { domain } : {}),
...(maxAge !== undefined ? { maxAge } : {}),
};
}
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { Pool } from 'pg';
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
constructor() {
const connectionString = process.env.DATABASE_URL;
const pool = new Pool({ connectionString });

const isLocal = !connectionString || /@(localhost|127\.0\.0\.1)/.test(connectionString);
const pool = new Pool({
connectionString,
...(isLocal ? {} : { ssl: { rejectUnauthorized: false } }),
});
const adapter = new PrismaPg(pool);
super({ adapter });
}
Expand Down
10 changes: 10 additions & 0 deletions infra/terraform/envs/prod/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ locals {
plain_parameters = {
GOOGLE_CALLBACK_URL = var.google_callback_url
FRONTEND_URL = var.frontend_url
COOKIE_DOMAIN = ".dookmark.site" # web↔api 쿠키 공유
NODE_ENV = "production"
PORT = "3001"
REDIS_HOST = module.data.redis_address
Expand Down Expand Up @@ -61,6 +62,15 @@ module "ecr" {
name = "dookmark"
}

# cicd 모듈 — GitHub Actions OIDC + 배포 전용 IAM 역할 (키 저장 없이 배포)
module "cicd" {
source = "../../modules/cicd"

name = "dookmark"
github_repo = "Nohgh/dookmark"
ecr_repository_arn = module.ecr.repository_arn
}

# compute 모듈 — EC2(Docker), ALB, Target Group, ACM
# network/ecr 출력을 참조로 연결. SSM은 secrets 모듈과 같은 경로
module "compute" {
Expand Down
14 changes: 9 additions & 5 deletions infra/terraform/envs/prod/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,23 @@ output "redis_address" {
value = module.data.redis_address
}

# 비밀번호는 민감값 → 화면에 안 찍힘. 값 확인은:
# terraform output -raw db_password

output "db_password" {
value = module.data.db_password
sensitive = true
}

# ── secrets 모듈 (Phase 5) ──
# secrets 모듈
output "ssm_parameter_names" {
value = module.secrets.parameter_names
}

# ── ecr 모듈 (Phase 6) ──
# ecr 모듈
output "ecr_repository_url" {
value = module.ecr.repository_url
}

# ── compute 모듈 (Phase 7) ──
# compute 모듈
output "alb_dns_name" {
value = module.compute.alb_dns_name
}
Expand All @@ -57,3 +56,8 @@ output "acm_validation_records" {
description = "가비아에 등록할 ACM 검증 CNAME (Phase 8)"
value = module.compute.acm_validation_records
}

# cicd 모듈
output "github_deploy_role_arn" {
value = module.cicd.deploy_role_arn
}
87 changes: 87 additions & 0 deletions infra/terraform/modules/cicd/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
data "aws_caller_identity" "current" {}

# GitHub Actions OIDC 신뢰 공급자 (계정당 1개)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

# 배포 역할 신뢰 정책 — 이 저장소의 main 브랜치 워크플로만 assume 가능
data "aws_iam_policy_document" "assume" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repo}:ref:refs/heads/main"]
}
}
}

resource "aws_iam_role" "deploy" {
name = "${var.name}-gha-deploy"
assume_role_policy = data.aws_iam_policy_document.assume.json
}

# 배포에 필요한 권한만: ECR push + SSM 배포 명령
data "aws_iam_policy_document" "deploy" {
statement {
sid = "EcrAuth"
actions = ["ecr:GetAuthorizationToken"]
resources = ["*"]
}

statement {
sid = "EcrPushPull"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
"ecr:PutImage",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
]
resources = [var.ecr_repository_arn]
}

statement {
sid = "SsmSendCommandDocument"
actions = ["ssm:SendCommand"]
resources = ["arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:document/AWS-RunShellScript"]
}

# 인스턴스 대상 SendCommand는 Name 태그로 제한(dookmark-server만)
statement {
sid = "SsmSendCommandInstance"
actions = ["ssm:SendCommand"]
resources = ["arn:aws:ec2:${var.region}:${data.aws_caller_identity.current.account_id}:instance/*"]
condition {
test = "StringEquals"
variable = "ssm:resourceTag/Name"
values = ["${var.name}-server"]
}
}

statement {
sid = "SsmReadAndDescribe"
actions = ["ssm:GetCommandInvocation", "ssm:ListCommandInvocations", "ec2:DescribeInstances"]
resources = ["*"]
}
}

resource "aws_iam_role_policy" "deploy" {
name = "${var.name}-gha-deploy-policy"
role = aws_iam_role.deploy.id
policy = data.aws_iam_policy_document.deploy.json
}
4 changes: 4 additions & 0 deletions infra/terraform/modules/cicd/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
output "deploy_role_arn" {
description = "GitHub Actions가 assume할 배포 역할 ARN (워크플로 role-to-assume)"
value = aws_iam_role.deploy.arn
}
20 changes: 20 additions & 0 deletions infra/terraform/modules/cicd/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
variable "name" {
description = "리소스 이름 접두사"
type = string
default = "dookmark"
}

variable "region" {
type = string
default = "ap-northeast-2"
}

variable "github_repo" {
description = "GitHub 저장소 (owner/repo). 이 저장소의 main 워크플로만 역할을 assume 가능"
type = string
}

variable "ecr_repository_arn" {
description = "배포 대상 ECR 리포지토리 ARN (push 권한 범위)"
type = string
}
7 changes: 7 additions & 0 deletions infra/terraform/modules/compute/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ resource "aws_instance" "server" {
encrypted = true
}

# "최신 AL2023" AMI는 AWS가 새로 배포하면 값이 바뀌어, 매 apply마다
# 인스턴스를 교체(재생성)해버린다. AMI 변경은 무시해 의도치 않은 교체를 막는다.
# (실제로 새 AMI로 갈아탈 땐 인스턴스를 명시적으로 taint/교체)
lifecycle {
ignore_changes = [ami]
}

tags = { Name = "${var.name}-server" }
}

Expand Down
Loading