CI/CD 파이프라인 구축
코드 변경을 검증하고 불변 이미지 digest로 게시한 뒤, 보호된 환경 승인과 제한된 헬스 게이트를 거쳐 동일한 digest를 배포합니다.
지난 절에서는 NestJS 애플리케이션을 Docker 이미지로 만드는 방법을 알아보았습니다.
이번 절에서는 그 이미지를 임의로 다시 만들지 않고, 소스 검증부터 운영 배포까지 하나의 추적 가능한 경로로 연결합니다. 예시는 GitHub Actions와 Docker Hub, 단일 EC2 호스트를 사용하지만 핵심 원칙은 다른 레지스트리와 배포 플랫폼에서도 같습니다.
NestJS · quality gate · flowchart
검증한 소스에서 만든 digest 하나만 운영까지 이동한다
PR은 검증에서 멈추고, main만 이미지를 한 번 게시합니다. 운영에는 태그가 아니라 게시 결과의 동일한 digest를 전달하며, 헬스 실패는 자동 복구가 아니라 명시적인 이전 digest 재배포 신호입니다.
01 · verify or stop
소스는 설치·lint·test·build를 모두 통과해야 한다
PR과 main SHA에 같은 검증을 적용합니다. 한 단계라도 실패하면 이미지 게시와 운영 배포는 시작되지 않습니다.
02 · event boundary
PR은 검증에서 끝나고 main만 게시한다
PR job에는 Registry 자격 증명과 운영 secret을 주지 않습니다. 통과한 main push만 이미지를 빌드합니다.
03 · immutable delivery
Registry가 돌려준 digest를 그대로 배포한다
커밋 태그는 추적용 이름입니다. 보호된 production job은 승인 후 정확한 repository@sha256 값을 서버의 두 Compose 명령에 전달합니다.
04 · bounded outcome
제한된 확인 뒤 유지하거나 명시적으로 복구한다
헬스 요청은 2초 타임아웃으로 최대 12회 실행하고 5초씩 기다립니다. 성공하면 유지하고, 실패하면 job을 실패 처리합니다.
자동 롤백은 구현되지 않았습니다. 운영자가 이전에 승인한 digest를 같은 보호 경로로 다시 배포합니다.
- 필수 품질 게이트
- 정상 릴리스 유지
- 중단 또는 명시적 복구
파이프라인이 보장하는 것은 “다시 빌드해 비슷한 이미지”가 아니라, 게시한 동일 digest가 승인과 배포 기록을 지나간다는 사실입니다.
NestJS에서 CI/CD란 무엇인가?
CI/CD는 코드를 자주 합치고, 기계적인 품질 검사를 반복하며, 검증 결과와 배포 기록을 연결하는 개발 관행입니다.
지속적 통합(Continuous Integration)
Pull Request와 main push는 같은 검증 job에서 다음 단계를 순서대로 통과합니다.
npm ci: lockfile과 일치하는 의존성 설치npm run lint -- --no-fix: CI에서 소스를 고치지 않는 정적 검사npm run test -- --runInBand: 테스트 실행npm run build: TypeScript 컴파일과 NestJS 빌드 확인
하나라도 실패하면 이후 이미지 게시와 배포는 시작되지 않습니다. PR은 이 검증 결과까지만 얻으며 레지스트리 자격 증명이나 운영 비밀에 접근하지 않습니다.
지속적 제공(Continuous Delivery)
검증을 통과한 main 커밋은 컨테이너 이미지를 한 번 빌드해 레지스트리에 게시합니다. 태그는 커밋을 찾기 위한 이름이고, 실제 배포 입력은 게시 결과가 돌려준 sha256:... digest입니다.
운영 job이 GitHub의 보호된 production environment 승인을 기다린다면 이 파이프라인은 지속적 제공입니다. 배포 가능한 산출물은 자동으로 준비하지만 운영 반영에는 명시적인 승인이 남아 있기 때문입니다.
지속적 배포(Continuous Deployment)
승인 절차까지 자동 정책으로 대체하고, 모든 검증을 통과한 변경을 사람의 개입 없이 운영에 반영할 때 지속적 배포라고 부릅니다. 이 절의 예시는 보호된 환경 승인을 사용하므로 지속적 배포라고 주장하지 않습니다.
품질 게이트와 배포 불변성
파이프라인의 중요한 경계는 다음과 같습니다.
소스 이벤트: Pull Request와 main push가 동일한 커밋 SHA를 검증 대상으로 만듭니다.
품질 검증: 설치, lint, test, build가 모두 성공해야 다음 job이 실행됩니다.
이미지 게시: main push만 이미지를 빌드하고 Registry가 기록한 불변 digest를 출력합니다.
보호된 배포: 승인된 production environment job이 이미지 태그가 아니라 정확한 digest를 서버에 전달합니다.
제한된 헬스 게이트: 정해진 횟수와 타임아웃 안에서 상태를 확인합니다. 성공하면 새 릴리스를 유지하고, 실패하면 job을 실패 처리합니다.
명시적 복구: 이 예시는 자동 롤백을 구현하지 않습니다. 실패 시 운영자가 배포 기록에서 이전에 승인한 digest를 선택해 같은 보호 경로로 다시 배포합니다.
latest 같은 가변 태그를 운영 배포 입력으로 사용하면 승인한 이미지와 실제로 내려받는 이미지가 달라질 수 있습니다. 이 절에서는 이미지 게시 job의 digest 출력을 배포 job까지 그대로 전달해 그 우회를 막습니다.
실행 전 준비
- GitHub 저장소에 NestJS 프로젝트,
package-lock.json,Dockerfile,.dockerignore,compose.yml이 있어야 합니다. compose.yml의app.image는${IMAGE:?IMAGE digest is required}처럼 외부IMAGE값을 참조해야 합니다.- EC2 호스트의
/opt/nestjs-app에는 운영용compose.yml과 서버에서 관리하는 설정이 있어야 합니다. - EC2 호스트에는 현재
docker compose명령과curl이 설치되어 있어야 합니다. - Docker Hub에는 비밀번호 대신 권한을 제한한 액세스 토큰을 사용합니다.
- SSH 호스트 키는 신뢰할 수 있는 경로에서 미리 확인하고
known_hosts한 줄 형식으로 보관합니다. 실행 중ssh-keyscan으로 즉석 신뢰하지 않습니다.
현재 예시는 프로젝트 런타임으로 Node.js 24 LTS를 사용합니다. actions/checkout@v7과 actions/setup-node@v6도 Node 24 기반의 현재 메이저이며, Docker 공식 액션은 docker/login-action@v4, docker/build-push-action@v7을 사용합니다.
GitHub Actions 워크플로우
.github/workflows/main.yml에 다음 워크플로우를 작성합니다. 이 파일에는 YAML 코드 fence가 하나만 있으며, PR 검증과 main 이미지 게시, 보호된 운영 배포를 분리합니다.
name: NestJS quality gate and deploy
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
NODE_VERSION: '24'
IMAGE_NAME: nestjs-app
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install locked dependencies
run: npm ci
- name: Lint without rewriting source
run: npm run lint -- --no-fix
- name: Test
run: npm run test -- --runInBand
- name: Build NestJS
run: npm run build
image:
needs: verify
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
repository: ${{ steps.image-meta.outputs.repository }}
digest: ${{ steps.image-build.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Define image repository
id: image-meta
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: echo "repository=docker.io/${DOCKER_USERNAME}/${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push once
id: image-build
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.image-meta.outputs.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: image
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: production
concurrency:
group: production
cancel-in-progress: false
steps:
- name: Deploy exact digest and run bounded health gate
env:
EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USERNAME: ${{ secrets.EC2_USERNAME }}
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_SSH_KNOWN_HOSTS: ${{ secrets.EC2_SSH_KNOWN_HOSTS }}
IMAGE_REF: ${{ needs.image.outputs.repository }}@${{ needs.image.outputs.digest }}
run: |
install -d -m 700 "$HOME/.ssh"
printf '%s\n' "$EC2_SSH_KEY" > "$HOME/.ssh/id_ed25519"
chmod 600 "$HOME/.ssh/id_ed25519"
printf '%s\n' "$EC2_SSH_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
chmod 600 "$HOME/.ssh/known_hosts"
ssh \
-i "$HOME/.ssh/id_ed25519" \
-o BatchMode=yes \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=10 \
-o ServerAliveInterval=15 \
-o ServerAliveCountMax=2 \
"${EC2_USERNAME}@${EC2_HOST}" \
bash -s -- "$IMAGE_REF" <<'REMOTE'
set -euo pipefail
IMAGE_REF="$1"
cd /opt/nestjs-app
sudo env IMAGE="$IMAGE_REF" docker compose pull app
sudo env IMAGE="$IMAGE_REF" docker compose up -d --no-deps app
healthy=false
for attempt in $(seq 1 12); do
if curl --fail --silent --show-error --max-time 2 \
http://127.0.0.1:3000/health > /dev/null; then
healthy=true
break
fi
sleep 5
done
if [ "$healthy" != "true" ]; then
echo "Health gate failed; release is not accepted."
echo "Rollback is explicit: redeploy a previously approved digest."
exit 1
fi
REMOTE워크플로우를 읽는 법
verify는 PR과 main에서 모두 실행됩니다. 반면 image는 main push 조건을 다시 확인하므로 PR에서는 Docker Hub 비밀이 열리지 않고 이미지도 게시되지 않습니다.
docker/build-push-action의 digest 출력은 Registry에 게시된 manifest를 가리킵니다. 배포 job은 repository@digest를 IMAGE_REF로 만들고, 서버의 docker compose pull과 up에 같은 값을 전달합니다. 두 명령 모두 sudo env IMAGE="$IMAGE_REF" ... 형태이므로 sudo가 기본 환경을 정리하더라도 필요한 이미지 값이 명시적으로 전달됩니다.
배포는 서드파티 SSH action 대신 GitHub 호스팅 러너의 OpenSSH 클라이언트를 직접 사용합니다. 서드파티 action을 사용해야 한다면 변경 가능한 태그가 아니라 검토한 전체 commit SHA에 고정해야 합니다.
헬스 확인은 요청당 2초 타임아웃, 최대 12회, 재시도 사이 5초 대기로 제한됩니다. 모두 실패하면 job과 GitHub deployment 기록이 실패하지만, 위 스크립트는 이전 컨테이너로 자동 복귀하지 않습니다. 자동 롤백이라고 설명하려면 이전 digest 저장, 전환, 재검증까지 별도로 구현해야 합니다.
비밀과 승인 경계 설정
Docker Hub 자격 증명은 저장소 Actions secret으로 설정하되 main 전용 image job에서만 참조합니다.
DOCKER_USERNAME: Docker Hub 사용자 이름DOCKERHUB_TOKEN: 이미지 push 범위만 가진 액세스 토큰
EC2 접속 값은 저장소 공용 secret이 아니라 production environment secret으로 설정합니다.
EC2_HOST: 운영 호스트 주소EC2_USERNAME: 제한된 운영 사용자EC2_SSH_KEY: 해당 사용자용 SSH 개인 키EC2_SSH_KNOWN_HOSTS: 사전에 확인한 호스트 공개 키의known_hosts항목
production environment에는 필요한 경우 required reviewers, self-review 방지, main만 허용하는 deployment branch 규칙을 설정하고 관리자 우회도 허용하지 않습니다. 승인 대기 중인 job은 environment secret에 접근할 수 없으므로 승인 전에 운영 비밀이 열리지 않는 경계가 만들어집니다. 사용할 수 있는 보호 규칙은 GitHub 요금제와 저장소 공개 범위에 따라 다를 수 있습니다.
운영 DB 비밀번호와 JWT 비밀값은 이미지나 GitHub workflow에 넣지 않습니다. 서버의 제한된 env 파일, AWS Secrets Manager, SSM Parameter Store, Vault 같은 운영 비밀 저장소에서 런타임에 주입합니다.
GitHub Actions · secure paved road · architecture
승인 전에는 운영 비밀이 열리지 않고, 승인 뒤에도 digest는 바뀌지 않는다
PR 검증, main 게시, 보호된 운영 배포를 서로 다른 권한 경계로 둡니다. Registry는 불변 digest를 전달하고, environment 승인은 비밀 접근과 배포를 함께 통제하며, 로그·deployment 기록·운영 관측이 결과를 남깁니다.
01 · pull request
PR은 같은 품질 게이트를 통과하고 검증에서 멈춘다
npm ci, lint, test, build 결과와 로그만 남깁니다. Registry token과 운영 environment secret은 열리지 않습니다.
02 · main publish
main만 이미지를 한 번 게시하고 digest를 남긴다
커밋 태그는 추적용이고, 다음 경계로 전달되는 값은 Registry가 반환한 불변 repository@sha256입니다.
03 · approval before secret
보호된 production 승인이 먼저다
required reviewer, self-review 방지, main 제한과 관리자 우회 금지 정책을 적용합니다. 승인된 job만 EC2 environment secret에 접근합니다.
04 · exact deploy / evidence
같은 digest를 배포하고 기록과 운영 신호를 연결한다
Compose는 명시적으로 전달받은 digest를 pull·up합니다. GitHub job과 deployment 기록, 제한된 헬스 결과, 외부 로그·지표가 감사와 관측 근거가 됩니다.
05 · forbidden bypass
PR 직접 배포와 가변 태그 배포는 경계 전에 중단한다
latest나 승인되지 않은 경로는 소스, digest, 승인, 배포 사이의 연결을 끊습니다.
헬스 실패는 자동 롤백이 아닙니다. 이전에 승인한 digest의 수동 재배포도 같은 보호 경로를 거칩니다.
- 승인과 비밀 경계
- 기록·정책 경로
- 금지된 우회
권한은 job별로 작게 열고, 운영 비밀은 environment 승인 뒤에만 열며, 배포 값은 repository@digest로 끝까지 고정합니다.
감사, 관측, 복구
GitHub Actions 로그와 environment deployment 기록은 누가 어떤 커밋에서 어떤 digest를 게시하고 승인했는지 추적하는 출발점입니다. 애플리케이션 로그, 지표, 경보와 지속적인 가용성 관측은 별도의 운영 시스템이 담당해야 합니다. 배포 직후의 제한된 헬스 게이트만으로 이후 가용성을 보장할 수는 없습니다.
복구 절차는 다음처럼 명시적으로 운영합니다.
- 실패한 deployment와 헬스 로그를 확인합니다.
- 이전에 승인되어 정상 동작한
repository@sha256:...값을 배포 기록에서 선택합니다. - 동일한
production승인 경로로 그 digest를 다시 배포합니다. - 제한된 헬스 게이트와 외부 모니터링을 다시 확인합니다.
PR에서 운영 job을 직접 호출하는 경로, 승인되지 않은 job이 environment secret을 읽는 경로, latest 태그로 digest를 바꾸는 경로는 허용하지 않습니다. 편의를 위한 우회 하나가 소스·산출물·승인·배포 기록의 연결을 끊기 때문입니다.
CI/CD 파이프라인의 목적은 단계 수를 늘리는 것이 아니라, 실패해야 할 지점에서 빠르게 멈추고 승인한 동일 산출물만 다음 경계로 이동시키는 것입니다.
NestJS 프로젝트에서는 npm ci → lint → test → build를 먼저 고정하고, 그 위에 불변 이미지 digest, 보호된 운영 승인, 제한된 헬스 확인, 명시적 복구와 관측을 한 층씩 더하면 됩니다.